Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ scope --help
scope run --help
```

For stdio MCP servers, `--pkg-version` pins the package used by the gateway:

```bash
scope mcp server create --id time --name Time --type stdio --command uvx --args mcp-server-time --pkg-version 1.2.3
scope mcp server update --id time --pkg-version 1.2.4
scope mcp server get --id time
```

### Provider secrets

`scope secret` manages provider credentials (GitHub tokens, Anthropic keys,
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/__snapshots__/cli-commands.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = `
"--header",
"--id",
"--name",
"--pkg-version",
"--project",
"--type",
"--url",
Expand Down Expand Up @@ -361,6 +362,7 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = `
"--header",
"--id",
"--name",
"--pkg-version",
"--project",
"--type",
"--url",
Expand Down
57 changes: 57 additions & 0 deletions apps/cli/src/commands/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import { registerMcpCommands } from "./mcp.js";
import { resetApiClient } from "../utils/api-client.js";

const requests: Array<{ method: string; body: string }> = [];
const lines: string[] = [];

async function run(...args: string[]): Promise<void> {
const program = new Command().exitOverride();
registerMcpCommands(program);
await program.parseAsync(["mcp", "server", ...args, "--project", "project-1"], { from: "user" });
}

beforeEach(() => {
resetApiClient();
requests.length = 0;
lines.length = 0;
vi.stubEnv("SCOPE_API_URL", "http://localhost:3100");
vi.stubGlobal("fetch", vi.fn(async (request: Request) => {
requests.push({ method: request.method, body: await request.clone().text() });
return new Response(JSON.stringify({ _id: "time", name: "Time", type: "stdio", command: "uvx", version: "1.2.3", createdAt: "2026-01-01T00:00:00Z" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}));
vi.spyOn(console, "log").mockImplementation((...values) => lines.push(values.map(String).join(" ")));
});

afterEach(() => {
resetApiClient();
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});

describe("MCP package version CLI", () => {
it("sends the version when creating a stdio server", async () => {
await run("create", "--id", "time", "--name", "Time", "--type", "stdio", "--command", "uvx", "--args", "mcp-server-time", "--pkg-version", "1.2.3");
expect(requests[0].method).toBe("POST");
expect(JSON.parse(requests[0].body).version).toBe("1.2.3");
});

it("sends the version when updating a server", async () => {
await run("update", "--id", "time", "--pkg-version", "1.2.3");
expect(requests[0].method).toBe("PUT");
expect(JSON.parse(requests[0].body)).toEqual({ version: "1.2.3" });
});

it("displays the stored version", async () => {
await run("get", "--id", "time");
expect(lines.join("\n")).toContain("1.2.3");
});
});
6 changes: 6 additions & 0 deletions apps/cli/src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ mcpServer
{ key: 'name', label: 'Name' },
{ key: 'type', label: 'Type' },
{ key: 'url', label: 'URL' },
{ key: 'version', label: 'Package Version' },
{ key: 'description', label: 'Description', formatter: (s: any) => s.description || '' },
{ key: 'createdAt', label: 'Created' },
{ key: 'updatedAt', label: 'Updated' },
Expand All @@ -111,6 +112,7 @@ mcpServer
if (server.args && server.args.length > 0) {
console.log(`${label('Args:')} ${value(server.args.join(' '))}`);
}
if (server.version) console.log(`${label('Package version:')} ${value(server.version)}`);
if (server.env && Object.keys(server.env).length > 0) {
console.log(`${label('Env:')}`);
for (const [k, v] of Object.entries(server.env)) {
Expand Down Expand Up @@ -144,6 +146,7 @@ mcpServer
.option("--url <url>", "Server URL (required for sse/http)")
.option("--command <command>", "Executable to spawn (required for stdio)")
.option("--args <args>", "Space-separated CLI arguments for stdio command")
.option("--pkg-version <version>", "Package version for stdio command")
.option("--env <env...>", "Environment variables in KEY=VALUE format (repeatable)")
.option("--description <desc>", "Description")
.option("--header <header...>", "Headers in name:value format (repeatable)")
Expand Down Expand Up @@ -174,6 +177,7 @@ mcpServer
if (isStdio) {
body.command = options.command;
if (options.args) body.args = options.args.trim().split(/\s+/);
if (options.pkgVersion) body.version = options.pkgVersion;
if (env && Object.keys(env).length > 0) body.env = env;
} else {
body.url = options.url;
Expand Down Expand Up @@ -210,6 +214,7 @@ mcpServer
.option("--url <url>", "Server URL")
.option("--command <command>", "Executable to spawn (stdio)")
.option("--args <args>", "Space-separated CLI arguments for stdio command")
.option("--pkg-version <version>", "Package version for stdio command")
.option("--env <env...>", "Environment variables in KEY=VALUE format (replaces all env vars)")
.option("--description <desc>", "Description")
.option("--header <header...>", "Headers in name:value format (replaces all headers)")
Expand All @@ -224,6 +229,7 @@ mcpServer
if (options.url) body.url = options.url;
if (options.command) body.command = options.command;
if (options.args) body.args = options.args.trim().split(/\s+/);
if (options.pkgVersion) body.version = options.pkgVersion;
if (options.env) body.env = parseEnvPairs(options.env);
if (options.description) body.description = options.description;
if (options.header) body.headers = parseHeaderPairs(options.header);
Expand Down
7 changes: 7 additions & 0 deletions docs/architecture/mcp-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ Registration payload varies by transport:
```

Tool names are namespaced: `{server-name}__{tool-name}` (e.g. `filesystem__read_text_file`).
For stdio servers, a stored `version` pins the executable package argument during
registration (`npx -y @scope/server@1.2.3` or `uvx server@1.2.3`). The server slug,
and therefore the names exposed to coding agents, stay unchanged. The gateway
is purged and re-registered for each run, so the current pin takes effect then.
Use `scope mcp server create --pkg-version <version>` or
`scope mcp server update --id <slug> --pkg-version <version>` to set the pin;
`scope mcp server get` displays it.

---

Expand Down
15 changes: 15 additions & 0 deletions packages/shared/src/mcp/mcp-gateway-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,21 @@ describe("McpGatewayClient", () => {
});
});

it.each([
["npx", ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], ["-y", "@modelcontextprotocol/server-filesystem@1.2.3", "/workspace"]],
["uvx", ["mcp-server-time"], ["mcp-server-time@1.2.3"]],
["npx", ["-y", "server-tools@0.9.0", "/workspace"], ["-y", "server-tools@1.2.3", "/workspace"]],
])("pins the %s package without changing the server name", async (command, args, expectedArgs) => {
mockFetch.mockResolvedValueOnce(okResponse());

await client.registerServer({ slug: "time", name: "Time", type: "stdio", command, args, version: "1.2.3" });

const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.name).toBe("time");
expect(body.args).toEqual(expectedArgs);
expect(args).not.toEqual(expectedArgs);
});

it("omits env from stdio body when empty", async () => {
mockFetch.mockResolvedValueOnce(okResponse());

Expand Down
12 changes: 11 additions & 1 deletion packages/shared/src/mcp/mcp-gateway-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,17 @@ export class McpGatewayClient {

if (config.type === 'stdio') {
body.command = config.command;
body.args = config.args ?? [];
const args = [...(config.args ?? [])];
if (config.version) {
// Ponytail: pin the executable package without changing the gateway name or tool names.
for (let index = args.length - 1; index >= 0; index--) {
const packageArg = args[index];
if (!/^(?:@[\w.-]+\/)?[\w.-]+(?:@[^\s/]+)?$/.test(packageArg) || packageArg.startsWith('-')) continue;
args[index] = `${packageArg.replace(/@[^@/]+$/, '')}@${config.version}`;
break;
}
}
body.args = args;
if (config.env && Object.keys(config.env).length > 0) body.env = config.env;
body.session_mode = config.sessionMode ?? 'stateful';
} else {
Expand Down