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