source dump of claude code
at main 280 lines 9.8 kB view raw
1/** 2 * MCP add CLI subcommand 3 * 4 * Extracted from main.tsx to enable direct testing. 5 */ 6import { type Command, Option } from '@commander-js/extra-typings' 7import { cliError, cliOk } from '../../cli/exit.js' 8import { 9 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 10 logEvent, 11} from '../../services/analytics/index.js' 12import { 13 readClientSecret, 14 saveMcpClientSecret, 15} from '../../services/mcp/auth.js' 16import { addMcpConfig } from '../../services/mcp/config.js' 17import { 18 describeMcpConfigFilePath, 19 ensureConfigScope, 20 ensureTransport, 21 parseHeaders, 22} from '../../services/mcp/utils.js' 23import { 24 getXaaIdpSettings, 25 isXaaEnabled, 26} from '../../services/mcp/xaaIdpLogin.js' 27import { parseEnvVars } from '../../utils/envUtils.js' 28import { jsonStringify } from '../../utils/slowOperations.js' 29 30/** 31 * Registers the `mcp add` subcommand on the given Commander command. 32 */ 33export 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}