/ commands / mcp / addCommand.ts
addCommand.ts
  1  /**
  2   * MCP add CLI subcommand
  3   *
  4   * Extracted from main.tsx to enable direct testing.
  5   */
  6  import { type Command, Option } from '@commander-js/extra-typings'
  7  import { cliError, cliOk } from '../../cli/exit.js'
  8  import {
  9    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 10    logEvent,
 11  } from '../../services/analytics/index.js'
 12  import {
 13    readClientSecret,
 14    saveMcpClientSecret,
 15  } from '../../services/mcp/auth.js'
 16  import { addMcpConfig } from '../../services/mcp/config.js'
 17  import {
 18    describeMcpConfigFilePath,
 19    ensureConfigScope,
 20    ensureTransport,
 21    parseHeaders,
 22  } from '../../services/mcp/utils.js'
 23  import {
 24    getXaaIdpSettings,
 25    isXaaEnabled,
 26  } from '../../services/mcp/xaaIdpLogin.js'
 27  import { parseEnvVars } from '../../utils/envUtils.js'
 28  import { jsonStringify } from '../../utils/slowOperations.js'
 29  
 30  /**
 31   * Registers the `mcp add` subcommand on the given Commander command.
 32   */
 33  export function registerMcpAddCommand(mcp: Command): void {
 34    mcp
 35      .command('add <name> <commandOrUrl> [args...]')
 36      .description(
 37        'Add an MCP server to Claude Code.\n\n' +
 38          'Examples:\n' +
 39          '  # Add HTTP server:\n' +
 40          '  claude mcp add --transport http sentry https://mcp.sentry.dev/mcp\n\n' +
 41          '  # Add HTTP server with headers:\n' +
 42          '  claude mcp add --transport http corridor https://app.corridor.dev/api/mcp --header "Authorization: Bearer ..."\n\n' +
 43          '  # Add stdio server with environment variables:\n' +
 44          '  claude mcp add -e API_KEY=xxx my-server -- npx my-mcp-server\n\n' +
 45          '  # Add stdio server with subprocess flags:\n' +
 46          '  claude mcp add my-server -- my-command --some-flag arg1',
 47      )
 48      .option(
 49        '-s, --scope <scope>',
 50        'Configuration scope (local, user, or project)',
 51        'local',
 52      )
 53      .option(
 54        '-t, --transport <transport>',
 55        'Transport type (stdio, sse, http). Defaults to stdio if not specified.',
 56      )
 57      .option(
 58        '-e, --env <env...>',
 59        'Set environment variables (e.g. -e KEY=value)',
 60      )
 61      .option(
 62        '-H, --header <header...>',
 63        'Set WebSocket headers (e.g. -H "X-Api-Key: abc123" -H "X-Custom: value")',
 64      )
 65      .option('--client-id <clientId>', 'OAuth client ID for HTTP/SSE servers')
 66      .option(
 67        '--client-secret',
 68        'Prompt for OAuth client secret (or set MCP_CLIENT_SECRET env var)',
 69      )
 70      .option(
 71        '--callback-port <port>',
 72        'Fixed port for OAuth callback (for servers requiring pre-registered redirect URIs)',
 73      )
 74      .helpOption('-h, --help', 'Display help for command')
 75      .addOption(
 76        new Option(
 77          '--xaa',
 78          "Enable XAA (SEP-990) for this server. Requires 'claude mcp xaa setup' first. Also requires --client-id and --client-secret (for the MCP server's AS).",
 79        ).hideHelp(!isXaaEnabled()),
 80      )
 81      .action(async (name, commandOrUrl, args, options) => {
 82        // Commander.js handles -- natively: it consumes -- and everything after becomes args
 83        const actualCommand = commandOrUrl
 84        const actualArgs = args
 85  
 86        // If no name is provided, error
 87        if (!name) {
 88          cliError(
 89            'Error: Server name is required.\n' +
 90              'Usage: claude mcp add <name> <command> [args...]',
 91          )
 92        } else if (!actualCommand) {
 93          cliError(
 94            'Error: Command is required when server name is provided.\n' +
 95              'Usage: claude mcp add <name> <command> [args...]',
 96          )
 97        }
 98  
 99        try {
100          const scope = ensureConfigScope(options.scope)
101          const transport = ensureTransport(options.transport)
102  
103          // XAA fail-fast: validate at add-time, not auth-time.
104          if (options.xaa && !isXaaEnabled()) {
105            cliError(
106              'Error: --xaa requires CLAUDE_CODE_ENABLE_XAA=1 in your environment',
107            )
108          }
109          const xaa = Boolean(options.xaa)
110          if (xaa) {
111            const missing: string[] = []
112            if (!options.clientId) missing.push('--client-id')
113            if (!options.clientSecret) missing.push('--client-secret')
114            if (!getXaaIdpSettings()) {
115              missing.push(
116                "'claude mcp xaa setup' (settings.xaaIdp not configured)",
117              )
118            }
119            if (missing.length) {
120              cliError(`Error: --xaa requires: ${missing.join(', ')}`)
121            }
122          }
123  
124          // Check if transport was explicitly provided
125          const transportExplicit = options.transport !== undefined
126  
127          // Check if the command looks like a URL (likely incorrect usage)
128          const looksLikeUrl =
129            actualCommand.startsWith('http://') ||
130            actualCommand.startsWith('https://') ||
131            actualCommand.startsWith('localhost') ||
132            actualCommand.endsWith('/sse') ||
133            actualCommand.endsWith('/mcp')
134  
135          logEvent('tengu_mcp_add', {
136            type: transport as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
137            scope:
138              scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
139            source:
140              'command' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
141            transport:
142              transport as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
143            transportExplicit: transportExplicit,
144            looksLikeUrl: looksLikeUrl,
145          })
146  
147          if (transport === 'sse') {
148            if (!actualCommand) {
149              cliError('Error: URL is required for SSE transport.')
150            }
151  
152            const headers = options.header
153              ? parseHeaders(options.header)
154              : undefined
155  
156            const callbackPort = options.callbackPort
157              ? parseInt(options.callbackPort, 10)
158              : undefined
159            const oauth =
160              options.clientId || callbackPort || xaa
161                ? {
162                    ...(options.clientId ? { clientId: options.clientId } : {}),
163                    ...(callbackPort ? { callbackPort } : {}),
164                    ...(xaa ? { xaa: true } : {}),
165                  }
166                : undefined
167  
168            const clientSecret =
169              options.clientSecret && options.clientId
170                ? await readClientSecret()
171                : undefined
172  
173            const serverConfig = {
174              type: 'sse' as const,
175              url: actualCommand,
176              headers,
177              oauth,
178            }
179            await addMcpConfig(name, serverConfig, scope)
180  
181            if (clientSecret) {
182              saveMcpClientSecret(name, serverConfig, clientSecret)
183            }
184  
185            process.stdout.write(
186              `Added SSE MCP server ${name} with URL: ${actualCommand} to ${scope} config\n`,
187            )
188            if (headers) {
189              process.stdout.write(
190                `Headers: ${jsonStringify(headers, null, 2)}\n`,
191              )
192            }
193          } else if (transport === 'http') {
194            if (!actualCommand) {
195              cliError('Error: URL is required for HTTP transport.')
196            }
197  
198            const headers = options.header
199              ? parseHeaders(options.header)
200              : undefined
201  
202            const callbackPort = options.callbackPort
203              ? parseInt(options.callbackPort, 10)
204              : undefined
205            const oauth =
206              options.clientId || callbackPort || xaa
207                ? {
208                    ...(options.clientId ? { clientId: options.clientId } : {}),
209                    ...(callbackPort ? { callbackPort } : {}),
210                    ...(xaa ? { xaa: true } : {}),
211                  }
212                : undefined
213  
214            const clientSecret =
215              options.clientSecret && options.clientId
216                ? await readClientSecret()
217                : undefined
218  
219            const serverConfig = {
220              type: 'http' as const,
221              url: actualCommand,
222              headers,
223              oauth,
224            }
225            await addMcpConfig(name, serverConfig, scope)
226  
227            if (clientSecret) {
228              saveMcpClientSecret(name, serverConfig, clientSecret)
229            }
230  
231            process.stdout.write(
232              `Added HTTP MCP server ${name} with URL: ${actualCommand} to ${scope} config\n`,
233            )
234            if (headers) {
235              process.stdout.write(
236                `Headers: ${jsonStringify(headers, null, 2)}\n`,
237              )
238            }
239          } else {
240            if (
241              options.clientId ||
242              options.clientSecret ||
243              options.callbackPort ||
244              options.xaa
245            ) {
246              process.stderr.write(
247                `Warning: --client-id, --client-secret, --callback-port, and --xaa are only supported for HTTP/SSE transports and will be ignored for stdio.\n`,
248              )
249            }
250  
251            // Warn if this looks like a URL but transport wasn't explicitly specified
252            if (!transportExplicit && looksLikeUrl) {
253              process.stderr.write(
254                `\nWarning: The command "${actualCommand}" looks like a URL, but is being interpreted as a stdio server as --transport was not specified.\n`,
255              )
256              process.stderr.write(
257                `If this is an HTTP server, use: claude mcp add --transport http ${name} ${actualCommand}\n`,
258              )
259              process.stderr.write(
260                `If this is an SSE server, use: claude mcp add --transport sse ${name} ${actualCommand}\n`,
261              )
262            }
263  
264            const env = parseEnvVars(options.env)
265            await addMcpConfig(
266              name,
267              { type: 'stdio', command: actualCommand, args: actualArgs, env },
268              scope,
269            )
270  
271            process.stdout.write(
272              `Added stdio MCP server ${name} with command: ${actualCommand} ${actualArgs.join(' ')} to ${scope} config\n`,
273            )
274          }
275          cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`)
276        } catch (error) {
277          cliError((error as Error).message)
278        }
279      })
280  }