From b57d68b176e669c2dfcd9cda51d6600b9b5d76f2 Mon Sep 17 00:00:00 2001 From: Ben Sandler Date: Thu, 10 Sep 2026 17:15:01 -0400 Subject: [PATCH 1/5] Prevent MCP registration from launching the wrong npm package - Incur confuses the link-cli executable with its npm package, generating npx link-cli --mcp instead of @stripe/link-cli and potentially inheriting package references from project metadata. - Set the default MCP command from the trusted package name and version embedded at build time. - Add an isolated registration regression test and repair guidance for existing MCP registrations. - Leave the separate unsafe skills suggestion path for the upstream Incur follow-up. Committed-By-Agent: codex Co-authored-by: codex --- .changeset/safe-mcp-package.md | 6 ++ .../src/__tests__/mcp-registration.test.ts | 59 +++++++++++++++++++ packages/cli/src/cli.tsx | 3 + 3 files changed, 68 insertions(+) create mode 100644 .changeset/safe-mcp-package.md create mode 100644 packages/cli/src/__tests__/mcp-registration.test.ts diff --git a/.changeset/safe-mcp-package.md b/.changeset/safe-mcp-package.md new file mode 100644 index 00000000..6aec2d11 --- /dev/null +++ b/.changeset/safe-mcp-package.md @@ -0,0 +1,6 @@ +--- +'@stripe/link-cli': patch +--- + +Register the MCP server with the versioned `@stripe/link-cli` package. +Existing MCP registrations are not updated automatically; rerun `link-cli mcp add` after upgrading to replace the generated `link-cli` entry. diff --git a/packages/cli/src/__tests__/mcp-registration.test.ts b/packages/cli/src/__tests__/mcp-registration.test.ts new file mode 100644 index 00000000..2a5a1b4a --- /dev/null +++ b/packages/cli/src/__tests__/mcp-registration.test.ts @@ -0,0 +1,59 @@ +import { execFile } from 'node:child_process'; +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, describe, expect, it } from 'vitest'; + +const execFileAsync = promisify(execFile); +const CLI_PATH = new URL('../../dist/cli.js', import.meta.url).pathname; +const PACKAGE = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), +) as { version: string }; + +let testDirectory: string | undefined; + +afterEach(() => { + if (testDirectory) rmSync(testDirectory, { force: true, recursive: true }); + testDirectory = undefined; +}); + +describe('MCP registration', () => { + it('registers the versioned scoped package', async () => { + testDirectory = mkdtempSync(path.join(os.tmpdir(), 'link-cli-mcp-test-')); + const capturePath = path.join(testDirectory, 'arguments.json'); + const npxPath = path.join(testDirectory, 'npx'); + writeFileSync( + npxPath, + `#!/usr/bin/env node +import { writeFileSync } from 'node:fs'; +writeFileSync(process.env.LINK_MCP_TEST_CAPTURE, JSON.stringify(process.argv.slice(2))); +`, + ); + chmodSync(npxPath, 0o755); + + await execFileAsync( + process.execPath, + [CLI_PATH, 'mcp', 'add', '--agent', 'cursor'], + { + env: { + ...process.env, + LINK_MCP_TEST_CAPTURE: capturePath, + npm_config_user_agent: 'npm/10.0.0 node/v22.0.0', + PATH: `${testDirectory}:${process.env.PATH}`, + }, + }, + ); + + const argumentsPassed = JSON.parse(readFileSync(capturePath, 'utf8')); + expect(argumentsPassed).toContain( + `npx @stripe/link-cli@${PACKAGE.version} --mcp`, + ); + }); +}); diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index d33a2fd0..0298973a 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -67,6 +67,9 @@ const cli = Cli.create('link-cli', { description: 'Create a secure, one-time payment credential from a Link wallet to let agents complete purchases on behalf of users.', version: cliVersion, + mcp: { + command: `npx ${cliName}@${cliVersion} --mcp`, + }, sync: { include: ['skills/*'], }, From 3113a4b9b4ab2fb8a4910eaad6748be68538fb6e Mon Sep 17 00:00:00 2001 From: Ben Sandler Date: Thu, 10 Sep 2026 22:28:34 -0400 Subject: [PATCH 2/5] Preserve package runners in MCP registration - Hardcoded npx broke MCP registration in Bun- and pnpm-only environments. - Mirror Incur runner precedence while retaining the scoped, versioned Link CLI package. - Replace the subprocess regression test with compact npm, pnpm, Bun, and precedence coverage. Committed-By-Agent: codex Co-authored-by: codex --- .changeset/safe-mcp-package.md | 6 +- .../src/__tests__/mcp-registration.test.ts | 59 ------------------- packages/cli/src/cli.tsx | 3 +- .../utils/__tests__/package-runner.test.ts | 11 ++++ packages/cli/src/utils/package-runner.ts | 31 ++++++++++ 5 files changed, 48 insertions(+), 62 deletions(-) delete mode 100644 packages/cli/src/__tests__/mcp-registration.test.ts create mode 100644 packages/cli/src/utils/__tests__/package-runner.test.ts create mode 100644 packages/cli/src/utils/package-runner.ts diff --git a/.changeset/safe-mcp-package.md b/.changeset/safe-mcp-package.md index 6aec2d11..2fd60f36 100644 --- a/.changeset/safe-mcp-package.md +++ b/.changeset/safe-mcp-package.md @@ -2,5 +2,7 @@ '@stripe/link-cli': patch --- -Register the MCP server with the versioned `@stripe/link-cli` package. -Existing MCP registrations are not updated automatically; rerun `link-cli mcp add` after upgrading to replace the generated `link-cli` entry. +Register the MCP server with the detected package runner and versioned +`@stripe/link-cli` package. Existing MCP registrations are not updated +automatically; rerun `link-cli mcp add` after upgrading to replace the generated +`link-cli` entry. diff --git a/packages/cli/src/__tests__/mcp-registration.test.ts b/packages/cli/src/__tests__/mcp-registration.test.ts deleted file mode 100644 index 2a5a1b4a..00000000 --- a/packages/cli/src/__tests__/mcp-registration.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { execFile } from 'node:child_process'; -import { - chmodSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { afterEach, describe, expect, it } from 'vitest'; - -const execFileAsync = promisify(execFile); -const CLI_PATH = new URL('../../dist/cli.js', import.meta.url).pathname; -const PACKAGE = JSON.parse( - readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), -) as { version: string }; - -let testDirectory: string | undefined; - -afterEach(() => { - if (testDirectory) rmSync(testDirectory, { force: true, recursive: true }); - testDirectory = undefined; -}); - -describe('MCP registration', () => { - it('registers the versioned scoped package', async () => { - testDirectory = mkdtempSync(path.join(os.tmpdir(), 'link-cli-mcp-test-')); - const capturePath = path.join(testDirectory, 'arguments.json'); - const npxPath = path.join(testDirectory, 'npx'); - writeFileSync( - npxPath, - `#!/usr/bin/env node -import { writeFileSync } from 'node:fs'; -writeFileSync(process.env.LINK_MCP_TEST_CAPTURE, JSON.stringify(process.argv.slice(2))); -`, - ); - chmodSync(npxPath, 0o755); - - await execFileAsync( - process.execPath, - [CLI_PATH, 'mcp', 'add', '--agent', 'cursor'], - { - env: { - ...process.env, - LINK_MCP_TEST_CAPTURE: capturePath, - npm_config_user_agent: 'npm/10.0.0 node/v22.0.0', - PATH: `${testDirectory}:${process.env.PATH}`, - }, - }, - ); - - const argumentsPassed = JSON.parse(readFileSync(capturePath, 'utf8')); - expect(argumentsPassed).toContain( - `npx @stripe/link-cli@${PACKAGE.version} --mcp`, - ); - }); -}); diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 0298973a..8f1a1f1d 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -14,6 +14,7 @@ import { createSpendRequestCli } from './commands/spend-request'; import { createTransactionsCli } from './commands/transactions'; import { createUserInfoCli } from './commands/user-info'; import { createWebBotAuthCli } from './commands/web-bot-auth'; +import { detectPackageRunner } from './utils/package-runner'; import { ResourceFactory } from './utils/resource-factory'; import { createAgentUpdateInfoProvider, @@ -68,7 +69,7 @@ const cli = Cli.create('link-cli', { 'Create a secure, one-time payment credential from a Link wallet to let agents complete purchases on behalf of users.', version: cliVersion, mcp: { - command: `npx ${cliName}@${cliVersion} --mcp`, + command: `${detectPackageRunner()} ${cliName}@${cliVersion} --mcp`, }, sync: { include: ['skills/*'], diff --git a/packages/cli/src/utils/__tests__/package-runner.test.ts b/packages/cli/src/utils/__tests__/package-runner.test.ts new file mode 100644 index 00000000..c4e31675 --- /dev/null +++ b/packages/cli/src/utils/__tests__/package-runner.test.ts @@ -0,0 +1,11 @@ +import { expect, it } from 'vitest'; +import { detectPackageRunner } from '../package-runner'; + +it.each([ + ['npm user agent', ['npm/10.0.0'], 'npx'], + ['npm before a pnpm executable', ['npm/10.0.0', '/bin/pnpm'], 'npx'], + ['pnpm executable', ['', '/bin/pnpm.cjs'], 'pnpx'], + ['Bun entrypoint', ['', '', '/tmp/.bun/bin/cli'], 'bunx'], +])('detects the package runner from the %s', (_, hints, expected) => { + expect(detectPackageRunner(hints)).toBe(expected); +}); diff --git a/packages/cli/src/utils/package-runner.ts b/packages/cli/src/utils/package-runner.ts new file mode 100644 index 00000000..741d1cec --- /dev/null +++ b/packages/cli/src/utils/package-runner.ts @@ -0,0 +1,31 @@ +import { realpathSync } from 'node:fs'; + +function runnerFromHint(value: string) { + if (value.includes('pnpm')) return 'pnpx'; + if (value.includes('bun')) return 'bunx'; + if (value.includes('npm')) return 'npx'; +} + +// Mirrors Incur's unexported detector because `mcp.command` replaces its default command. +export function detectPackageRunner(hints?: readonly string[]) { + if (!hints) { + const userAgent = process.env.npm_config_user_agent ?? ''; + let entry = process.argv[1] ?? ''; + try { + entry = realpathSync(entry); + } catch {} + + hints = [ + /pnpm|bun/.test(userAgent) || userAgent.startsWith('npm/') + ? userAgent + : '', + process.env.npm_execpath ?? '', + /(?:\/\.pnpm\/|\\\.pnpm\\)/.test(entry) + ? 'pnpm' + : /(?:\/\.bun\/|\\bun\\)/.test(entry) + ? 'bun' + : '', + ]; + } + return hints.map(runnerFromHint).find(Boolean) ?? 'npx'; +} From d18d325e468529c9be0e5ce2b9b5b578ab22c7c7 Mon Sep 17 00:00:00 2001 From: Ben Sandler Date: Thu, 10 Sep 2026 22:39:08 -0400 Subject: [PATCH 3/5] Fix package runner lint - Use a local resolved hints variable to avoid reassigning the function parameter. - Preserve existing package-runner detection behavior and precedence. Committed-By-Agent: codex Co-authored-by: codex --- packages/cli/src/utils/package-runner.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/utils/package-runner.ts b/packages/cli/src/utils/package-runner.ts index 741d1cec..dd0c1123 100644 --- a/packages/cli/src/utils/package-runner.ts +++ b/packages/cli/src/utils/package-runner.ts @@ -8,14 +8,15 @@ function runnerFromHint(value: string) { // Mirrors Incur's unexported detector because `mcp.command` replaces its default command. export function detectPackageRunner(hints?: readonly string[]) { - if (!hints) { + let resolvedHints = hints; + if (!resolvedHints) { const userAgent = process.env.npm_config_user_agent ?? ''; let entry = process.argv[1] ?? ''; try { entry = realpathSync(entry); } catch {} - hints = [ + resolvedHints = [ /pnpm|bun/.test(userAgent) || userAgent.startsWith('npm/') ? userAgent : '', @@ -27,5 +28,5 @@ export function detectPackageRunner(hints?: readonly string[]) { : '', ]; } - return hints.map(runnerFromHint).find(Boolean) ?? 'npx'; + return resolvedHints.map(runnerFromHint).find(Boolean) ?? 'npx'; } From 1661d74e43cf5bfd3ec7a051abaa93144060ab6d Mon Sep 17 00:00:00 2001 From: Ben Sandler Date: Thu, 10 Sep 2026 22:57:11 -0400 Subject: [PATCH 4/5] Preserve standalone MCP execution - Register Node standalone builds with their current executable path. - Keep the detected package runner and pinned scoped package for managed installs. - Cover standalone registration and document the corrected behavior. Committed-By-Agent: codex Co-authored-by: codex --- .changeset/safe-mcp-package.md | 8 ++++---- packages/cli/src/cli.tsx | 4 ++-- .../src/utils/__tests__/package-runner.test.ts | 11 ++++++++++- packages/cli/src/utils/package-runner.ts | 16 ++++++++++++++++ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/.changeset/safe-mcp-package.md b/.changeset/safe-mcp-package.md index 2fd60f36..e66ed004 100644 --- a/.changeset/safe-mcp-package.md +++ b/.changeset/safe-mcp-package.md @@ -2,7 +2,7 @@ '@stripe/link-cli': patch --- -Register the MCP server with the detected package runner and versioned -`@stripe/link-cli` package. Existing MCP registrations are not updated -automatically; rerun `link-cli mcp add` after upgrading to replace the generated -`link-cli` entry. +Register the MCP server with its standalone executable when applicable, or the +detected package runner and versioned `@stripe/link-cli` package. Existing MCP +registrations are not updated automatically; rerun `link-cli mcp add` after +upgrading to replace the generated `link-cli` entry. diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 8f1a1f1d..ecdc577b 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -14,7 +14,7 @@ import { createSpendRequestCli } from './commands/spend-request'; import { createTransactionsCli } from './commands/transactions'; import { createUserInfoCli } from './commands/user-info'; import { createWebBotAuthCli } from './commands/web-bot-auth'; -import { detectPackageRunner } from './utils/package-runner'; +import { buildMcpCommand } from './utils/package-runner'; import { ResourceFactory } from './utils/resource-factory'; import { createAgentUpdateInfoProvider, @@ -69,7 +69,7 @@ const cli = Cli.create('link-cli', { 'Create a secure, one-time payment credential from a Link wallet to let agents complete purchases on behalf of users.', version: cliVersion, mcp: { - command: `${detectPackageRunner()} ${cliName}@${cliVersion} --mcp`, + command: buildMcpCommand(cliName, cliVersion), }, sync: { include: ['skills/*'], diff --git a/packages/cli/src/utils/__tests__/package-runner.test.ts b/packages/cli/src/utils/__tests__/package-runner.test.ts index c4e31675..03e79a55 100644 --- a/packages/cli/src/utils/__tests__/package-runner.test.ts +++ b/packages/cli/src/utils/__tests__/package-runner.test.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest'; -import { detectPackageRunner } from '../package-runner'; +import { buildMcpCommand, detectPackageRunner } from '../package-runner'; it.each([ ['npm user agent', ['npm/10.0.0'], 'npx'], @@ -9,3 +9,12 @@ it.each([ ])('detects the package runner from the %s', (_, hints, expected) => { expect(detectPackageRunner(hints)).toBe(expected); }); + +it('uses the current executable for a standalone SEA', () => { + expect( + buildMcpCommand('@stripe/link-cli', '1.2.3', { + sea: true, + executable: '/Applications/Link CLI/link-cli', + }), + ).toBe('"/Applications/Link CLI/link-cli" --mcp'); +}); diff --git a/packages/cli/src/utils/package-runner.ts b/packages/cli/src/utils/package-runner.ts index dd0c1123..6a0be07a 100644 --- a/packages/cli/src/utils/package-runner.ts +++ b/packages/cli/src/utils/package-runner.ts @@ -1,4 +1,5 @@ import { realpathSync } from 'node:fs'; +import { isSea } from 'node:sea'; function runnerFromHint(value: string) { if (value.includes('pnpm')) return 'pnpx'; @@ -30,3 +31,18 @@ export function detectPackageRunner(hints?: readonly string[]) { } return resolvedHints.map(runnerFromHint).find(Boolean) ?? 'npx'; } + +export function buildMcpCommand( + packageName: string, + version: string, + { + sea = isSea(), + executable = process.execPath, + }: { + sea?: boolean; + executable?: string; + } = {}, +) { + if (sea) return `"${executable.replaceAll('"', '\\"')}" --mcp`; + return `${detectPackageRunner()} ${packageName}@${version} --mcp`; +} From 259c015e96a3827c7bdb5990621e8c3f54369cd2 Mon Sep 17 00:00:00 2001 From: Ben Sandler Date: Thu, 10 Sep 2026 23:00:24 -0400 Subject: [PATCH 5/5] Avoid a SEA runtime import in the CLI bundle - Detect standalone execution by comparing the resolved entrypoint and executable paths. - Avoid the node:sea import that the regular CLI bundle rewrites incorrectly. - Preserve absolute executable registration with the focused standalone regression. Committed-By-Agent: codex Co-authored-by: codex --- .../cli/src/utils/__tests__/package-runner.test.ts | 2 +- packages/cli/src/utils/package-runner.ts | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/utils/__tests__/package-runner.test.ts b/packages/cli/src/utils/__tests__/package-runner.test.ts index 03e79a55..21a0200c 100644 --- a/packages/cli/src/utils/__tests__/package-runner.test.ts +++ b/packages/cli/src/utils/__tests__/package-runner.test.ts @@ -13,7 +13,7 @@ it.each([ it('uses the current executable for a standalone SEA', () => { expect( buildMcpCommand('@stripe/link-cli', '1.2.3', { - sea: true, + entry: '/Applications/Link CLI/link-cli', executable: '/Applications/Link CLI/link-cli', }), ).toBe('"/Applications/Link CLI/link-cli" --mcp'); diff --git a/packages/cli/src/utils/package-runner.ts b/packages/cli/src/utils/package-runner.ts index 6a0be07a..f3c26cec 100644 --- a/packages/cli/src/utils/package-runner.ts +++ b/packages/cli/src/utils/package-runner.ts @@ -1,5 +1,4 @@ import { realpathSync } from 'node:fs'; -import { isSea } from 'node:sea'; function runnerFromHint(value: string) { if (value.includes('pnpm')) return 'pnpx'; @@ -36,13 +35,18 @@ export function buildMcpCommand( packageName: string, version: string, { - sea = isSea(), + entry = process.argv[1], executable = process.execPath, }: { - sea?: boolean; + entry?: string; executable?: string; } = {}, ) { - if (sea) return `"${executable.replaceAll('"', '\\"')}" --mcp`; + let standalone = entry === executable; + try { + standalone = realpathSync(entry ?? '') === realpathSync(executable); + } catch {} + + if (standalone) return `"${executable.replaceAll('"', '\\"')}" --mcp`; return `${detectPackageRunner()} ${packageName}@${version} --mcp`; }