feat: build

This commit is contained in:
claude-code-best committed 2026-03-31 19:22:47 +08:00
commit f90eee85d8
1906 files changed
+514339

No files matched your search

+280
View File
@@ -0,0 +1,280 @@
/**
* 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)
}
})
}
+12
View File
@@ -0,0 +1,12 @@
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
+85
View File
@@ -0,0 +1,85 @@
import { c as _c } from "react/compiler-runtime";
import React, { useEffect, useRef } from 'react';
import { MCPSettings } from '../../components/mcp/index.js';
import { MCPReconnect } from '../../components/mcp/MCPReconnect.js';
import { useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js';
import { useAppState } from '../../state/AppState.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { PluginSettings } from '../plugin/PluginSettings.js';
// TODO: This is a hack to get the context value from toggleMcpServer (useContext only works in a component)
// Ideally, all MCP state and functions would be in global state.
function MCPToggle(t0) {
const $ = _c(7);
const {
action,
target,
onComplete
} = t0;
const mcpClients = useAppState(_temp);
const toggleMcpServer = useMcpToggleEnabled();
const didRun = useRef(false);
let t1;
let t2;
if ($[0] !== action || $[1] !== mcpClients || $[2] !== onComplete || $[3] !== target || $[4] !== toggleMcpServer) {
t1 = () => {
if (didRun.current) {
return;
}
didRun.current = true;
const isEnabling = action === "enable";
const clients = mcpClients.filter(_temp2);
const toToggle = target === "all" ? clients.filter(c_0 => isEnabling ? c_0.type === "disabled" : c_0.type !== "disabled") : clients.filter(c_1 => c_1.name === target);
if (toToggle.length === 0) {
onComplete(target === "all" ? `All MCP servers are already ${isEnabling ? "enabled" : "disabled"}` : `MCP server "${target}" not found`);
return;
}
for (const s_0 of toToggle) {
toggleMcpServer(s_0.name);
}
onComplete(target === "all" ? `${isEnabling ? "Enabled" : "Disabled"} ${toToggle.length} MCP server(s)` : `MCP server "${target}" ${isEnabling ? "enabled" : "disabled"}`);
};
t2 = [action, target, mcpClients, toggleMcpServer, onComplete];
$[0] = action;
$[1] = mcpClients;
$[2] = onComplete;
$[3] = target;
$[4] = toggleMcpServer;
$[5] = t1;
$[6] = t2;
} else {
t1 = $[5];
t2 = $[6];
}
useEffect(t1, t2);
return null;
}
function _temp2(c) {
return c.name !== "ide";
}
function _temp(s) {
return s.mcp.clients;
}
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
if (args) {
const parts = args.trim().split(/\s+/);
// Allow /mcp no-redirect to bypass the redirect for testing
if (parts[0] === 'no-redirect') {
return <MCPSettings onComplete={onDone} />;
}
if (parts[0] === 'reconnect' && parts[1]) {
return <MCPReconnect serverName={parts.slice(1).join(' ')} onComplete={onDone} />;
}
if (parts[0] === 'enable' || parts[0] === 'disable') {
return <MCPToggle action={parts[0]} target={parts.length > 1 ? parts.slice(1).join(' ') : 'all'} onComplete={onDone} />;
}
}
// Redirect base /mcp command to /plugins installed tab for ant users
if ("external" === 'ant') {
return <PluginSettings onComplete={onDone} args="manage" showMcpRedirectMessage />;
}
return <MCPSettings onComplete={onDone} />;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUVmZmVjdCIsInVzZVJlZiIsIk1DUFNldHRpbmdzIiwiTUNQUmVjb25uZWN0IiwidXNlTWNwVG9nZ2xlRW5hYmxlZCIsInVzZUFwcFN0YXRlIiwiTG9jYWxKU1hDb21tYW5kT25Eb25lIiwiUGx1Z2luU2V0dGluZ3MiLCJNQ1BUb2dnbGUiLCJ0MCIsIiQiLCJfYyIsImFjdGlvbiIsInRhcmdldCIsIm9uQ29tcGxldGUiLCJtY3BDbGllbnRzIiwiX3RlbXAiLCJ0b2dnbGVNY3BTZXJ2ZXIiLCJkaWRSdW4iLCJ0MSIsInQyIiwiY3VycmVudCIsImlzRW5hYmxpbmciLCJjbGllbnRzIiwiZmlsdGVyIiwiX3RlbXAyIiwidG9Ub2dnbGUiLCJjXzAiLCJjIiwidHlwZSIsImNfMSIsIm5hbWUiLCJsZW5ndGgiLCJzXzAiLCJzIiwibWNwIiwiY2FsbCIsIm9uRG9uZSIsIl9jb250ZXh0IiwiYXJncyIsIlByb21pc2UiLCJSZWFjdE5vZGUiLCJwYXJ0cyIsInRyaW0iLCJzcGxpdCIsInNsaWNlIiwiam9pbiJdLCJzb3VyY2VzIjpbIm1jcC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7IHVzZUVmZmVjdCwgdXNlUmVmIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBNQ1BTZXR0aW5ncyB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvbWNwL2luZGV4LmpzJ1xuaW1wb3J0IHsgTUNQUmVjb25uZWN0IH0gZnJvbSAnLi4vLi4vY29tcG9uZW50cy9tY3AvTUNQUmVjb25uZWN0LmpzJ1xuaW1wb3J0IHsgdXNlTWNwVG9nZ2xlRW5hYmxlZCB9IGZyb20gJy4uLy4uL3NlcnZpY2VzL21jcC9NQ1BDb25uZWN0aW9uTWFuYWdlci5qcydcbmltcG9ydCB7IHVzZUFwcFN0YXRlIH0gZnJvbSAnLi4vLi4vc3RhdGUvQXBwU3RhdGUuanMnXG5pbXBvcnQgdHlwZSB7IExvY2FsSlNYQ29tbWFuZE9uRG9uZSB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5pbXBvcnQgeyBQbHVnaW5TZXR0aW5ncyB9IGZyb20gJy4uL3BsdWdpbi9QbHVnaW5TZXR0aW5ncy5qcydcblxuLy8gVE9ETzogVGhpcyBpcyBhIGhhY2sgdG8gZ2V0IHRoZSBjb250ZXh0IHZhbHVlIGZyb20gdG9nZ2xlTWNwU2VydmVyICh1c2VDb250ZXh0IG9ubHkgd29ya3MgaW4gYSBjb21wb25lbnQpXG4vLyBJZGVhbGx5LCBhbGwgTUNQIHN0YXRlIGFuZCBmdW5jdGlvbnMgd291bGQgYmUgaW4gZ2xvYmFsIHN0YXRlLlxuZnVuY3Rpb24gTUNQVG9nZ2xlKHtcbiAgYWN0aW9uLFxuICB0YXJnZXQsXG4gIG9uQ29tcGxldGUsXG59OiB7XG4gIGFjdGlvbjogJ2VuYWJsZScgfCAnZGlzYWJsZSdcbiAgdGFyZ2V0OiBzdHJpbmdcbiAgb25Db21wbGV0ZTogKHJlc3VsdDogc3RyaW5nKSA9PiB2b2lkXG59KTogbnVsbCB7XG4gIGNvbnN0IG1jcENsaWVudHMgPSB1c2VBcHBTdGF0ZShzID0+IHMubWNwLmNsaWVudHMpXG4gIGNvbnN0IHRvZ2dsZU1jcFNlcnZlciA9IHVzZU1jcFRvZ2dsZUVuYWJsZWQoKVxuICBjb25zdCBkaWRSdW4gPSB1c2VSZWYoZmFsc2UpXG5cbiAgdXNlRWZmZWN0KCgpID0+IHtcbiAgICBpZiAoZGlkUnVuLmN1cnJlbnQpIHJldHVyblxuICAgIGRpZFJ1bi5jdXJyZW50ID0gdHJ1ZVxuXG4gICAgY29uc3QgaXNFbmFibGluZyA9IGFjdGlvbiA9PT0gJ2VuYWJsZSdcbiAgICBjb25zdCBjbGllbnRzID0gbWNwQ2xpZW50cy5maWx0ZXIoYyA9PiBjLm5hbWUgIT09ICdpZGUnKVxuICAgIGNvbnN0IHRvVG9nZ2xlID1cbiAgICAgIHRhcmdldCA9PT0gJ2FsbCdcbiAgICAgICAgPyBjbGllbnRzLmZpbHRlcihjID0+XG4gICAgICAgICAgICBpc0VuYWJsaW5nID8gYy50eXBlID09PSAnZGlzYWJsZWQnIDogYy50eXBlICE9PSAnZGlzYWJsZWQnLFxuICAgICAgICAgIClcbiAgICAgICAgOiBjbGllbnRzLmZpbHRlcihjID0+IGMubmFtZSA9PT0gdGFyZ2V0KVxuXG4gICAgaWYgKHRvVG9nZ2xlLmxlbmd0aCA9PT0gMCkge1xuICAgICAgb25Db21wbGV0ZShcbiAgICAgICAgdGFyZ2V0ID09PSAnYWxsJ1xuICAgICAgICAgID8gYEFsbCBNQ1Agc2VydmVycyBhcmUgYWxyZWFkeSAke2lzRW5hYmxpbmcgPyAnZW5hYmxlZCcgOiAnZGlzYWJsZWQnfWBcbiAgICAgICAgICA6IGBNQ1Agc2VydmVyIFwiJHt0YXJnZXR9XCIgbm90IGZvdW5kYCxcbiAgICAgIClcbiAgICAgIHJldHVyblxuICAgIH1cblxuICAgIGZvciAoY29uc3QgcyBvZiB0b1RvZ2dsZSkge1xuICAgICAgdm9pZCB0b2dnbGVNY3BTZXJ2ZXIocy5uYW1lKVxuICAgIH1cblxuICAgIG9uQ29tcGxldGUoXG4gICAgICB0YXJnZXQgPT09ICdhbGwnXG4gICAgICAgID8gYCR7aXNFbmFibGluZyA/ICdFbmFibGVkJyA6ICdEaXNhYmxlZCd9ICR7dG9Ub2dnbGUubGVuZ3RofSBNQ1Agc2VydmVyKHMpYFxuICAgICAgICA6IGBNQ1Agc2VydmVyIFwiJHt0YXJnZXR9XCIgJHtpc0VuYWJsaW5nID8gJ2VuYWJsZWQnIDogJ2Rpc2FibGVkJ31gLFxuICAgIClcbiAgfSwgW2FjdGlvbiwgdGFyZ2V0LCBtY3BDbGllbnRzLCB0b2dnbGVNY3BTZXJ2ZXIsIG9uQ29tcGxldGVdKVxuXG4gIHJldHVybiBudWxsXG59XG5cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBjYWxsKFxuICBvbkRvbmU6IExvY2FsSlNYQ29tbWFuZE9uRG9uZSxcbiAgX2NvbnRleHQ6IHVua25vd24sXG4gIGFyZ3M/OiBzdHJpbmcsXG4pOiBQcm9taXNlPFJlYWN0LlJlYWN0Tm9kZT4ge1xuICBpZiAoYXJncykge1xuICAgIGNvbnN0IHBhcnRzID0gYXJncy50cmltKCkuc3BsaXQoL1xccysvKVxuXG4gICAgLy8gQWxsb3cgL21jcCBuby1yZWRpcmVjdCB0byBieXBhc3MgdGhlIHJlZGlyZWN0IGZvciB0ZXN0aW5nXG4gICAgaWYgKHBhcnRzWzBdID09PSAnbm8tcmVkaXJlY3QnKSB7XG4gICAgICByZXR1cm4gPE1DUFNldHRpbmdzIG9uQ29tcGxldGU9e29uRG9uZX0gLz5cbiAgICB9XG5cbiAgICBpZiAocGFydHNbMF0gPT09ICdyZWNvbm5lY3QnICYmIHBhcnRzWzFdKSB7XG4gICAgICByZXR1cm4gKFxuICAgICAgICA8TUNQUmVjb25uZWN0XG4gICAgICAgICAgc2VydmVyTmFtZT17cGFydHMuc2xpY2UoMSkuam9pbignICcpfVxuICAgICAgICAgIG9uQ29tcGxldGU9e29uRG9uZX1cbiAgICAgICAgLz5cbiAgICAgIClcbiAgICB9XG5cbiAgICBpZiAocGFydHNbMF0gPT09ICdlbmFibGUnIHx8IHBhcnRzWzBdID09PSAnZGlzYWJsZScpIHtcbiAgICAgIHJldHVybiAoXG4gICAgICAgIDxNQ1BUb2dnbGVcbiAgICAgICAgICBhY3Rpb249e3BhcnRzWzBdfVxuICAgICAgICAgIHRhcmdldD17cGFydHMubGVuZ3RoID4gMSA/IHBhcnRzLnNsaWNlKDEpLmpvaW4oJyAnKSA6ICdhbGwnfVxuICAgICAgICAgIG9uQ29tcGxldGU9e29uRG9uZX1cbiAgICAgICAgLz5cbiAgICAgIClcbiAgICB9XG4gIH1cblxuICAvLyBSZWRpcmVjdCBiYXNlIC9tY3AgY29tbWFuZCB0byAvcGx1Z2lucyBpbnN0YWxsZWQgdGFiIGZvciBhbnQgdXNlcnNcbiAgaWYgKFwiZXh0ZXJuYWxcIiA9PT0gJ2FudCcpIHtcbiAgICByZXR1cm4gKFxuICAgICAgPFBsdWdpblNldHRpbmdzXG4gICAgICAgIG9uQ29tcGxldGU9e29uRG9uZX1cbiAgICAgICAgYXJncz1cIm1hbmFnZVwiXG4gICAgICAgIHNob3dNY3BSZWRpcmVjdE1lc3NhZ2VcbiAgICAgIC8+XG4gICAgKVxuICB9XG5cbiAgcmV0dXJuIDxNQ1BTZXR0aW5ncyBvbkNvbXBsZXRlPXtvbkRvbmV9IC8+XG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQUlDLFNBQVMsRUFBRUMsTUFBTSxRQUFRLE9BQU87QUFDaEQsU0FBU0MsV0FBVyxRQUFRLCtCQUErQjtBQUMzRCx Line truncated
+266
View File
@@ -0,0 +1,266 @@
/**
* `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')
})
}