claude-code

This commit is contained in:
ashutoshpythoncs@gmail.com
2026-03-31 18:58:05 +05:30
parent a2a44a5841
commit b564857c0b
2148 changed files with 564518 additions and 2 deletions

View File

@@ -0,0 +1,282 @@
/**
* 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)
}
})
}

14
src/commands/mcp/index.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { Command } from '../../commands.js'
const mcp = {
type: 'local-jsx',
name: 'mcp',
description: 'Manage MCP servers',
immediate: true,
argumentHint: '[enable|disable [server-name]]',
load: () => import('./mcp.js'),
} satisfies Command
export default mcp

86
src/commands/mcp/mcp.tsx Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,268 @@
/**
* `claude mcp xaa` — manage the XAA (SEP-990) IdP connection.
*
* The IdP connection is user-level: configure once, all XAA-enabled MCP
* servers reuse it. Lives in settings.xaaIdp (non-secret) + a keychain slot
* keyed by issuer (secret). Separate trust domain from per-server AS secrets.
*/
import type { Command } from '@commander-js/extra-typings'
import { cliError, cliOk } from '../../cli/exit.js'
import {
acquireIdpIdToken,
clearIdpClientSecret,
clearIdpIdToken,
getCachedIdpIdToken,
getIdpClientSecret,
getXaaIdpSettings,
issuerKey,
saveIdpClientSecret,
saveIdpIdTokenFromJwt,
} from '../../services/mcp/xaaIdpLogin.js'
import { errorMessage } from '../../utils/errors.js'
import { updateSettingsForSource } from '../../utils/settings/settings.js'
export function registerMcpXaaIdpCommand(mcp: Command): void {
const xaaIdp = mcp
.command('xaa')
.description('Manage the XAA (SEP-990) IdP connection')
xaaIdp
.command('setup')
.description(
'Configure the IdP connection (one-time setup for all XAA-enabled servers)',
)
.requiredOption('--issuer <url>', 'IdP issuer URL (OIDC discovery)')
.requiredOption('--client-id <id>', "Claude Code's client_id at the IdP")
.option(
'--client-secret',
'Read IdP client secret from MCP_XAA_IDP_CLIENT_SECRET env var',
)
.option(
'--callback-port <port>',
'Fixed loopback callback port (only if IdP does not honor RFC 8252 port-any matching)',
)
.action(options => {
// Validate everything BEFORE any writes. An exit(1) mid-write leaves
// settings configured but keychain missing — confusing state.
// updateSettingsForSource doesn't schema-check on write; a non-URL
// issuer lands on disk and then poisons the whole userSettings source
// on next launch (SettingsSchema .url() fails → parseSettingsFile
// returns { settings: null }, dropping everything, not just xaaIdp).
let issuerUrl: URL
try {
issuerUrl = new URL(options.issuer)
} catch {
return cliError(
`Error: --issuer must be a valid URL (got "${options.issuer}")`,
)
}
// OIDC discovery + token exchange run against this host. Allow http://
// only for loopback (conformance harness mock IdP); anything else leaks
// the client secret and authorization code over plaintext.
if (
issuerUrl.protocol !== 'https:' &&
!(
issuerUrl.protocol === 'http:' &&
(issuerUrl.hostname === 'localhost' ||
issuerUrl.hostname === '127.0.0.1' ||
issuerUrl.hostname === '[::1]')
)
) {
return cliError(
`Error: --issuer must use https:// (got "${issuerUrl.protocol}//${issuerUrl.host}")`,
)
}
const callbackPort = options.callbackPort
? parseInt(options.callbackPort, 10)
: undefined
// callbackPort <= 0 fails Zod's .positive() on next launch — same
// settings-poisoning failure mode as the issuer check above.
if (
callbackPort !== undefined &&
(!Number.isInteger(callbackPort) || callbackPort <= 0)
) {
return cliError('Error: --callback-port must be a positive integer')
}
const secret = options.clientSecret
? process.env.MCP_XAA_IDP_CLIENT_SECRET
: undefined
if (options.clientSecret && !secret) {
return cliError(
'Error: --client-secret requires MCP_XAA_IDP_CLIENT_SECRET env var',
)
}
// Read old config now (before settings overwrite) so we can clear stale
// keychain slots after a successful write. `clear` can't do this after
// the fact — it reads the *current* settings.xaaIdp, which by then is
// the new one.
const old = getXaaIdpSettings()
const oldIssuer = old?.issuer
const oldClientId = old?.clientId
// callbackPort MUST be present (even as undefined) — mergeWith deep-merges
// and only deletes on explicit `undefined`, not on absent key. A conditional
// spread would leak a prior fixed port into a new IdP's config.
const { error } = updateSettingsForSource('userSettings', {
xaaIdp: {
issuer: options.issuer,
clientId: options.clientId,
callbackPort,
},
})
if (error) {
return cliError(`Error writing settings: ${error.message}`)
}
// Clear stale keychain slots only after settings write succeeded —
// otherwise a write failure leaves settings pointing at oldIssuer with
// its secret already gone. Compare via issuerKey(): trailing-slash or
// host-case differences normalize to the same keychain slot.
if (oldIssuer) {
if (issuerKey(oldIssuer) !== issuerKey(options.issuer)) {
clearIdpIdToken(oldIssuer)
clearIdpClientSecret(oldIssuer)
} else if (oldClientId !== options.clientId) {
// Same issuer slot but different OAuth client registration — the
// cached id_token's aud claim and the stored secret are both for the
// old client. `xaa login` would send {new clientId, old secret} and
// fail with opaque `invalid_client`; downstream SEP-990 exchange
// would fail aud validation. Keep both when clientId is unchanged:
// re-setup without --client-secret means "tweak port, keep secret".
clearIdpIdToken(oldIssuer)
clearIdpClientSecret(oldIssuer)
}
}
if (secret) {
const { success, warning } = saveIdpClientSecret(options.issuer, secret)
if (!success) {
return cliError(
`Error: settings written but keychain save failed${warning ? `${warning}` : ''}. ` +
`Re-run with --client-secret once keychain is available.`,
)
}
}
cliOk(`XAA IdP connection configured for ${options.issuer}`)
})
xaaIdp
.command('login')
.description(
'Cache an IdP id_token so XAA-enabled MCP servers authenticate ' +
'silently. Default: run the OIDC browser login. With --id-token: ' +
'write a pre-obtained JWT directly (used by conformance/e2e tests ' +
'where the mock IdP does not serve /authorize).',
)
.option(
'--force',
'Ignore any cached id_token and re-login (useful after IdP-side revocation)',
)
// TODO(paulc): read the JWT from stdin instead of argv to keep it out of
// shell history. Fine for conformance (docker exec uses argv directly,
// no shell parser), but a real user would want `echo $TOKEN | ... --stdin`.
.option(
'--id-token <jwt>',
'Write this pre-obtained id_token directly to cache, skipping the OIDC browser login',
)
.action(async options => {
const idp = getXaaIdpSettings()
if (!idp) {
return cliError(
"Error: no XAA IdP connection. Run 'claude mcp xaa setup' first.",
)
}
// Direct-inject path: skip cache check, skip OIDC. Writing IS the
// operation. Issuer comes from settings (single source of truth), not
// a separate flag — one less thing to desync.
if (options.idToken) {
const expiresAt = saveIdpIdTokenFromJwt(idp.issuer, options.idToken)
return cliOk(
`id_token cached for ${idp.issuer} (expires ${new Date(expiresAt).toISOString()})`,
)
}
if (options.force) {
clearIdpIdToken(idp.issuer)
}
const wasCached = getCachedIdpIdToken(idp.issuer) !== undefined
if (wasCached) {
return cliOk(
`Already logged in to ${idp.issuer} (cached id_token still valid). Use --force to re-login.`,
)
}
process.stdout.write(`Opening browser for IdP login at ${idp.issuer}\n`)
try {
await acquireIdpIdToken({
idpIssuer: idp.issuer,
idpClientId: idp.clientId,
idpClientSecret: getIdpClientSecret(idp.issuer),
callbackPort: idp.callbackPort,
onAuthorizationUrl: url => {
process.stdout.write(
`If the browser did not open, visit:\n ${url}\n`,
)
},
})
cliOk(
`Logged in. MCP servers with --xaa will now authenticate silently.`,
)
} catch (e) {
cliError(`IdP login failed: ${errorMessage(e)}`)
}
})
xaaIdp
.command('show')
.description('Show the current IdP connection config')
.action(() => {
const idp = getXaaIdpSettings()
if (!idp) {
return cliOk('No XAA IdP connection configured.')
}
const hasSecret = getIdpClientSecret(idp.issuer) !== undefined
const hasIdToken = getCachedIdpIdToken(idp.issuer) !== undefined
process.stdout.write(`Issuer: ${idp.issuer}\n`)
process.stdout.write(`Client ID: ${idp.clientId}\n`)
if (idp.callbackPort !== undefined) {
process.stdout.write(`Callback port: ${idp.callbackPort}\n`)
}
process.stdout.write(
`Client secret: ${hasSecret ? '(stored in keychain)' : '(not set — PKCE-only)'}\n`,
)
process.stdout.write(
`Logged in: ${hasIdToken ? 'yes (id_token cached)' : "no — run 'claude mcp xaa login'"}\n`,
)
cliOk()
})
xaaIdp
.command('clear')
.description('Clear the IdP connection config and cached id_token')
.action(() => {
// Read issuer first so we can clear the right keychain slots.
const idp = getXaaIdpSettings()
// updateSettingsForSource uses mergeWith: set to undefined (not delete)
// to signal key removal.
const { error } = updateSettingsForSource('userSettings', {
xaaIdp: undefined,
})
if (error) {
return cliError(`Error writing settings: ${error.message}`)
}
// Clear keychain only after settings write succeeded — otherwise a
// write failure leaves settings pointing at the IdP with its secrets
// already gone (same pattern as `setup`'s old-issuer cleanup).
if (idp) {
clearIdpIdToken(idp.issuer)
clearIdpClientSecret(idp.issuer)
}
cliOk('XAA IdP connection cleared')
})
}