diff --git a/.changeset/safe-mcp-package.md b/.changeset/safe-mcp-package.md new file mode 100644 index 00000000..e66ed004 --- /dev/null +++ b/.changeset/safe-mcp-package.md @@ -0,0 +1,8 @@ +--- +'@stripe/link-cli': patch +--- + +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 d33a2fd0..ecdc577b 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 { buildMcpCommand } from './utils/package-runner'; import { ResourceFactory } from './utils/resource-factory'; import { createAgentUpdateInfoProvider, @@ -67,6 +68,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: 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 new file mode 100644 index 00000000..21a0200c --- /dev/null +++ b/packages/cli/src/utils/__tests__/package-runner.test.ts @@ -0,0 +1,20 @@ +import { expect, it } from 'vitest'; +import { buildMcpCommand, 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); +}); + +it('uses the current executable for a standalone SEA', () => { + expect( + buildMcpCommand('@stripe/link-cli', '1.2.3', { + 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 new file mode 100644 index 00000000..f3c26cec --- /dev/null +++ b/packages/cli/src/utils/package-runner.ts @@ -0,0 +1,52 @@ +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[]) { + let resolvedHints = hints; + if (!resolvedHints) { + const userAgent = process.env.npm_config_user_agent ?? ''; + let entry = process.argv[1] ?? ''; + try { + entry = realpathSync(entry); + } catch {} + + resolvedHints = [ + /pnpm|bun/.test(userAgent) || userAgent.startsWith('npm/') + ? userAgent + : '', + process.env.npm_execpath ?? '', + /(?:\/\.pnpm\/|\\\.pnpm\\)/.test(entry) + ? 'pnpm' + : /(?:\/\.bun\/|\\bun\\)/.test(entry) + ? 'bun' + : '', + ]; + } + return resolvedHints.map(runnerFromHint).find(Boolean) ?? 'npx'; +} + +export function buildMcpCommand( + packageName: string, + version: string, + { + entry = process.argv[1], + executable = process.execPath, + }: { + entry?: string; + executable?: string; + } = {}, +) { + let standalone = entry === executable; + try { + standalone = realpathSync(entry ?? '') === realpathSync(executable); + } catch {} + + if (standalone) return `"${executable.replaceAll('"', '\\"')}" --mcp`; + return `${detectPackageRunner()} ${packageName}@${version} --mcp`; +}