From de699bf319db5aae1e85831596fb3c4c06b9ee37 Mon Sep 17 00:00:00 2001 From: Naveen Chatlapalli Date: Thu, 22 Oct 2026 10:02:56 -0500 Subject: [PATCH] Pin stdio MCP package versions from CLI through gateway --- apps/cli/README.md | 8 +++ .../__snapshots__/cli-commands.test.ts.snap | 2 + apps/cli/src/commands/mcp.test.ts | 57 +++++++++++++++++++ apps/cli/src/commands/mcp.ts | 6 ++ docs/architecture/mcp-gateway.md | 7 +++ .../shared/src/mcp/mcp-gateway-client.test.ts | 15 +++++ packages/shared/src/mcp/mcp-gateway-client.ts | 12 +++- 7 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 apps/cli/src/commands/mcp.test.ts diff --git a/apps/cli/README.md b/apps/cli/README.md index b40bc2b5d..0c2914c87 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -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, diff --git a/apps/cli/src/__snapshots__/cli-commands.test.ts.snap b/apps/cli/src/__snapshots__/cli-commands.test.ts.snap index 66f3e414c..2efa9c512 100644 --- a/apps/cli/src/__snapshots__/cli-commands.test.ts.snap +++ b/apps/cli/src/__snapshots__/cli-commands.test.ts.snap @@ -324,6 +324,7 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = ` "--header", "--id", "--name", + "--pkg-version", "--project", "--type", "--url", @@ -361,6 +362,7 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = ` "--header", "--id", "--name", + "--pkg-version", "--project", "--type", "--url", diff --git a/apps/cli/src/commands/mcp.test.ts b/apps/cli/src/commands/mcp.test.ts new file mode 100644 index 000000000..8919a9c0a --- /dev/null +++ b/apps/cli/src/commands/mcp.test.ts @@ -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 { + 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"); + }); +}); diff --git a/apps/cli/src/commands/mcp.ts b/apps/cli/src/commands/mcp.ts index 7e9713374..69270d316 100644 --- a/apps/cli/src/commands/mcp.ts +++ b/apps/cli/src/commands/mcp.ts @@ -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' }, @@ -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)) { @@ -144,6 +146,7 @@ mcpServer .option("--url ", "Server URL (required for sse/http)") .option("--command ", "Executable to spawn (required for stdio)") .option("--args ", "Space-separated CLI arguments for stdio command") + .option("--pkg-version ", "Package version for stdio command") .option("--env ", "Environment variables in KEY=VALUE format (repeatable)") .option("--description ", "Description") .option("--header ", "Headers in name:value format (repeatable)") @@ -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; @@ -210,6 +214,7 @@ mcpServer .option("--url ", "Server URL") .option("--command ", "Executable to spawn (stdio)") .option("--args ", "Space-separated CLI arguments for stdio command") + .option("--pkg-version ", "Package version for stdio command") .option("--env ", "Environment variables in KEY=VALUE format (replaces all env vars)") .option("--description ", "Description") .option("--header ", "Headers in name:value format (replaces all headers)") @@ -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); diff --git a/docs/architecture/mcp-gateway.md b/docs/architecture/mcp-gateway.md index 1a1cd1ba2..c924adc1f 100644 --- a/docs/architecture/mcp-gateway.md +++ b/docs/architecture/mcp-gateway.md @@ -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 ` or +`scope mcp server update --id --pkg-version ` to set the pin; +`scope mcp server get` displays it. --- diff --git a/packages/shared/src/mcp/mcp-gateway-client.test.ts b/packages/shared/src/mcp/mcp-gateway-client.test.ts index 578d3a44b..e8d7402db 100644 --- a/packages/shared/src/mcp/mcp-gateway-client.test.ts +++ b/packages/shared/src/mcp/mcp-gateway-client.test.ts @@ -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()); diff --git a/packages/shared/src/mcp/mcp-gateway-client.ts b/packages/shared/src/mcp/mcp-gateway-client.ts index ff41aa565..8af96f0cd 100644 --- a/packages/shared/src/mcp/mcp-gateway-client.ts +++ b/packages/shared/src/mcp/mcp-gateway-client.ts @@ -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 {