From fa1af37d74a840bf0fb4e51f901251f19877f9bc Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 19 Sep 2026 09:50:00 +1000 Subject: [PATCH 01/16] feat(mcp): add TradingView plugin and headless OAuth login --- .claude-plugin/marketplace.json | 5 + .github/plugin/marketplace.json | 5 + CHANGELOG.md | 5 + .../content/docs/docs/guides/mcp-proxy.mdx | 22 +- docs/src/content/docs/docs/reference/cli.mdx | 23 +- .../tradingview/.claude-plugin/plugin.json | 10 + plugins/tradingview/.mcp.json | 8 + src/cli/agent-help.ts | 2 + src/cli/commands/mcp.ts | 82 ++++- src/cli/metadata/mcp.ts | 28 ++ src/core/mcp-http-stdio-proxy.ts | 312 +++++++++++++----- tests/e2e/mcp-proxy-command.test.ts | 10 + tests/e2e/mcp-proxy-oauth.test.ts | 51 ++- tests/helpers/dummy-mcp-oauth-server.ts | 17 + tests/unit/cli/agent-help.test.ts | 5 + tests/unit/core/mcp-http-stdio-proxy.test.ts | 128 ++++++- 16 files changed, 612 insertions(+), 101 deletions(-) create mode 100644 plugins/tradingview/.claude-plugin/plugin.json create mode 100644 plugins/tradingview/.mcp.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a3b2824a..4877afab 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -16,6 +16,11 @@ "name": "engineering", "description": "Reusable engineering workflow skills", "source": "./plugins/engineering" + }, + { + "name": "tradingview", + "description": "Official TradingView market data and analytics", + "source": "./plugins/tradingview" } ] } diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index a8378c20..b70726ea 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -11,6 +11,11 @@ "name": "deepwiki", "description": "AI-generated documentation for GitHub repositories", "source": "./plugins/deepwiki" + }, + { + "name": "tradingview", + "description": "Official TradingView market data and analytics", + "source": "./plugins/tradingview" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index adcd124f..56755ecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ ### Added +- Added the official TradingView MCP plugin with OAuth-backed access to market + data, analytics, watchlists, alerts, news, and screeners. +- Added `allagents mcp auth` for OAuth login from headless or remote machines, + with interactive callback URL paste and strict redirect/state validation. + - Pi and OMP as file-sync clients at project and user scope, including native runtime skill paths and agent instructions. - Native Pi package and OMP marketplace-plugin lifecycle support for install, diff --git a/docs/src/content/docs/docs/guides/mcp-proxy.mdx b/docs/src/content/docs/docs/guides/mcp-proxy.mdx index ed722c87..4fa1460a 100644 --- a/docs/src/content/docs/docs/guides/mcp-proxy.mdx +++ b/docs/src/content/docs/docs/guides/mcp-proxy.mdx @@ -143,7 +143,7 @@ Per-server `proxy` lists are additive — they extend the default `clients`, not ## OAuth & Token Cache -The first time `allagents mcp proxy ` connects to a server that requires OAuth, it runs the standard authorization-code + PKCE flow: it registers a client with the server's authorization server (or reuses a cached registration), opens your browser to complete the login, and exchanges the resulting code for tokens. +The first time `allagents mcp proxy ` connects to a server that requires OAuth, it runs the standard authorization-code + PKCE flow: it registers a client with the server's authorization server (or reuses a cached registration), opens your browser to complete the login, and exchanges the resulting code for tokens. On a headless or remote machine, authorize first with `allagents mcp auth `. Client registration, tokens, and discovery metadata are cached per server under: @@ -157,6 +157,26 @@ Client registration, tokens, and discovery metadata are cached per server under: Later connections reuse this cache — no browser prompt — and an expired access token is refreshed automatically using the cached refresh token, still without reopening a browser. If you ever need to force a fresh login for a specific server (e.g. a revoked token), delete that server's subdirectory and reconnect. +### Headless or Remote Browser Login + +When the browser is on another device, its redirect to `127.0.0.1` cannot +reach the AllAgents process. Run the interactive login directly: + +```bash +allagents mcp auth https://mcp.tradingview.com/mcp +``` + +Open the printed authorization URL on any device. After approval, the browser +may show that the loopback page cannot be reached; copy the complete callback +URL from the address bar and paste it into the AllAgents prompt. AllAgents +accepts only the registered loopback address with the exact OAuth state, then +stores the resulting credentials in the same per-server cache used by +`mcp proxy`. Restart or reconnect the MCP client afterward. + +Only clients selected by `mcpProxy` use this cache. Clients synced with a native +HTTP configuration continue to use their own OAuth flow, so configure the +target client for proxying before running `mcp auth`. + ## Prerequisites None beyond `allagents` itself — the proxy has no separate runtime dependency to fetch or cache. diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index 912485c5..aa458d96 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -408,6 +408,7 @@ Local plugin sources are listed separately in `data.skippedLocalSources`. The JS ```bash allagents mcp add [options] +allagents mcp auth [--header KEY=VALUE...] allagents mcp proxy [--header KEY=VALUE...] allagents mcp remove allagents mcp list @@ -446,6 +447,26 @@ allagents mcp add gh-server npx --arg=-y --arg=@modelcontextprotocol/server-gith allagents mcp add deepwiki https://new.example.com --force ``` +### mcp auth + +Authorize an OAuth-enabled HTTP MCP server before connecting it through a +headless or remote machine. Open the printed authorization URL in any browser. +When that browser redirects to an unreachable loopback address, copy the full +URL from its address bar and paste it into the prompt. AllAgents validates the +registered callback address and OAuth state before exchanging the code, then +caches the resulting credentials for `mcp proxy`. + +| Flag | Description | +|------|-------------| +| `--header ` | HTTP header forwarded to the upstream MCP server (repeatable) | + +```bash +allagents mcp auth https://mcp.tradingview.com/mcp +``` + +Restart or reconnect the MCP client after authorization so `mcp proxy` can use +the cached token. + ### mcp proxy Expose a remote HTTP MCP server locally over stdio. This is the helper command AllAgents writes into proxied client configs when `mcpProxy` rewrites an HTTP server for clients that only support stdio transport. @@ -459,7 +480,7 @@ allagents mcp proxy https://mcp.deepwiki.com/mcp allagents mcp proxy https://mcp.internal.corp --header Authorization=Bearer-token ``` -`proxy` is the only supported public command shown in help and generated configs. +`auth` is the interactive login command; `proxy` remains the helper written to generated client configs. ### mcp remove diff --git a/plugins/tradingview/.claude-plugin/plugin.json b/plugins/tradingview/.claude-plugin/plugin.json new file mode 100644 index 00000000..2956a301 --- /dev/null +++ b/plugins/tradingview/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "tradingview", + "description": "Official TradingView market data and analytics for quotes, technicals, fundamentals, news, watchlists, alerts, and screeners.", + "author": { + "name": "AllAgents" + }, + "version": "1.0.0", + "category": "finance", + "homepage": "https://github.com/allagentsdev/allagents/tree/main/plugins/tradingview" +} diff --git a/plugins/tradingview/.mcp.json b/plugins/tradingview/.mcp.json new file mode 100644 index 00000000..587c6cdf --- /dev/null +++ b/plugins/tradingview/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "tradingview": { + "type": "http", + "url": "https://mcp.tradingview.com/mcp" + } + } +} diff --git a/src/cli/agent-help.ts b/src/cli/agent-help.ts index ebb740e1..2fa274c2 100644 --- a/src/cli/agent-help.ts +++ b/src/cli/agent-help.ts @@ -1,5 +1,6 @@ import type { AgentCommandMeta } from './help.js'; import { normalizeSkillHelpArgs } from './skill-arg-normalizer.js'; +import { mcpAuthMeta } from './metadata/mcp.js'; import { skillsAddMeta, @@ -39,6 +40,7 @@ const allCommands: AgentCommandMeta[] = [ setupMeta, syncMeta, statusMeta, + mcpAuthMeta, pluginInstallMeta, pluginUninstallMeta, pluginUpdateMeta, diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts index 7aa20b8d..344edd00 100644 --- a/src/cli/commands/mcp.ts +++ b/src/cli/commands/mcp.ts @@ -1,3 +1,4 @@ +import { isCancel, password } from '@clack/prompts'; import { array, command, @@ -19,7 +20,11 @@ import { removeWorkspaceMcpServer, setWorkspaceMcpServerProxy, } from '../../core/mcp-servers.js'; -import { runHttpMcpStdioProxy } from '../../core/mcp-http-stdio-proxy.js'; +import { + validateOAuthCallbackUrl, + runHttpMcpOAuthLogin, + runHttpMcpStdioProxy, +} from '../../core/mcp-http-stdio-proxy.js'; import { syncMcpOnly } from '../../core/mcp-sync.js'; import { type ClientType, @@ -30,12 +35,14 @@ import { formatMcpResult } from '../format-sync.js'; import { buildDescription, conciseSubcommands } from '../help.js'; import { isJsonMode, jsonOutput } from '../json-output.js'; import { + mcpAuthMeta, mcpAddMeta, mcpGetMeta, mcpListMeta, mcpRemoveMeta, mcpUpdateMeta, } from '../metadata/mcp.js'; +import { terminalSafe } from '../terminal-output.js'; // ============================================================================= // Helpers @@ -63,7 +70,7 @@ function exitWithError(command: string, error: string): never { if (isJsonMode()) { jsonOutput({ success: false, command, error }); } else { - console.error(`Error: ${error}`); + console.error(`Error: ${terminalSafe(error)}`); } process.exit(1); } @@ -333,6 +340,76 @@ const mcpRemoveCmd = command({ }, }); +// ============================================================================= +// mcp auth +// ============================================================================= + +const mcpAuthCmd = command({ + name: 'auth', + description: buildDescription(mcpAuthMeta), + args: { + serverUrl: positional({ type: string, displayName: 'serverUrl' }), + header: addArgs.header, + }, + handler: async ({ serverUrl, header }) => { + if (isJsonMode()) { + exitWithError('mcp auth', 'OAuth login requires an interactive terminal'); + } + if (!process.stdin.isTTY) { + exitWithError('mcp auth', 'OAuth login requires an interactive terminal'); + } + + const headerResult = parseKeyValuePairs(header, '--header'); + if ('error' in headerResult) { + exitWithError('mcp auth', headerResult.error); + } + + try { + await runHttpMcpOAuthLogin( + serverUrl, + async ({ authorizationUrl, redirectUrl, state }) => { + console.log('Open this URL in any browser:'); + console.log(authorizationUrl.toString()); + console.log( + `After approval, copy the full ${redirectUrl} URL from the browser address bar.`, + ); + + const callbackUrl = await password({ + message: 'Paste the full OAuth callback URL', + validate: (value) => { + if (!value) { + return 'OAuth callback URL is required'; + } + try { + validateOAuthCallbackUrl(value, redirectUrl, state); + return undefined; + } catch (error) { + return error instanceof Error + ? error.message + : 'Invalid OAuth callback URL'; + } + }, + }); + if (isCancel(callbackUrl)) { + throw new Error('OAuth authorization cancelled'); + } + return callbackUrl; + }, + headerResult.values, + ); + } catch (error) { + exitWithError( + 'mcp auth', + error instanceof Error ? error.message : String(error), + ); + } + + console.log( + `\u2713 OAuth authorization complete for ${terminalSafe(serverUrl)}`, + ); + }, +}); + // ============================================================================= // mcp proxy // ============================================================================= @@ -505,6 +582,7 @@ export const mcpCmd = conciseSubcommands({ name: 'mcp', description: 'Manage MCP servers for AI clients', cmds: { + auth: mcpAuthCmd, add: mcpAddCmd, proxy: mcpProxyCmd, remove: mcpRemoveCmd, diff --git a/src/cli/metadata/mcp.ts b/src/cli/metadata/mcp.ts index 2da5f6ec..0897d77e 100644 --- a/src/cli/metadata/mcp.ts +++ b/src/cli/metadata/mcp.ts @@ -118,6 +118,34 @@ export const mcpGetMeta: AgentCommandMeta = { ], }; +export const mcpAuthMeta: AgentCommandMeta = { + command: 'mcp auth', + description: 'Authorize an HTTP MCP server from a local or remote browser', + whenToUse: + 'When an OAuth-enabled MCP server is running on a headless or remote machine and the browser cannot reach its loopback callback', + examples: [ + 'allagents mcp auth https://mcp.tradingview.com/mcp', + 'allagents mcp auth https://mcp.internal.corp --header Authorization=Bearer-token', + ], + expectedOutput: + 'Prints an authorization URL, prompts for the full loopback callback URL, validates the callback state, and caches OAuth credentials. Exit 0 on success, 1 on cancellation or failure.', + positionals: [ + { + name: 'serverUrl', + type: 'string', + required: true, + description: 'Remote HTTP MCP server URL', + }, + ], + options: [ + { + flag: '--header', + type: 'string', + description: 'HTTP header KEY=VALUE (repeatable)', + }, + ], +}; + export const mcpUpdateMeta: AgentCommandMeta = { command: 'mcp update', description: 'Sync MCP servers only, without touching other artifacts', diff --git a/src/core/mcp-http-stdio-proxy.ts b/src/core/mcp-http-stdio-proxy.ts index 5605a11e..450ff376 100644 --- a/src/core/mcp-http-stdio-proxy.ts +++ b/src/core/mcp-http-stdio-proxy.ts @@ -18,7 +18,10 @@ import { import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { + FetchLike, + Transport, +} from '@modelcontextprotocol/sdk/shared/transport.js'; import type { OAuthClientInformationMixed, OAuthClientMetadata, @@ -38,6 +41,95 @@ import { const AUTH_TIMEOUT_MS = 5 * 60 * 1000; export const AUTH_URL_LOG_PREFIX = 'If the browser does not open, visit: '; +export interface OAuthCallbackRequest { + authorizationUrl: URL; + redirectUrl: string; + state: string; +} + +export type OAuthCallbackUrlReader = ( + request: OAuthCallbackRequest, +) => Promise; + +type ParsedOAuthCallback = + | { code: string; authorizationError?: never } + | { code?: never; authorizationError: true }; + +export class OAuthAuthorizationError extends Error { + constructor() { + super('OAuth authorization failed'); + this.name = 'OAuthAuthorizationError'; + } +} + +function parseOAuthCallbackResponse( + callbackUrl: string, + redirectUrl: string, + expectedState: string, +): ParsedOAuthCallback { + let callback: URL; + try { + callback = new URL(callbackUrl.trim()); + } catch { + throw new Error('Invalid OAuth callback URL'); + } + + const expected = new URL(redirectUrl); + if ( + callback.username || + callback.password || + callback.origin !== expected.origin || + callback.pathname !== expected.pathname || + callback.hash + ) { + throw new Error( + 'OAuth callback URL does not match the registered redirect', + ); + } + + const states = callback.searchParams.getAll('state'); + if (states.length !== 1 || states[0] !== expectedState) { + throw new Error('OAuth state validation failed'); + } + + const errors = callback.searchParams.getAll('error'); + const codes = callback.searchParams.getAll('code'); + if (errors.length === 1 && errors[0] && codes.length === 0) { + return { authorizationError: true }; + } + if (errors.length > 0) { + throw new Error('Invalid OAuth authorization response'); + } + if (codes.length !== 1 || !codes[0]) { + throw new Error('No OAuth authorization code received'); + } + return { code: codes[0] }; +} + +export function validateOAuthCallbackUrl( + callbackUrl: string, + redirectUrl: string, + expectedState: string, +): void { + parseOAuthCallbackResponse(callbackUrl, redirectUrl, expectedState); +} + +export function parseOAuthCallbackUrl( + callbackUrl: string, + redirectUrl: string, + expectedState: string, +): string { + const callback = parseOAuthCallbackResponse( + callbackUrl, + redirectUrl, + expectedState, + ); + if (callback.authorizationError) { + throw new OAuthAuthorizationError(); + } + return callback.code; +} + export function hashServerUrl(serverUrl: string): string { return createHash('sha256').update(serverUrl).digest('hex').slice(0, 16); } @@ -51,13 +143,32 @@ function getCacheDir(serverUrl: string): string { ); } -function getRequestInit( +function getMcpFetch( + serverUrl: string, headers: Record, -): RequestInit | undefined { +): FetchLike | undefined { if (Object.keys(headers).length === 0) { return undefined; } - return { headers }; + + const serverOrigin = new URL(serverUrl).origin; + return async (input, init) => { + const requestUrl = new URL(input.toString()); + if (requestUrl.origin !== serverOrigin) { + return fetch(input, init); + } + + const mergedHeaders = new Headers(headers); + new Headers(init?.headers).forEach((value, key) => + mergedHeaders.set(key, value), + ); + + return fetch(input, { + ...init, + headers: mergedHeaders, + redirect: 'error', + }); + }; } async function pathExists(path: string): Promise { @@ -193,6 +304,7 @@ class FileOAuthClientProvider implements OAuthClientProvider { constructor( private readonly port: number, serverUrl: string, + private readonly callbackUrlReader?: OAuthCallbackUrlReader, ) { const cacheDir = getCacheDir(serverUrl); this.clientInfoPath = join(cacheDir, 'client-info.json'); @@ -260,7 +372,9 @@ class FileOAuthClientProvider implements OAuthClientProvider { } redirectToAuthorization(authorizationUrl: URL): void { - this.pendingAuth ??= this.waitForAuthorizationCode(authorizationUrl); + this.pendingAuth ??= this.callbackUrlReader + ? this.waitForPastedAuthorizationCode(authorizationUrl) + : this.waitForAuthorizationCode(authorizationUrl); } async saveCodeVerifier(codeVerifier: string): Promise { @@ -316,88 +430,93 @@ class FileOAuthClientProvider implements OAuthClientProvider { return this.pendingAuth; } + private async waitForPastedAuthorizationCode( + authorizationUrl: URL, + ): Promise { + if (!this.callbackUrlReader) { + throw new Error('OAuth callback URL reader is unavailable'); + } + const callbackUrl = await this.callbackUrlReader({ + authorizationUrl, + redirectUrl: this.redirectUriValue, + state: this.stateValue, + }); + return parseOAuthCallbackUrl( + callbackUrl, + this.redirectUriValue, + this.stateValue, + ); + } + private waitForAuthorizationCode(authorizationUrl: URL): Promise { - return new Promise((resolve, reject) => { - const server = createServer( - (request: IncomingMessage, response: ServerResponse) => { - try { - const parsed = new URL( - request.url ?? '/', - `http://127.0.0.1:${this.port}`, - ); - const code = parsed.searchParams.get('code'); - const error = parsed.searchParams.get('error'); - const state = parsed.searchParams.get('state'); - - if (code) { - if (state !== this.stateValue) { - response.writeHead(400, { - 'content-type': 'text/html; charset=utf-8', - }); - response.end( - '

Authorization failed

State validation failed.

', - ); - server.close(); - reject(new Error('OAuth state validation failed')); - return; - } - response.writeHead(200, { - 'content-type': 'text/html; charset=utf-8', - }); - response.end( - '

Authorization complete

You can close this window.

', - ); - server.close(); - resolve(code); - return; - } - - const message = error ?? 'No authorization code received'; - response.writeHead(400, { - 'content-type': 'text/html; charset=utf-8', - }); - response.end( - `

Authorization failed

${message}

`, - ); - server.close(); - reject(new Error(message)); - } catch (error) { - server.close(); - reject(error instanceof Error ? error : new Error(String(error))); - } - }, - ); + const { promise, resolve, reject } = Promise.withResolvers(); + const server = createServer( + (request: IncomingMessage, response: ServerResponse) => { + try { + const callbackUrl = new URL( + request.url ?? '/', + this.redirectUriValue, + ).toString(); + const code = parseOAuthCallbackUrl( + callbackUrl, + this.redirectUriValue, + this.stateValue, + ); - const timeout = setTimeout(() => { - server.close(); - reject(new Error('Timed out waiting for OAuth authorization callback')); - }, AUTH_TIMEOUT_MS); + response.writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + }); + response.end( + '

Authorization complete

You can close this window.

', + ); + server.close(); + resolve(code); + } catch (error) { + response.writeHead(400, { + 'content-type': 'text/html; charset=utf-8', + }); + response.end( + '

Authorization failed

The OAuth response was rejected.

', + ); + server.close(); + reject(error instanceof Error ? error : new Error(String(error))); + } + }, + ); - server.on('close', () => { - clearTimeout(timeout); - }); - server.on('error', reject); - server.listen(this.port, '127.0.0.1', () => { - console.error('Opening browser for authorization...'); + const timeout = setTimeout(() => { + server.close(); + reject(new Error('Timed out waiting for OAuth authorization callback')); + }, AUTH_TIMEOUT_MS); + + server.on('close', () => { + clearTimeout(timeout); + }); + server.on('error', reject); + server.listen(this.port, '127.0.0.1', () => { + console.error('Opening browser for authorization...'); + console.error(`${AUTH_URL_LOG_PREFIX}${authorizationUrl.toString()}`); + console.error( + 'Remote browser? Stop this MCP client, run `allagents mcp auth ` in a terminal, then reconnect.', + ); + // Test-only escape hatch: e2e tests fetch the URL themselves against a local + // dummy IdP, and skipping the real OS browser-open avoids ever launching one. + if (process.env.ALLAGENTS_MCP_OAUTH_NO_BROWSER === '1') { console.error( - `${AUTH_URL_LOG_PREFIX}${authorizationUrl.toString()}`, + 'Skipping automatic browser open (ALLAGENTS_MCP_OAUTH_NO_BROWSER=1).', ); - // Test-only escape hatch: e2e tests fetch the URL themselves against a local - // dummy IdP, and skipping the real OS browser-open avoids ever launching one. - if (process.env.ALLAGENTS_MCP_OAUTH_NO_BROWSER === '1') { - console.error( - 'Skipping automatic browser open (ALLAGENTS_MCP_OAUTH_NO_BROWSER=1).', - ); - } else { - void tryOpenBrowser(authorizationUrl.toString()); - } - }); + } else { + void tryOpenBrowser(authorizationUrl.toString()); + } }); + + return promise; } } async function buildOAuthProvider( serverUrl: string, + callbackUrlReader?: OAuthCallbackUrlReader, ): Promise { const cacheDir = getCacheDir(serverUrl); const cachedClientInfo = await readJsonFile( @@ -409,7 +528,11 @@ async function buildOAuthProvider( port = await findFreePort(); } - const provider = new FileOAuthClientProvider(port, serverUrl); + const provider = new FileOAuthClientProvider( + port, + serverUrl, + callbackUrlReader, + ); await provider.load(); return provider; } @@ -427,11 +550,17 @@ function parseCallToolResponse( }; } +interface RemoteConnection { + client: Client; + transport: StreamableHTTPClientTransport; +} + async function connectRemoteTransport( serverUrl: string, headers: Record, -): Promise { - const provider = await buildOAuthProvider(serverUrl); + callbackUrlReader?: OAuthCallbackUrlReader, +): Promise { + const provider = await buildOAuthProvider(serverUrl, callbackUrlReader); const client = new Client( { name: 'AllAgents', @@ -441,10 +570,10 @@ async function connectRemoteTransport( ); const buildTransport = () => { - const requestInit = getRequestInit(headers); + const mcpFetch = getMcpFetch(serverUrl, headers); return new StreamableHTTPClientTransport(new URL(serverUrl), { authProvider: provider, - ...(requestInit && { requestInit }), + ...(mcpFetch && { fetch: mcpFetch }), }); }; @@ -462,14 +591,31 @@ async function connectRemoteTransport( await client.connect(transport as unknown as Transport); } - return client; + return { client, transport }; +} + +export async function runHttpMcpOAuthLogin( + serverUrl: string, + callbackUrlReader: OAuthCallbackUrlReader, + headers: Record = {}, +): Promise { + const { client, transport } = await connectRemoteTransport( + serverUrl, + headers, + callbackUrlReader, + ); + try { + await transport.terminateSession(); + } finally { + await client.close(); + } } export async function runHttpMcpStdioProxy( serverUrl: string, headers: Record = {}, ): Promise { - const remote = await connectRemoteTransport(serverUrl, headers); + const { client: remote } = await connectRemoteTransport(serverUrl, headers); const local = new Server( { name: 'AllAgents', diff --git a/tests/e2e/mcp-proxy-command.test.ts b/tests/e2e/mcp-proxy-command.test.ts index 6043e34b..bbc176b7 100644 --- a/tests/e2e/mcp-proxy-command.test.ts +++ b/tests/e2e/mcp-proxy-command.test.ts @@ -23,9 +23,19 @@ describe('mcp proxy command help', () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain('- proxy - Expose a remote HTTP MCP server locally over stdio'); + expect(result.stdout).toContain( + '- auth - Authorize an HTTP MCP server from a local or remote browser', + ); expect(result.stdout).not.toContain('- proxy-stdio -'); }); + test('exposes auth through machine-readable agent help', () => { + const result = runCli(['--agent-help', 'mcp', 'auth']); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).command).toBe('mcp auth'); + }); + test('rejects proxy-stdio after the rename', () => { const result = runCli(['mcp', 'proxy-stdio']); diff --git a/tests/e2e/mcp-proxy-oauth.test.ts b/tests/e2e/mcp-proxy-oauth.test.ts index 641905c4..df5eb769 100644 --- a/tests/e2e/mcp-proxy-oauth.test.ts +++ b/tests/e2e/mcp-proxy-oauth.test.ts @@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdirSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { hashServerUrl } from '../../src/core/mcp-http-stdio-proxy.ts'; +import { + hashServerUrl, + runHttpMcpOAuthLogin, +} from '../../src/core/mcp-http-stdio-proxy.ts'; import { type DummyMcpOAuthServer, FIXTURE_ANSWER, @@ -83,6 +86,52 @@ describe('mcp proxy OAuth e2e', () => { } }, 15000); + test('completes OAuth from a callback URL pasted on a headless host', async () => { + dummy = await startDummyMcpOAuthServer(); + const previousTestHome = process.env.ALLAGENTS_TEST_HOME; + process.env.ALLAGENTS_TEST_HOME = homeDir; + + try { + const resourceSecret = 'resource-server-only'; + await runHttpMcpOAuthLogin( + dummy.mcpUrl, + async ({ authorizationUrl }) => { + const response = await fetch(authorizationUrl, { + redirect: 'manual', + }); + expect(response.status).toBe(302); + const location = response.headers.get('location'); + expect(location).toBeTruthy(); + return new URL(location!, authorizationUrl).toString(); + }, + { 'x-resource-secret': resourceSecret }, + ); + + expect(dummy.authorizeCallCount).toBe(1); + expect(dummy.tokenCallCounts.authorization_code).toBe(1); + expect( + dummy.mcpRequestHeaders.some( + (headers) => headers['x-resource-secret'] === resourceSecret, + ), + ).toBe(true); + expect( + dummy.idpRequestHeaders.every( + (headers) => headers['x-resource-secret'] === undefined, + ), + ).toBe(true); + expect(dummy.activeSessionCount).toBe(0); + const connection = await connectAndAutoAuthorize(dummy.mcpUrl, homeDir); + await connection.close(); + expect(dummy.authorizeCallCount).toBe(1); + } finally { + if (previousTestHome === undefined) { + delete process.env.ALLAGENTS_TEST_HOME; + } else { + process.env.ALLAGENTS_TEST_HOME = previousTestHome; + } + } + }, 15000); + test('reuses the cached token on a second connection without re-authorizing', async () => { dummy = await startDummyMcpOAuthServer(); diff --git a/tests/helpers/dummy-mcp-oauth-server.ts b/tests/helpers/dummy-mcp-oauth-server.ts index e3af80e5..849d48eb 100644 --- a/tests/helpers/dummy-mcp-oauth-server.ts +++ b/tests/helpers/dummy-mcp-oauth-server.ts @@ -3,6 +3,7 @@ import { createServer, type IncomingMessage, type Server as HttpServer, + type IncomingHttpHeaders, type ServerResponse, } from 'node:http'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; @@ -43,6 +44,9 @@ export interface DummyMcpOAuthServer { idpIssuer: string; readonly authorizeCallCount: number; readonly tokenCallCounts: { authorization_code: number; refresh_token: number }; + readonly idpRequestHeaders: ReadonlyArray; + readonly mcpRequestHeaders: ReadonlyArray; + readonly activeSessionCount: number; stop(): Promise; } @@ -114,6 +118,8 @@ export async function startDummyMcpOAuthServer( authorizeCallCount: 0, tokenCallCounts: { authorization_code: 0, refresh_token: 0 }, }; + const idpRequestHeaders: IncomingHttpHeaders[] = []; + const mcpRequestHeaders: IncomingHttpHeaders[] = []; let idpIssuer = ''; let mcpUrl = ''; @@ -126,6 +132,7 @@ export async function startDummyMcpOAuthServer( req: IncomingMessage, res: ServerResponse, ): Promise { + idpRequestHeaders.push({ ...req.headers }); const url = new URL(req.url ?? '/', idpIssuer); if (req.method === 'GET' && url.pathname === '/.well-known/oauth-authorization-server') { @@ -308,6 +315,7 @@ export async function startDummyMcpOAuthServer( req: IncomingMessage, res: ServerResponse, ): Promise { + mcpRequestHeaders.push({ ...req.headers }); const url = new URL(req.url ?? '/', mcpUrl); if (req.method === 'GET' && url.pathname === '/.well-known/oauth-protected-resource') { @@ -363,6 +371,15 @@ export async function startDummyMcpOAuthServer( get tokenCallCounts() { return counters.tokenCallCounts; }, + get idpRequestHeaders() { + return idpRequestHeaders; + }, + get mcpRequestHeaders() { + return mcpRequestHeaders; + }, + get activeSessionCount() { + return sessions.size; + }, async stop() { await Promise.all([...sessions.values()].map((transport) => transport.close())); await Promise.all([close(idpServer), close(mcpHttpServer)]); diff --git a/tests/unit/cli/agent-help.test.ts b/tests/unit/cli/agent-help.test.ts index b182e248..c9ab2ece 100644 --- a/tests/unit/cli/agent-help.test.ts +++ b/tests/unit/cli/agent-help.test.ts @@ -178,6 +178,11 @@ describe('findMetaByCommand', () => { expect(meta!.command).toBe('status'); }); + test('resolves the interactive MCP OAuth command', () => { + const meta = findMetaByCommand('mcp auth https://mcp.tradingview.com/mcp'); + expect(meta?.command).toBe('mcp auth'); + }); + test('resolves deprecated "workspace status" alias to status meta', () => { const meta = findMetaByCommand('workspace status'); expect(meta).toBeDefined(); diff --git a/tests/unit/core/mcp-http-stdio-proxy.test.ts b/tests/unit/core/mcp-http-stdio-proxy.test.ts index ed7e2ea6..d4fa1a62 100644 --- a/tests/unit/core/mcp-http-stdio-proxy.test.ts +++ b/tests/unit/core/mcp-http-stdio-proxy.test.ts @@ -1,13 +1,115 @@ -import { describe, expect, test } from 'bun:test'; -import { getBrowserOpenCommands } from '../../../src/core/mcp-http-stdio-proxy.js'; - -describe('getBrowserOpenCommands', () => { - test('uses explorer on Windows so OAuth URLs are not parsed by cmd', () => { - const url = - 'https://idp.example/auth?response_type=code&client_id=test&state=abc'; - - expect(getBrowserOpenCommands(url, 'win32')).toEqual([ - { command: 'explorer.exe', args: [url] }, - ]); - }); -}); \ No newline at end of file +import { describe, expect, test } from 'bun:test'; +import { + getBrowserOpenCommands, + parseOAuthCallbackUrl, + validateOAuthCallbackUrl, +} from '../../../src/core/mcp-http-stdio-proxy.js'; + +describe('getBrowserOpenCommands', () => { + test('uses explorer on Windows so OAuth URLs are not parsed by cmd', () => { + const url = + 'https://idp.example/auth?response_type=code&client_id=test&state=abc'; + + expect(getBrowserOpenCommands(url, 'win32')).toEqual([ + { command: 'explorer.exe', args: [url] }, + ]); + }); +}); + +describe('parseOAuthCallbackUrl', () => { + const redirectUrl = 'http://127.0.0.1:38421/callback'; + const state = 'expected-state'; + + test('returns the code from the registered callback URL', () => { + expect( + parseOAuthCallbackUrl( + `${redirectUrl}?code=authorization-code&state=${state}`, + redirectUrl, + state, + ), + ).toBe('authorization-code'); + }); + + test.each([ + [ + 'different state', + `${redirectUrl}?code=authorization-code&state=wrong-state`, + 'OAuth state validation failed', + ], + [ + 'duplicate state', + `${redirectUrl}?code=authorization-code&state=${state}&state=${state}`, + 'OAuth state validation failed', + ], + [ + 'missing state', + `${redirectUrl}?code=authorization-code`, + 'OAuth state validation failed', + ], + [ + 'different loopback port', + `http://127.0.0.1:9999/callback?code=authorization-code&state=${state}`, + 'OAuth callback URL does not match', + ], + [ + 'different callback path', + `http://127.0.0.1:38421/other?code=authorization-code&state=${state}`, + 'OAuth callback URL does not match', + ], + [ + 'embedded credentials', + `http://user@127.0.0.1:38421/callback?code=authorization-code&state=${state}`, + 'OAuth callback URL does not match', + ], + [ + 'fragment', + `${redirectUrl}?code=authorization-code&state=${state}#fragment`, + 'OAuth callback URL does not match', + ], + [ + 'missing code', + `${redirectUrl}?state=${state}`, + 'No OAuth authorization code received', + ], + [ + 'empty code', + `${redirectUrl}?code=&state=${state}`, + 'No OAuth authorization code received', + ], + [ + 'duplicate code', + `${redirectUrl}?code=one&code=two&state=${state}`, + 'No OAuth authorization code received', + ], + [ + 'code and error', + `${redirectUrl}?code=one&error=access_denied&state=${state}`, + 'Invalid OAuth authorization response', + ], + ])('rejects a callback with %s', (_name, callbackUrl, message) => { + expect(() => + parseOAuthCallbackUrl(callbackUrl, redirectUrl, state), + ).toThrow(message); + }); + + test('accepts an authorization denial as a valid callback envelope', () => { + const callbackUrl = `${redirectUrl}?error=access_denied&state=${state}`; + + expect(() => + validateOAuthCallbackUrl(callbackUrl, redirectUrl, state), + ).not.toThrow(); + expect(() => + parseOAuthCallbackUrl(callbackUrl, redirectUrl, state), + ).toThrow('OAuth authorization failed'); + }); + + test('does not include provider-controlled error text in the exception', () => { + expect(() => + parseOAuthCallbackUrl( + `${redirectUrl}?error=%1B%5B31maccess_denied&state=${state}`, + redirectUrl, + state, + ), + ).toThrow(new Error('OAuth authorization failed')); + }); +}); From f709618015f7859a553083231ccb393de75c03f7 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 19 Sep 2026 10:35:38 +1000 Subject: [PATCH 02/16] feat(mcp): simplify server setup and reauthentication --- CHANGELOG.md | 13 +- README.md | 4 +- .../docs/getting-started/installation.mdx | 2 +- .../content/docs/docs/guides/mcp-proxy.mdx | 225 ++++++++---------- docs/src/content/docs/docs/reference/cli.mdx | 47 ++-- .../docs/docs/reference/configuration.mdx | 9 +- scripts/dev-mcp-server.ts | 2 +- src/cli/agent-help.ts | 16 +- src/cli/commands/mcp.ts | 208 +++++++++------- src/cli/help.ts | 12 +- src/cli/metadata/mcp.ts | 38 +-- src/core/mcp-http-stdio-proxy.ts | 142 +++++++---- src/core/mcp-proxy.ts | 5 +- src/core/mcp-servers.ts | 24 +- tests/e2e/mcp-add-proxy.test.ts | 101 ++++++-- tests/e2e/mcp-proxy-command.test.ts | 32 ++- tests/e2e/mcp-proxy-oauth.test.ts | 81 ++++++- tests/helpers/dummy-mcp-oauth-server.ts | 5 +- tests/unit/cli/agent-help.test.ts | 8 +- tests/unit/core/mcp-proxy.test.ts | 9 + tests/unit/core/mcp-servers.test.ts | 6 +- 21 files changed, 600 insertions(+), 389 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56755ecd..f2c41d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,13 @@ ### Breaking Changes -- **MCP proxy command**: Removed the temporary `allagents mcp proxy-stdio` alias. Use `allagents mcp proxy ` instead. +- **MCP setup commands**: HTTP servers added with `allagents mcp add` now + authenticate and route through AllAgents automatically. The public + `--proxy` option and `mcp auth ` command were replaced by + `mcp reauth `. The generated `mcp proxy` helper remains internal. - **Migration**: Re-run `allagents mcp update` or `allagents update` after upgrading so synced client configs are regenerated with `mcp proxy`. + **Migration**: Remove `--proxy` from `mcp add` calls. Replace + `allagents mcp auth ` with `allagents mcp reauth `. - **Plugin Git ref terminology**: Renamed workspace plugin `pin` to `ref`, CLI `--pin` to `--ref`, and sync-state `pinnedRef` to `requestedRef`. Inline @@ -19,8 +23,9 @@ - Added the official TradingView MCP plugin with OAuth-backed access to market data, analytics, watchlists, alerts, news, and screeners. -- Added `allagents mcp auth` for OAuth login from headless or remote machines, - with interactive callback URL paste and strict redirect/state validation. +- Added automatic OAuth login to `allagents mcp add` and named credential + renewal with `allagents mcp reauth`, including local loopback completion and + remote callback URL paste with strict redirect and state validation. - Pi and OMP as file-sync clients at project and user scope, including native runtime skill paths and agent instructions. diff --git a/README.md b/README.md index 6d477e3d..3e6e8589 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,8 @@ clients: | `allagents profile remove --yes` | Remove unchanged managed profile resources | | `allagents skill add ` | Add a skill from a repo (plural `skills` alias supported) | | `allagents skill list` | List skills and status | -| `allagents mcp add ` | Add an MCP server and sync to clients | -| `allagents mcp proxy ` | Bridge a remote HTTP MCP server to local stdio | +| `allagents mcp add ` | Add, authenticate, and sync an MCP server | +| `allagents mcp reauth ` | Reauthenticate a configured HTTP MCP server | | `allagents mcp list` | List workspace MCP servers | | `allagents workspace status` | Show workspace state | | `allagents self update` | Update AllAgents CLI | diff --git a/docs/src/content/docs/docs/getting-started/installation.mdx b/docs/src/content/docs/docs/getting-started/installation.mdx index bc3788c5..35f01cde 100644 --- a/docs/src/content/docs/docs/getting-started/installation.mdx +++ b/docs/src/content/docs/docs/getting-started/installation.mdx @@ -22,7 +22,7 @@ npx allagents ``` :::note -If you plan to use the [MCP Proxy](/docs/guides/mcp-proxy/) feature, install AllAgents globally (npm or bun) instead. The proxy command is invoked directly by your MCP clients later, not by you through npx, so `allagents` needs to already be resolvable on `PATH` at that point. +If you plan to use [HTTP MCP servers](/docs/guides/mcp-proxy/), install AllAgents globally (npm or bun) instead. Your MCP clients launch AllAgents' generated bridge directly, so `allagents` must remain resolvable on `PATH`; running setup through `npx` alone is not sufficient. ::: ## Using bun diff --git a/docs/src/content/docs/docs/guides/mcp-proxy.mdx b/docs/src/content/docs/docs/guides/mcp-proxy.mdx index 4fa1460a..acaadf7a 100644 --- a/docs/src/content/docs/docs/guides/mcp-proxy.mdx +++ b/docs/src/content/docs/docs/guides/mcp-proxy.mdx @@ -1,153 +1,140 @@ --- -title: MCP Proxy -description: Transparently proxy HTTP MCP servers through a built-in stdio bridge, with OAuth handled for you. +title: HTTP MCP Servers +description: Add, authenticate, and share HTTP MCP servers across AI clients. --- -Some MCP servers use HTTP transport with OAuth authentication, but not every AI client supports HTTP natively. The MCP proxy feature rewrites HTTP server configs to run through AllAgents' own built-in stdio bridge (`allagents mcp proxy `), so all clients connect through an already-authenticated proxy — no separate package to install. - -## Quick Start - -The fastest way to try MCP proxy is to scaffold the ready-made -[`examples/workspaces/mcp-proxy`](https://github.com/allagentsdev/allagents/tree/main/examples/workspaces/mcp-proxy) -workspace with `allagents workspace init --from`: +AllAgents acts as the MCP client for HTTP servers it manages. One command +connects to the server, handles OAuth when required, saves the workspace +configuration, and syncs every selected AI client: ```bash -allagents workspace init ./mcp-proxy-demo \ - --from allagentsdev/allagents/examples/workspaces/mcp-proxy -cd ./mcp-proxy-demo +allagents mcp add tradingview https://mcp.tradingview.com/mcp ``` -This creates a workspace pre-configured with the `deepwiki` plugin — a real -public HTTP MCP server (`https://mcp.deepwiki.com/mcp`) — and an `mcpProxy` -section that rewrites it to stdio for Codex while leaving Claude's HTTP -config untouched: +You do not need to run a separate proxy or authentication command. -```yaml -# .allagents/workspace.yaml -repositories: [] +## Add a Server -plugins: - # Real HTTP MCP server from the official AllAgents marketplace. - # Ships a `.mcp.json` that points at https://mcp.deepwiki.com/mcp - - allagentsdev/allagents/plugins/deepwiki +Use a stable name and the server's HTTP endpoint: -clients: - - claude - - codex - -mcpProxy: - # Claude Code supports HTTP MCP natively, so it gets the original URL. - # Codex only speaks stdio, so rewrite its config to run through the - # built-in `allagents mcp proxy` bridge. - clients: - - codex +```bash +allagents mcp add deepwiki https://mcp.deepwiki.com/mcp +allagents mcp add tradingview https://mcp.tradingview.com/mcp ``` -`workspace init` also runs the initial sync, so you can immediately inspect -what each client received: +For an HTTP server, `mcp add`: -```bash -cat .mcp.json # Claude — original HTTP config -cat .codex/config.toml # Codex — rewritten to `allagents mcp proxy` stdio -``` +1. Connects as the AllAgents MCP client +2. Opens a browser if the server requires OAuth +3. Verifies the MCP connection before changing `workspace.yaml` +4. Stores the server under `mcpServers` +5. Routes the selected AI clients through AllAgents and syncs their configs -DeepWiki is a public, no-auth MCP server, so this example works end-to-end -with nothing more than `allagents` itself installed. Point any of your -configured clients at the workspace and you can immediately call tools like -`read_wiki_structure` or `ask_question` against any indexed GitHub repo. +Public servers such as DeepWiki complete without a browser. OAuth servers such +as TradingView prompt for login on the first connection. -## Why Use MCP Proxy +### Local and Remote Browsers -- **OAuth handled once** — the built-in proxy runs the full PKCE authorization flow the first time it connects, then caches the client registration and tokens under `~/.allagents/oauth-proxy/`; subsequent connections reuse them (with automatic token refresh) instead of reopening a browser -- **Stdio everywhere** — clients that only support stdio can connect to HTTP servers -- **Transparent** — configure which clients need proxying and AllAgents rewrites configs automatically during sync -- **Nothing extra to install** — the proxy is built into the `allagents` binary; there's no separate package to fetch or cache on first use +The same command supports both environments: -## Configuration +- **Local browser:** approve access and let the browser return to the loopback + callback. The terminal continues automatically. +- **Remote or headless host:** open the printed authorization URL on another + device. After approval, copy the complete loopback callback URL from the + browser address bar and paste it into the waiting terminal prompt. -Add an `mcpProxy` section to your `workspace.yaml`: +AllAgents accepts only the registered loopback address with the exact OAuth +state. -```yaml -mcpProxy: - clients: - - claude - - copilot - servers: - my-internal-api: - proxy: - - codex +In a non-interactive shell, AllAgents still verifies the connection without +opening a browser. Public servers and servers with valid cached credentials +succeed. If fresh OAuth consent is required, the command fails before changing +the workspace; rerun it in an interactive terminal. + +### Client Filters and Headers + +```bash +allagents mcp add internal https://mcp.internal.corp \ + --header Authorization=Bearer-token \ + --client claude,copilot ``` -### Fields +`--client` limits both the server sync and AllAgents routing to those clients. +Without `--client`, routing stays dynamic so clients added to the workspace +later receive the same AllAgents-managed connection. +`--header` values are sent only to the MCP server origin, not to OAuth +discovery or identity-provider origins. + +## Reauthenticate -| Field | Required | Description | -|-------|----------|-------------| -| `clients` | Yes | Default list of clients where all HTTP servers are proxied | -| `servers` | No | Per-server overrides | -| `servers..proxy` | Yes (if server entry exists) | Additional clients to proxy this specific server for | +Use the configured server name, not its URL: -### How It Works +```bash +allagents mcp reauth tradingview +``` -1. During `allagents update`, AllAgents collects MCP servers from installed plugins -2. For each server + client pair, it checks if proxying is needed: - - Is the client listed in `mcpProxy.clients`? - - Is there a per-server override in `mcpProxy.servers..proxy` that includes this client? -3. If yes **and** the server uses HTTP transport (has a `url` field), the config is rewritten to invoke `allagents mcp proxy` via stdio -4. Stdio servers are never transformed — they pass through unchanged +`reauth` clears that server's cached OAuth registration and tokens, then runs a +fresh connection and login. Reconnect the AI client afterward if it already +had an MCP session open. -### Transform Example +## Stdio Servers -A plugin provides an HTTP MCP server: +Stdio servers use the same `add` command and run directly rather than through +the HTTP client: -```json -{ - "knowledge-base": { - "url": "https://knowledge.mcp.example.com" - } -} +```bash +allagents mcp add gh-server npx \ + --arg=-y \ + --arg=@modelcontextprotocol/server-github \ + -e GH_TOKEN=ghp_xxx ``` -With `mcpProxy.clients: [claude]`, the synced config for Claude becomes: +## How HTTP Routing Works + +AI clients have different MCP transport support. AllAgents normalizes that +difference: HTTP servers added with `mcp add` are synced as stdio commands that +launch AllAgents' internal HTTP bridge. OAuth registration and tokens are +therefore shared instead of being configured independently in every client. + +The generated client config may contain an internal invocation like: ```json { - "knowledge-base": { + "tradingview": { "command": "allagents", - "args": ["mcp", "proxy", "https://knowledge.mcp.example.com"] + "args": ["mcp", "proxy", "https://mcp.tradingview.com/mcp"] } } ``` -Other clients not listed in `mcpProxy.clients` receive the original HTTP config unchanged. +This is generated plumbing, not a setup command. -## Per-Server Overrides +### Plugin-Provided Servers -The `servers` map lets you proxy specific servers for additional clients beyond the default list: +Servers declared by plugins are not added through `mcp add`. To route those +through AllAgents, use the advanced `mcpProxy` workspace setting: ```yaml mcpProxy: clients: - - claude + - codex servers: - my-internal-api: + plugin-server: proxy: - - codex + - claude - copilot ``` -In this example: -- **All HTTP servers** are proxied for `claude` (from the default `clients` list) -- **Only `my-internal-api`** is additionally proxied for `codex` and `copilot` +The top-level `clients` list applies to every plugin-provided HTTP server. +Per-server lists add clients for only the named server. Stdio servers are +never transformed. -Per-server `proxy` lists are additive — they extend the default `clients`, not replace them. +## OAuth Cache -## OAuth & Token Cache +AllAgents caches OAuth client registration, tokens, PKCE verifier, and +discovery metadata per server: -The first time `allagents mcp proxy ` connects to a server that requires OAuth, it runs the standard authorization-code + PKCE flow: it registers a client with the server's authorization server (or reuses a cached registration), opens your browser to complete the login, and exchanges the resulting code for tokens. On a headless or remote machine, authorize first with `allagents mcp auth `. - -Client registration, tokens, and discovery metadata are cached per server under: - -``` +```text ~/.allagents/oauth-proxy// client-info.json tokens.json @@ -155,43 +142,31 @@ Client registration, tokens, and discovery metadata are cached per server under: discovery.json ``` -Later connections reuse this cache — no browser prompt — and an expired access token is refreshed automatically using the cached refresh token, still without reopening a browser. If you ever need to force a fresh login for a specific server (e.g. a revoked token), delete that server's subdirectory and reconnect. +Later connections reuse valid tokens and refresh expired access tokens when a +refresh token is available. Use `mcp reauth ` instead of deleting cache +files by hand. -### Headless or Remote Browser Login +## Prerequisites -When the browser is on another device, its redirect to `127.0.0.1` cannot -reach the AllAgents process. Run the interactive login directly: +Install `allagents` globally so AI clients can launch it from their generated +MCP configuration: ```bash -allagents mcp auth https://mcp.tradingview.com/mcp +npm install -g allagents +# or +bun install -g allagents ``` -Open the printed authorization URL on any device. After approval, the browser -may show that the loopback page cannot be reached; copy the complete callback -URL from the address bar and paste it into the AllAgents prompt. AllAgents -accepts only the registered loopback address with the exact OAuth state, then -stores the resulting credentials in the same per-server cache used by -`mcp proxy`. Restart or reconnect the MCP client afterward. - -Only clients selected by `mcpProxy` use this cache. Clients synced with a native -HTTP configuration continue to use their own OAuth flow, so configure the -target client for proxying before running `mcp auth`. - -## Prerequisites - -None beyond `allagents` itself — the proxy has no separate runtime dependency to fetch or cache. - -:::caution -`allagents` must be installed and on `PATH` (`npm install -g allagents` or `bun install -g allagents`) — running it via `npx allagents` is **not** enough for this feature. The proxy command is invoked directly by each MCP client (Claude Code, Codex, etc.), not by you, and the generated config embeds a bare `command: "allagents"`. `npx` resolves and runs the package for *your own* shell invocation, but doesn't add anything to `PATH` for another process to find afterward — so a client spawning that config later will fail with "command not found" unless `allagents` is genuinely installed. -::: +Running `npx allagents` for setup is not sufficient because it does not leave +an `allagents` executable on `PATH` for clients to launch later. ## Scope -MCP proxy works with both project-scoped and user-scoped syncs. +HTTP MCP routing works with both project-scoped and user-scoped syncs. ### Project Scope -During `allagents update`, proxied servers are written to each client's project-level MCP config file: +During `allagents update`, routed servers are written to each client's project-level MCP config file: | Client | Config File | |--------|------------| @@ -202,4 +177,4 @@ During `allagents update`, proxied servers are written to each client's project- ### User Scope -When using `--scope user`, proxied servers are synced via client CLI commands (`claude mcp add`, `codex mcp add`) to user-level config, making them available across all projects. +When using `--scope user`, routed servers are synced via client CLI commands (`claude mcp add`, `codex mcp add`) to user-level config, making them available across all projects. diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index aa458d96..11bc64cc 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -408,8 +408,7 @@ Local plugin sources are listed separately in `data.skippedLocalSources`. The JS ```bash allagents mcp add [options] -allagents mcp auth [--header KEY=VALUE...] -allagents mcp proxy [--header KEY=VALUE...] +allagents mcp reauth allagents mcp remove allagents mcp list allagents mcp get @@ -420,7 +419,9 @@ Manage MCP servers at the workspace level. Servers are persisted in a top-level ### mcp add -Add a new MCP server to `workspace.yaml` and immediately sync it to all configured clients. +Add a new MCP server to `workspace.yaml` and immediately sync it to all +configured clients. For HTTP servers, AllAgents connects first, completes OAuth +when required, and routes selected clients through its built-in MCP client. | Flag | Description | |------|-------------| @@ -433,6 +434,10 @@ Add a new MCP server to `workspace.yaml` and immediately sync it to all configur **Transport auto-detection:** if `` starts with `http://` or `https://`, http transport is selected; otherwise stdio is selected. Passing `--transport stdio` with a URL, or `--transport http` with a non-URL command, is rejected. +Non-interactive HTTP adds perform the same connection check without starting a +browser. They can use cached credentials, but fail before mutation when fresh +OAuth consent is required. + ```bash # HTTP server allagents mcp add deepwiki https://mcp.deepwiki.com/mcp @@ -447,40 +452,22 @@ allagents mcp add gh-server npx --arg=-y --arg=@modelcontextprotocol/server-gith allagents mcp add deepwiki https://new.example.com --force ``` -### mcp auth - -Authorize an OAuth-enabled HTTP MCP server before connecting it through a -headless or remote machine. Open the printed authorization URL in any browser. -When that browser redirects to an unreachable loopback address, copy the full -URL from its address bar and paste it into the prompt. AllAgents validates the -registered callback address and OAuth state before exchanging the code, then -caches the resulting credentials for `mcp proxy`. +### mcp reauth -| Flag | Description | -|------|-------------| -| `--header ` | HTTP header forwarded to the upstream MCP server (repeatable) | +Force a fresh OAuth login for a workspace-managed HTTP MCP server. Pass the +configured server name: ```bash -allagents mcp auth https://mcp.tradingview.com/mcp +allagents mcp reauth tradingview ``` -Restart or reconnect the MCP client after authorization so `mcp proxy` can use -the cached token. - -### mcp proxy - -Expose a remote HTTP MCP server locally over stdio. This is the helper command AllAgents writes into proxied client configs when `mcpProxy` rewrites an HTTP server for clients that only support stdio transport. +AllAgents clears the cached OAuth credentials for that server, opens a browser, +and verifies a new connection. If the browser is on another device, paste the +complete loopback callback URL into the waiting terminal prompt. -| Flag | Description | -|------|-------------| -| `--header ` | HTTP header forwarded to the upstream MCP server (repeatable) | - -```bash -allagents mcp proxy https://mcp.deepwiki.com/mcp -allagents mcp proxy https://mcp.internal.corp --header Authorization=Bearer-token -``` +The HTTP-to-stdio bridge written into generated client configs is an internal +implementation detail; users do not need to run it directly. -`auth` is the interactive login command; `proxy` remains the helper written to generated client configs. ### mcp remove diff --git a/docs/src/content/docs/docs/reference/configuration.mdx b/docs/src/content/docs/docs/reference/configuration.mdx index 65f29cfb..4f922e38 100644 --- a/docs/src/content/docs/docs/reference/configuration.mdx +++ b/docs/src/content/docs/docs/reference/configuration.mdx @@ -529,9 +529,12 @@ Manage these entries declaratively in `workspace.yaml`, or via the [`allagents m Servers AllAgents adds are tracked in `.allagents/sync-state.json`; pre-existing user-managed servers in client MCP configs (`.mcp.json`, `.vscode/mcp.json`, `.copilot/mcp-config.json`, `.codex/config.toml`) are never touched. -## MCP Proxy +## Advanced MCP Routing -The optional `mcpProxy` section rewrites HTTP MCP servers to stdio via AllAgents' built-in `allagents mcp proxy` bridge for clients that need it. See the [MCP Proxy guide](/docs/guides/mcp-proxy/) for details. +HTTP servers created with `allagents mcp add` are routed through AllAgents +automatically. The optional `mcpProxy` section applies the same built-in bridge +to HTTP servers supplied by plugins, globally or per server. See the +[HTTP MCP Servers guide](/docs/guides/mcp-proxy/) for details. ```yaml mcpProxy: @@ -548,7 +551,7 @@ mcpProxy: |-------|----------|-------------| | `clients` | Yes | Clients where all HTTP servers are proxied to stdio | | `servers` | No | Per-server overrides with additional client lists | -| `servers..proxy` | Yes (per entry) | Additional clients to proxy this specific server for | +| `servers..proxy` | Yes (per entry) | Additional clients for this server, or `*` for every current and future project MCP client | Only servers with HTTP transport (`url` field) are transformed. Stdio servers pass through unchanged. diff --git a/scripts/dev-mcp-server.ts b/scripts/dev-mcp-server.ts index 40808d3c..78ba98c1 100644 --- a/scripts/dev-mcp-server.ts +++ b/scripts/dev-mcp-server.ts @@ -9,7 +9,7 @@ async function main() { console.log(` OAuth issuer: ${server.idpIssuer}`); console.log(''); console.log('Point allagents at it, e.g.:'); - console.log(` allagents mcp add local-dev ${server.mcpUrl} --proxy`); + console.log(` allagents mcp add local-dev ${server.mcpUrl}`); console.log(` bun run scripts/smoke-mcp-oauth.ts ${server.mcpUrl}`); console.log(''); console.log( diff --git a/src/cli/agent-help.ts b/src/cli/agent-help.ts index 2fa274c2..18d44588 100644 --- a/src/cli/agent-help.ts +++ b/src/cli/agent-help.ts @@ -1,6 +1,13 @@ import type { AgentCommandMeta } from './help.js'; import { normalizeSkillHelpArgs } from './skill-arg-normalizer.js'; -import { mcpAuthMeta } from './metadata/mcp.js'; +import { + mcpAddMeta, + mcpGetMeta, + mcpListMeta, + mcpReauthMeta, + mcpRemoveMeta, + mcpUpdateMeta, +} from './metadata/mcp.js'; import { skillsAddMeta, @@ -40,7 +47,12 @@ const allCommands: AgentCommandMeta[] = [ setupMeta, syncMeta, statusMeta, - mcpAuthMeta, + mcpAddMeta, + mcpReauthMeta, + mcpRemoveMeta, + mcpListMeta, + mcpGetMeta, + mcpUpdateMeta, pluginInstallMeta, pluginUninstallMeta, pluginUpdateMeta, diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts index 344edd00..410c33f9 100644 --- a/src/cli/commands/mcp.ts +++ b/src/cli/commands/mcp.ts @@ -21,9 +21,10 @@ import { setWorkspaceMcpServerProxy, } from '../../core/mcp-servers.js'; import { - validateOAuthCallbackUrl, - runHttpMcpOAuthLogin, + type ConnectHttpMcpServerOptions, + connectHttpMcpServer, runHttpMcpStdioProxy, + validateOAuthCallbackUrl, } from '../../core/mcp-http-stdio-proxy.js'; import { syncMcpOnly } from '../../core/mcp-sync.js'; import { @@ -35,10 +36,10 @@ import { formatMcpResult } from '../format-sync.js'; import { buildDescription, conciseSubcommands } from '../help.js'; import { isJsonMode, jsonOutput } from '../json-output.js'; import { - mcpAuthMeta, mcpAddMeta, mcpGetMeta, mcpListMeta, + mcpReauthMeta, mcpRemoveMeta, mcpUpdateMeta, } from '../metadata/mcp.js'; @@ -124,6 +125,69 @@ function buildConfigFromAddFlags( return built.config; } +async function connectConfiguredHttpServer( + commandName: string, + serverUrl: string, + headers: Record | undefined, + mode: { + resetCredentials: boolean; + allowAuthorization: boolean; + }, +): Promise { + try { + const options: ConnectHttpMcpServerOptions = { + headers: headers ?? {}, + resetCredentials: mode.resetCredentials, + allowAuthorization: mode.allowAuthorization, + }; + if (mode.allowAuthorization) { + options.callbackUrlReader = async ({ redirectUrl, state, signal }) => { + const callbackUrl = await password({ + message: 'Paste the OAuth callback URL if using another browser', + signal, + validate: (value) => { + if (!value) { + return 'OAuth callback URL is required'; + } + try { + validateOAuthCallbackUrl(value, redirectUrl, state); + return undefined; + } catch (error) { + return error instanceof Error + ? error.message + : 'Invalid OAuth callback URL'; + } + }, + }); + if (isCancel(callbackUrl)) { + throw new Error('OAuth authorization cancelled'); + } + return callbackUrl; + }; + } + await connectHttpMcpServer(serverUrl, options); + } catch (error) { + exitWithError( + commandName, + error instanceof Error ? error.message : String(error), + ); + } +} + +async function getConfiguredMcpServer( + commandName: string, + name: string, +): Promise { + try { + return await getWorkspaceMcpServer(name, process.cwd()); + } catch (error) { + exitWithError( + commandName, + error instanceof Error ? error.message : String(error), + ); + } +} + /** * Run MCP-only sync after a mutation and print per-scope results. Always runs * offline because the mutation only affects local workspace.yaml and does not @@ -226,11 +290,6 @@ const addArgs = { long: 'client', description: 'Comma-separated list of client filters', }), - proxy: flag({ - long: 'proxy', - description: - 'Rewrite HTTP MCP server sync through the built-in AllAgents HTTP proxy helper for the targeted clients', - }), }; const mcpAddCmd = command({ @@ -252,7 +311,6 @@ const mcpAddCmd = command({ env, header, client, - proxy, force, }) => { const config = buildConfigFromAddFlags( @@ -264,11 +322,21 @@ const mcpAddCmd = command({ header, client, ); - const proxyClients = client ? parseClientFilter(client) : undefined; - if (proxy && !('url' in config)) { + const existing = await getConfiguredMcpServer('mcp add', name); + if (existing && !force) { exitWithError( 'mcp add', - '--proxy is only supported for HTTP MCP servers', + `MCP server '${name}' already exists in workspace.yaml. Use --force to replace it.`, + ); + } + + if ('url' in config) { + const allowAuthorization = !isJsonMode() && Boolean(process.stdin.isTTY); + await connectConfiguredHttpServer( + 'mcp add', + config.url, + config.headers, + { resetCredentials: false, allowAuthorization }, ); } @@ -281,16 +349,16 @@ const mcpAddCmd = command({ if (!addResult.success) exitWithError('mcp add', addResult.error ?? 'Unknown error'); - if (proxy) { + if ('url' in config) { const proxyResult = await setWorkspaceMcpServerProxy( name, process.cwd(), - proxyClients, + config.clients, ); if (!proxyResult.success) { exitWithError( 'mcp add', - proxyResult.error ?? 'Failed to persist MCP proxy config', + proxyResult.error ?? 'Failed to configure AllAgents MCP routing', ); } } else if (force) { @@ -301,7 +369,7 @@ const mcpAddCmd = command({ if (!clearResult.success) { exitWithError( 'mcp add', - clearResult.error ?? 'Failed to clear MCP proxy config', + clearResult.error ?? 'Failed to clear AllAgents MCP routing', ); } } @@ -312,7 +380,6 @@ const mcpAddCmd = command({ { name, config: addResult.config, - proxy, }, ); }, @@ -341,71 +408,45 @@ const mcpRemoveCmd = command({ }); // ============================================================================= -// mcp auth +// mcp reauth // ============================================================================= -const mcpAuthCmd = command({ - name: 'auth', - description: buildDescription(mcpAuthMeta), +const mcpReauthCmd = command({ + name: 'reauth', + description: buildDescription(mcpReauthMeta), args: { - serverUrl: positional({ type: string, displayName: 'serverUrl' }), - header: addArgs.header, + name: positional({ type: string, displayName: 'name' }), }, - handler: async ({ serverUrl, header }) => { - if (isJsonMode()) { - exitWithError('mcp auth', 'OAuth login requires an interactive terminal'); - } - if (!process.stdin.isTTY) { - exitWithError('mcp auth', 'OAuth login requires an interactive terminal'); - } - - const headerResult = parseKeyValuePairs(header, '--header'); - if ('error' in headerResult) { - exitWithError('mcp auth', headerResult.error); + handler: async ({ name }) => { + if (isJsonMode() || !process.stdin.isTTY) { + exitWithError( + 'mcp reauth', + 'OAuth login requires an interactive terminal', + ); } - try { - await runHttpMcpOAuthLogin( - serverUrl, - async ({ authorizationUrl, redirectUrl, state }) => { - console.log('Open this URL in any browser:'); - console.log(authorizationUrl.toString()); - console.log( - `After approval, copy the full ${redirectUrl} URL from the browser address bar.`, - ); - - const callbackUrl = await password({ - message: 'Paste the full OAuth callback URL', - validate: (value) => { - if (!value) { - return 'OAuth callback URL is required'; - } - try { - validateOAuthCallbackUrl(value, redirectUrl, state); - return undefined; - } catch (error) { - return error instanceof Error - ? error.message - : 'Invalid OAuth callback URL'; - } - }, - }); - if (isCancel(callbackUrl)) { - throw new Error('OAuth authorization cancelled'); - } - return callbackUrl; - }, - headerResult.values, + const config = await getConfiguredMcpServer('mcp reauth', name); + if (!config) { + exitWithError( + 'mcp reauth', + `MCP server '${name}' is not defined in workspace.yaml`, ); - } catch (error) { + } + if (!('url' in config)) { exitWithError( - 'mcp auth', - error instanceof Error ? error.message : String(error), + 'mcp reauth', + `MCP server '${name}' uses stdio and cannot be reauthenticated`, ); } + await connectConfiguredHttpServer( + 'mcp reauth', + config.url, + config.headers, + { resetCredentials: true, allowAuthorization: true }, + ); console.log( - `\u2713 OAuth authorization complete for ${terminalSafe(serverUrl)}`, + `\u2713 Reauthenticated MCP server '${terminalSafe(name)}'`, ); }, }); @@ -578,16 +619,19 @@ const mcpUpdateCmd = command({ // mcp group // ============================================================================= -export const mcpCmd = conciseSubcommands({ - name: 'mcp', - description: 'Manage MCP servers for AI clients', - cmds: { - auth: mcpAuthCmd, - add: mcpAddCmd, - proxy: mcpProxyCmd, - remove: mcpRemoveCmd, - list: mcpListCmd, - get: mcpGetCmd, - update: mcpUpdateCmd, +export const mcpCmd = conciseSubcommands( + { + name: 'mcp', + description: 'Manage MCP servers for AI clients', + cmds: { + add: mcpAddCmd, + reauth: mcpReauthCmd, + proxy: mcpProxyCmd, + remove: mcpRemoveCmd, + list: mcpListCmd, + get: mcpGetCmd, + update: mcpUpdateCmd, + }, }, -}); + ['proxy'], +); diff --git a/src/cli/help.ts b/src/cli/help.ts index 5cf71a7d..dc7366fd 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -1,4 +1,5 @@ import { subcommands } from 'cmd-ts'; +import { terminalSafe } from './terminal-output.js'; /** * Command metadata type and help text builder for enriched --help output. @@ -68,6 +69,7 @@ export function buildDescription(meta: CommandMeta): string { */ export function conciseSubcommands( config: Parameters[0], + hiddenSubcommands: readonly string[] = [], ): ReturnType { const result = subcommands(config); const originalPrintHelp = result.printHelp.bind(result); @@ -82,13 +84,21 @@ export function conciseSubcommands( cmd.description = cmd.description.split('\n')[0] ?? cmd.description; } } - const output = originalPrintHelp(context); + let output = originalPrintHelp(context); for (const [key, cmd] of Object.entries(config.cmds)) { const original = originals.get(key); if (original !== undefined) { cmd.description = original; } } + for (const name of hiddenSubcommands) { + output = output + .split('\n') + .filter( + (line) => !terminalSafe(line).startsWith(`- ${name} -`), + ) + .join('\n'); + } return output; }; diff --git a/src/cli/metadata/mcp.ts b/src/cli/metadata/mcp.ts index 0897d77e..d0b470c8 100644 --- a/src/cli/metadata/mcp.ts +++ b/src/cli/metadata/mcp.ts @@ -2,18 +2,17 @@ import type { AgentCommandMeta } from '../help.js'; export const mcpAddMeta: AgentCommandMeta = { command: 'mcp add', - description: 'Add an MCP server to workspace.yaml and sync to clients', + description: 'Add an MCP server, authenticate, and sync it to clients', whenToUse: - 'When adding a new MCP server that you want AllAgents to manage and sync to all configured clients', + 'When adding a new MCP server that AllAgents should connect to and manage for configured clients', examples: [ 'allagents mcp add deepwiki https://mcp.deepwiki.com/mcp', 'allagents mcp add my-server npx --arg=-y --arg=@my/mcp-server', 'allagents mcp add gh-api npx -e GH_TOKEN=abc123 --arg=-y --arg=@modelcontextprotocol/server-github', 'allagents mcp add deepwiki https://mcp.deepwiki.com/mcp --client claude,copilot', - 'allagents mcp add secure-api https://api.example.com/mcp --proxy', ], expectedOutput: - 'Adds the server to workspace.yaml and syncs it to all configured clients. With --proxy, HTTP servers are rewritten through the built-in AllAgents HTTP-to-stdio proxy path for the targeted clients. Exit 0 on success, 1 on failure.', + 'For HTTP servers, connects and completes OAuth when needed, adds the server to workspace.yaml, and routes selected clients through AllAgents. Stdio servers are added directly. Exit 0 on success, 1 on failure.', positionals: [ { name: 'name', @@ -59,12 +58,6 @@ export const mcpAddMeta: AgentCommandMeta = { description: 'Comma-separated list of clients that should receive this server (default: all project-scoped clients)', }, - { - flag: '--proxy', - type: 'boolean', - description: - 'For HTTP servers, persist server-scoped proxy intent and sync targeted clients via the built-in AllAgents HTTP proxy helper', - }, { flag: '--force', short: '-f', @@ -118,30 +111,23 @@ export const mcpGetMeta: AgentCommandMeta = { ], }; -export const mcpAuthMeta: AgentCommandMeta = { - command: 'mcp auth', - description: 'Authorize an HTTP MCP server from a local or remote browser', +export const mcpReauthMeta: AgentCommandMeta = { + command: 'mcp reauth', + description: 'Reauthenticate a configured HTTP MCP server', whenToUse: - 'When an OAuth-enabled MCP server is running on a headless or remote machine and the browser cannot reach its loopback callback', + 'When a workspace-managed HTTP MCP server needs a fresh OAuth login', examples: [ - 'allagents mcp auth https://mcp.tradingview.com/mcp', - 'allagents mcp auth https://mcp.internal.corp --header Authorization=Bearer-token', + 'allagents mcp reauth tradingview', + 'allagents mcp reauth secure-api', ], expectedOutput: - 'Prints an authorization URL, prompts for the full loopback callback URL, validates the callback state, and caches OAuth credentials. Exit 0 on success, 1 on cancellation or failure.', + 'Clears cached OAuth credentials for the named server, opens a browser for login, accepts a pasted callback URL when the browser is remote, and verifies the connection. Exit 0 on success, 1 on cancellation or failure.', positionals: [ { - name: 'serverUrl', + name: 'name', type: 'string', required: true, - description: 'Remote HTTP MCP server URL', - }, - ], - options: [ - { - flag: '--header', - type: 'string', - description: 'HTTP header KEY=VALUE (repeatable)', + description: 'Workspace-managed HTTP MCP server name', }, ], }; diff --git a/src/core/mcp-http-stdio-proxy.ts b/src/core/mcp-http-stdio-proxy.ts index 450ff376..8b199e2d 100644 --- a/src/core/mcp-http-stdio-proxy.ts +++ b/src/core/mcp-http-stdio-proxy.ts @@ -45,6 +45,7 @@ export interface OAuthCallbackRequest { authorizationUrl: URL; redirectUrl: string; state: string; + signal: AbortSignal; } export type OAuthCallbackUrlReader = ( @@ -299,12 +300,14 @@ class FileOAuthClientProvider implements OAuthClientProvider { private discovery: OAuthDiscoveryState | undefined = undefined; private codeVerifierValue: string | undefined = undefined; private pendingAuth: Promise | undefined = undefined; + private authorizationUnavailable = false; private readonly stateValue = randomUUID(); constructor( private readonly port: number, serverUrl: string, private readonly callbackUrlReader?: OAuthCallbackUrlReader, + private readonly allowAuthorization = true, ) { const cacheDir = getCacheDir(serverUrl); this.clientInfoPath = join(cacheDir, 'client-info.json'); @@ -372,9 +375,11 @@ class FileOAuthClientProvider implements OAuthClientProvider { } redirectToAuthorization(authorizationUrl: URL): void { - this.pendingAuth ??= this.callbackUrlReader - ? this.waitForPastedAuthorizationCode(authorizationUrl) - : this.waitForAuthorizationCode(authorizationUrl); + if (!this.allowAuthorization) { + this.authorizationUnavailable = true; + return; + } + this.pendingAuth ??= this.waitForAuthorizationCode(authorizationUrl); } async saveCodeVerifier(codeVerifier: string): Promise { @@ -424,32 +429,54 @@ class FileOAuthClientProvider implements OAuthClientProvider { } async waitForAuthCode(): Promise { + if (this.authorizationUnavailable) { + throw new Error( + 'OAuth authorization requires an interactive terminal', + ); + } if (!this.pendingAuth) { throw new Error('OAuth authorization has not been started'); } return this.pendingAuth; } - private async waitForPastedAuthorizationCode( - authorizationUrl: URL, - ): Promise { - if (!this.callbackUrlReader) { - throw new Error('OAuth callback URL reader is unavailable'); - } - const callbackUrl = await this.callbackUrlReader({ - authorizationUrl, - redirectUrl: this.redirectUriValue, - state: this.stateValue, - }); - return parseOAuthCallbackUrl( - callbackUrl, - this.redirectUriValue, - this.stateValue, - ); - } private waitForAuthorizationCode(authorizationUrl: URL): Promise { const { promise, resolve, reject } = Promise.withResolvers(); + const readerAbortController = new AbortController(); + let settled = false; + const settle = ( + outcome: 'resolve' | 'reject', + value: string | Error, + ): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + readerAbortController.abort(); + if (server.listening) server.close(); + if (outcome === 'resolve') { + resolve(value as string); + } else { + reject(value as Error); + } + }; + const acceptCallback = (callbackUrl: string): void => { + try { + settle( + 'resolve', + parseOAuthCallbackUrl( + callbackUrl, + this.redirectUriValue, + this.stateValue, + ), + ); + } catch (error) { + settle( + 'reject', + error instanceof Error ? error : new Error(String(error)), + ); + } + }; const server = createServer( (request: IncomingMessage, response: ServerResponse) => { try { @@ -462,15 +489,13 @@ class FileOAuthClientProvider implements OAuthClientProvider { this.redirectUriValue, this.stateValue, ); - response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', }); response.end( '

Authorization complete

You can close this window.

', ); - server.close(); - resolve(code); + settle('resolve', code); } catch (error) { response.writeHead(400, { 'content-type': 'text/html; charset=utf-8', @@ -478,26 +503,27 @@ class FileOAuthClientProvider implements OAuthClientProvider { response.end( '

Authorization failed

The OAuth response was rejected.

', ); - server.close(); - reject(error instanceof Error ? error : new Error(String(error))); + settle( + 'reject', + error instanceof Error ? error : new Error(String(error)), + ); } }, ); - const timeout = setTimeout(() => { - server.close(); - reject(new Error('Timed out waiting for OAuth authorization callback')); + settle( + 'reject', + new Error('Timed out waiting for OAuth authorization callback'), + ); }, AUTH_TIMEOUT_MS); - - server.on('close', () => { - clearTimeout(timeout); - }); - server.on('error', reject); + server.on('error', (error) => settle('reject', error)); server.listen(this.port, '127.0.0.1', () => { console.error('Opening browser for authorization...'); console.error(`${AUTH_URL_LOG_PREFIX}${authorizationUrl.toString()}`); console.error( - 'Remote browser? Stop this MCP client, run `allagents mcp auth ` in a terminal, then reconnect.', + this.callbackUrlReader + ? 'Using a remote browser? Paste its callback URL in this terminal.' + : 'Using a remote browser? Run `allagents mcp reauth ` in this workspace, then reconnect.', ); // Test-only escape hatch: e2e tests fetch the URL themselves against a local // dummy IdP, and skipping the real OS browser-open avoids ever launching one. @@ -508,15 +534,31 @@ class FileOAuthClientProvider implements OAuthClientProvider { } else { void tryOpenBrowser(authorizationUrl.toString()); } + if (this.callbackUrlReader) { + void this.callbackUrlReader({ + authorizationUrl, + redirectUrl: this.redirectUriValue, + state: this.stateValue, + signal: readerAbortController.signal, + }) + .then(acceptCallback) + .catch((error) => { + if (readerAbortController.signal.aborted) return; + settle( + 'reject', + error instanceof Error ? error : new Error(String(error)), + ); + }); + } }); - return promise; } } async function buildOAuthProvider( serverUrl: string, - callbackUrlReader?: OAuthCallbackUrlReader, + callbackUrlReader: OAuthCallbackUrlReader | undefined, + allowAuthorization: boolean, ): Promise { const cacheDir = getCacheDir(serverUrl); const cachedClientInfo = await readJsonFile( @@ -532,6 +574,7 @@ async function buildOAuthProvider( port, serverUrl, callbackUrlReader, + allowAuthorization, ); await provider.load(); return provider; @@ -559,8 +602,13 @@ async function connectRemoteTransport( serverUrl: string, headers: Record, callbackUrlReader?: OAuthCallbackUrlReader, + allowAuthorization = true, ): Promise { - const provider = await buildOAuthProvider(serverUrl, callbackUrlReader); + const provider = await buildOAuthProvider( + serverUrl, + callbackUrlReader, + allowAuthorization, + ); const client = new Client( { name: 'AllAgents', @@ -594,15 +642,25 @@ async function connectRemoteTransport( return { client, transport }; } -export async function runHttpMcpOAuthLogin( +export interface ConnectHttpMcpServerOptions { + headers?: Record; + callbackUrlReader?: OAuthCallbackUrlReader; + resetCredentials?: boolean; + allowAuthorization?: boolean; +} + +export async function connectHttpMcpServer( serverUrl: string, - callbackUrlReader: OAuthCallbackUrlReader, - headers: Record = {}, + options: ConnectHttpMcpServerOptions = {}, ): Promise { + if (options.resetCredentials) { + await rm(getCacheDir(serverUrl), { recursive: true, force: true }); + } const { client, transport } = await connectRemoteTransport( serverUrl, - headers, - callbackUrlReader, + options.headers ?? {}, + options.callbackUrlReader, + options.allowAuthorization ?? true, ); try { await transport.terminateSession(); diff --git a/src/core/mcp-proxy.ts b/src/core/mcp-proxy.ts index b5798f96..66b1b35d 100644 --- a/src/core/mcp-proxy.ts +++ b/src/core/mcp-proxy.ts @@ -12,7 +12,10 @@ export function shouldProxy( return true; } const serverOverride = config.servers?.[serverName]; - if (serverOverride?.proxy.includes(client)) { + if ( + serverOverride?.proxy.includes('*') || + serverOverride?.proxy.includes(client) + ) { return true; } return false; diff --git a/src/core/mcp-servers.ts b/src/core/mcp-servers.ts index 409bd2c2..dc29cb54 100644 --- a/src/core/mcp-servers.ts +++ b/src/core/mcp-servers.ts @@ -4,25 +4,14 @@ import { join } from 'node:path'; import { dump } from 'js-yaml'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; import type { - ClientEntry, ClientType, McpServerConfig, WorkspaceConfig, } from '../models/workspace-config.js'; -import { - McpServerConfigSchema, - getClientTypes, -} from '../models/workspace-config.js'; +import { McpServerConfigSchema } from '../models/workspace-config.js'; import { ensureWorkspace } from './workspace-modify.js'; import { parseWorkspaceConfigForEdit } from '../utils/workspace-parser.js'; -const PROJECT_MCP_CLIENTS: ReadonlySet = new Set([ - 'claude', - 'codex', - 'vscode', - 'copilot', - 'universal', -]); /** * Result of add/remove/update operations on workspace mcpServers. @@ -37,7 +26,7 @@ export interface McpServerModifyResult { export interface McpProxyModifyResult { success: boolean; error?: string; - proxyClients?: ClientType[]; + proxyClients?: string[]; } function removeServerScopedProxyIntent( @@ -75,11 +64,6 @@ async function writeConfig( await writeFile(configPath, dump(config, { lineWidth: -1 }), 'utf-8'); } -function getProjectMcpClients(entries: ClientEntry[]): ClientType[] { - return getClientTypes(entries).filter((client): client is ClientType => - PROJECT_MCP_CLIENTS.has(client), - ); -} /** * Validate a server config via the McpServerConfigSchema. Returns a @@ -161,9 +145,7 @@ export async function setWorkspaceMcpServerProxy( }; } - const resolvedClients = [ - ...new Set(proxyClients ?? getProjectMcpClients(workspaceConfig.clients)), - ]; + const resolvedClients = [...new Set(proxyClients ?? ['*'])]; workspaceConfig.mcpProxy ??= { clients: [] }; workspaceConfig.mcpProxy.clients ??= []; workspaceConfig.mcpProxy.servers ??= {}; diff --git a/tests/e2e/mcp-add-proxy.test.ts b/tests/e2e/mcp-add-proxy.test.ts index dc788577..098d6017 100644 --- a/tests/e2e/mcp-add-proxy.test.ts +++ b/tests/e2e/mcp-add-proxy.test.ts @@ -3,6 +3,10 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { load } from 'js-yaml'; +import { + type DummyMcpOAuthServer, + startDummyMcpOAuthServer, +} from '../helpers/dummy-mcp-oauth-server.js'; interface CliResult { exitCode: number; @@ -10,9 +14,13 @@ interface CliResult { stderr: string; } -function runCli(workdir: string, homeDir: string, args: string[]): CliResult { +async function runCli( + workdir: string, + homeDir: string, + args: string[], +): Promise { const cliEntry = join(import.meta.dir, '..', '..', 'src', 'cli', 'index.ts'); - const proc = Bun.spawnSync(['bun', 'run', cliEntry, '--json', ...args], { + const proc = Bun.spawn(['bun', 'run', cliEntry, '--json', ...args], { cwd: workdir, env: { ...process.env, @@ -21,12 +29,13 @@ function runCli(workdir: string, homeDir: string, args: string[]): CliResult { stderr: 'pipe', stdout: 'pipe', }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); - return { - exitCode: proc.exitCode, - stdout: new TextDecoder().decode(proc.stdout), - stderr: new TextDecoder().decode(proc.stderr), - }; + return { exitCode, stdout, stderr }; } function readWorkspaceConfig(workspaceDir: string): Record { @@ -36,23 +45,26 @@ function readWorkspaceConfig(workspaceDir: string): Record { >; } -describe('mcp add --proxy e2e', () => { +describe('mcp add HTTP client routing e2e', () => { let workspaceDir: string; let homeDir: string; + let dummy: DummyMcpOAuthServer; - beforeEach(() => { + beforeEach(async () => { workspaceDir = join(tmpdir(), `allagents-e2e-mcp-add-proxy-${Date.now()}-${Math.random().toString(36).slice(2)}`); homeDir = join(tmpdir(), `allagents-e2e-home-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(join(workspaceDir, '.allagents'), { recursive: true }); mkdirSync(homeDir, { recursive: true }); + dummy = await startDummyMcpOAuthServer({ requireAuth: false }); }); - afterEach(() => { + afterEach(async () => { + await dummy.stop(); rmSync(workspaceDir, { recursive: true, force: true }); rmSync(homeDir, { recursive: true, force: true }); }); - test('adds deepwiki with proxy enabled for all configured MCP clients', () => { + test('adds deepwiki with proxy enabled for all configured MCP clients', async () => { writeFileSync( join(workspaceDir, '.allagents', 'workspace.yaml'), `repositories: [] @@ -66,12 +78,11 @@ clients: 'utf-8', ); - const result = runCli(workspaceDir, homeDir, [ + const result = await runCli(workspaceDir, homeDir, [ 'mcp', 'add', 'deepwiki', - 'https://mcp.deepwiki.com/mcp', - '--proxy', + dummy.mcpUrl, ]); expect(result.exitCode).toBe(0); @@ -80,13 +91,13 @@ clients: const workspace = readWorkspaceConfig(workspaceDir); expect(workspace.mcpServers).toEqual({ - deepwiki: { type: 'http', url: 'https://mcp.deepwiki.com/mcp' }, + deepwiki: { type: 'http', url: dummy.mcpUrl }, }); expect(workspace.mcpProxy).toEqual({ clients: [], servers: { deepwiki: { - proxy: ['claude', 'codex', 'vscode', 'copilot'], + proxy: ['*'], }, }, }); @@ -96,12 +107,12 @@ clients: expect(claudeConfig.mcpServers.deepwiki.args).toEqual([ 'mcp', 'proxy', - 'https://mcp.deepwiki.com/mcp', + dummy.mcpUrl, ]); const codexConfig = readFileSync(join(workspaceDir, '.codex', 'config.toml'), 'utf-8'); expect(codexConfig).toContain('proxy'); - expect(codexConfig).toContain('https://mcp.deepwiki.com/mcp'); + expect(codexConfig).toContain(dummy.mcpUrl); const vscodeConfig = JSON.parse(readFileSync(join(workspaceDir, '.vscode', 'mcp.json'), 'utf-8')); expect(vscodeConfig.servers.deepwiki.command).toBe('allagents'); @@ -111,7 +122,7 @@ clients: expect(copilotConfig.mcpServers.deepwiki.command).toBe('allagents'); expect(copilotConfig.mcpServers.deepwiki.args[0]).toBe('mcp'); - const rerun = runCli(workspaceDir, homeDir, ['mcp', 'update']); + const rerun = await runCli(workspaceDir, homeDir, ['mcp', 'update']); expect(rerun.exitCode).toBe(0); const rerunPayload = JSON.parse(rerun.stdout); expect(rerunPayload.success).toBe(true); @@ -121,7 +132,7 @@ clients: expect(rerunPayload.data.mcpResults.copilot.added).toBe(0); }); - test('scopes proxying to selected clients with --client', () => { + test('scopes proxying to selected clients with --client', async () => { writeFileSync( join(workspaceDir, '.allagents', 'workspace.yaml'), `repositories: [] @@ -134,12 +145,11 @@ clients: 'utf-8', ); - const result = runCli(workspaceDir, homeDir, [ + const result = await runCli(workspaceDir, homeDir, [ 'mcp', 'add', 'secure-api', - 'https://api.example.com/mcp', - '--proxy', + dummy.mcpUrl, '--client', 'claude,codex', ]); @@ -150,7 +160,7 @@ clients: expect(workspace.mcpServers).toEqual({ 'secure-api': { type: 'http', - url: 'https://api.example.com/mcp', + url: dummy.mcpUrl, clients: ['claude', 'codex'], }, }); @@ -167,4 +177,47 @@ clients: expect(existsSync(join(workspaceDir, '.codex', 'config.toml'))).toBe(true); expect(existsSync(join(workspaceDir, '.vscode', 'mcp.json'))).toBe(false); }); + + test('fails before mutation when a non-interactive HTTP preflight cannot connect', async () => { + writeFileSync( + join(workspaceDir, '.allagents', 'workspace.yaml'), + `repositories: [] +plugins: [] +clients: + - claude +`, + 'utf-8', + ); + + const result = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'add', + 'unreachable', + 'http://127.0.0.1:1/mcp', + ]); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).success).toBe(false); + expect(readWorkspaceConfig(workspaceDir).mcpServers).toBeUndefined(); + }); + + test('returns a structured error for malformed workspace config', async () => { + writeFileSync( + join(workspaceDir, '.allagents', 'workspace.yaml'), + 'repositories: [', + 'utf-8', + ); + + const result = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'add', + 'example', + 'https://example.com/mcp', + ]); + + expect(result.exitCode).toBe(1); + const payload = JSON.parse(result.stdout); + expect(payload.success).toBe(false); + expect(payload.command).toBe('mcp add'); + }); }); diff --git a/tests/e2e/mcp-proxy-command.test.ts b/tests/e2e/mcp-proxy-command.test.ts index bbc176b7..9742a846 100644 --- a/tests/e2e/mcp-proxy-command.test.ts +++ b/tests/e2e/mcp-proxy-command.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from 'bun:test'; import { join } from 'node:path'; -function runCli(args: string[]) { +function runCli(args: string[], env: Record = {}) { const cliEntry = join(import.meta.dir, '..', '..', 'src', 'cli', 'index.ts'); const proc = Bun.spawnSync(['bun', 'run', cliEntry, ...args], { cwd: process.cwd(), - env: process.env, + env: { ...process.env, ...env }, stderr: 'pipe', stdout: 'pipe', }); @@ -17,23 +17,33 @@ function runCli(args: string[]) { }; } -describe('mcp proxy command help', () => { - test('lists proxy as the canonical subcommand', () => { +describe('mcp public command help', () => { + test('lists setup and reauthentication without exposing the proxy helper', () => { const result = runCli(['mcp', '--help']); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('- proxy - Expose a remote HTTP MCP server locally over stdio'); - expect(result.stdout).toContain( - '- auth - Authorize an HTTP MCP server from a local or remote browser', - ); + expect(result.stdout).toContain('- add - Add an MCP server'); + expect(result.stdout).toContain('- reauth - Reauthenticate a configured HTTP MCP server'); + expect(result.stdout).not.toContain('- auth -'); + expect(result.stdout).not.toContain('- proxy -'); expect(result.stdout).not.toContain('- proxy-stdio -'); }); - test('exposes auth through machine-readable agent help', () => { - const result = runCli(['--agent-help', 'mcp', 'auth']); + test('keeps proxy hidden when help uses ANSI color', () => { + const result = runCli(['mcp', '--help'], { FORCE_COLOR: '1' }); expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout).command).toBe('mcp auth'); + expect(result.stdout).not.toContain('Expose a remote HTTP MCP server locally over stdio'); + }); + + test('exposes add and reauth through machine-readable agent help', () => { + const addResult = runCli(['--agent-help', 'mcp', 'add']); + const reauthResult = runCli(['--agent-help', 'mcp', 'reauth']); + + expect(addResult.exitCode).toBe(0); + expect(JSON.parse(addResult.stdout).command).toBe('mcp add'); + expect(reauthResult.exitCode).toBe(0); + expect(JSON.parse(reauthResult.stdout).command).toBe('mcp reauth'); }); test('rejects proxy-stdio after the rename', () => { diff --git a/tests/e2e/mcp-proxy-oauth.test.ts b/tests/e2e/mcp-proxy-oauth.test.ts index df5eb769..6126f3ea 100644 --- a/tests/e2e/mcp-proxy-oauth.test.ts +++ b/tests/e2e/mcp-proxy-oauth.test.ts @@ -3,8 +3,8 @@ import { mkdirSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { + connectHttpMcpServer, hashServerUrl, - runHttpMcpOAuthLogin, } from '../../src/core/mcp-http-stdio-proxy.ts'; import { type DummyMcpOAuthServer, @@ -37,6 +37,8 @@ function connectAndAutoAuthorize( describe('mcp proxy OAuth e2e', () => { let homeDir: string; let dummy: DummyMcpOAuthServer | undefined; + const originalTestHome = process.env.ALLAGENTS_TEST_HOME; + const originalNoBrowser = process.env.ALLAGENTS_MCP_OAUTH_NO_BROWSER; beforeEach(() => { homeDir = join( @@ -44,12 +46,24 @@ describe('mcp proxy OAuth e2e', () => { `allagents-e2e-oauth-home-${Date.now()}-${Math.random().toString(36).slice(2)}`, ); mkdirSync(homeDir, { recursive: true }); + process.env.ALLAGENTS_TEST_HOME = homeDir; + process.env.ALLAGENTS_MCP_OAUTH_NO_BROWSER = '1'; }); afterEach(async () => { rmSync(homeDir, { recursive: true, force: true }); await dummy?.stop(); dummy = undefined; + if (originalTestHome === undefined) { + delete process.env.ALLAGENTS_TEST_HOME; + } else { + process.env.ALLAGENTS_TEST_HOME = originalTestHome; + } + if (originalNoBrowser === undefined) { + delete process.env.ALLAGENTS_MCP_OAUTH_NO_BROWSER; + } else { + process.env.ALLAGENTS_MCP_OAUTH_NO_BROWSER = originalNoBrowser; + } }); test('completes OAuth and calls a tool on the first connection', async () => { @@ -93,9 +107,8 @@ describe('mcp proxy OAuth e2e', () => { try { const resourceSecret = 'resource-server-only'; - await runHttpMcpOAuthLogin( - dummy.mcpUrl, - async ({ authorizationUrl }) => { + await connectHttpMcpServer(dummy.mcpUrl, { + callbackUrlReader: async ({ authorizationUrl }) => { const response = await fetch(authorizationUrl, { redirect: 'manual', }); @@ -104,8 +117,8 @@ describe('mcp proxy OAuth e2e', () => { expect(location).toBeTruthy(); return new URL(location!, authorizationUrl).toString(); }, - { 'x-resource-secret': resourceSecret }, - ); + headers: { 'x-resource-secret': resourceSecret }, + }); expect(dummy.authorizeCallCount).toBe(1); expect(dummy.tokenCallCounts.authorization_code).toBe(1); @@ -132,6 +145,62 @@ describe('mcp proxy OAuth e2e', () => { } }, 15000); + test('accepts a local callback while the remote paste fallback is pending', async () => { + dummy = await startDummyMcpOAuthServer(); + let fallbackAborted = false; + + await connectHttpMcpServer(dummy.mcpUrl, { + callbackUrlReader: ({ authorizationUrl, signal }) => { + void fetch(authorizationUrl); + return new Promise((_, reject) => { + signal.addEventListener( + 'abort', + () => { + fallbackAborted = true; + reject(new Error('Local callback completed')); + }, + { once: true }, + ); + }); + }, + }); + + expect(fallbackAborted).toBe(true); + expect(dummy.authorizeCallCount).toBe(1); + }, 15000); + + test('forces a fresh OAuth flow when credentials are reset', async () => { + dummy = await startDummyMcpOAuthServer(); + const authorize = async ({ authorizationUrl }: { authorizationUrl: URL }) => { + const response = await fetch(authorizationUrl, { redirect: 'manual' }); + const location = response.headers.get('location'); + expect(location).toBeTruthy(); + return new URL(location!, authorizationUrl).toString(); + }; + + await connectHttpMcpServer(dummy.mcpUrl, { + callbackUrlReader: authorize, + }); + expect(dummy.authorizeCallCount).toBe(1); + + await connectHttpMcpServer(dummy.mcpUrl, { + callbackUrlReader: authorize, + resetCredentials: true, + }); + expect(dummy.authorizeCallCount).toBe(2); + }, 15000); + + test('fails without prompting when authorization is disabled', async () => { + dummy = await startDummyMcpOAuthServer(); + + await expect( + connectHttpMcpServer(dummy.mcpUrl, { + allowAuthorization: false, + }), + ).rejects.toThrow('OAuth authorization requires an interactive terminal'); + expect(dummy.authorizeCallCount).toBe(0); + }, 15000); + test('reuses the cached token on a second connection without re-authorizing', async () => { dummy = await startDummyMcpOAuthServer(); diff --git a/tests/helpers/dummy-mcp-oauth-server.ts b/tests/helpers/dummy-mcp-oauth-server.ts index 849d48eb..54543638 100644 --- a/tests/helpers/dummy-mcp-oauth-server.ts +++ b/tests/helpers/dummy-mcp-oauth-server.ts @@ -37,6 +37,8 @@ interface TokenRecord { export interface StartDummyMcpOAuthServerOptions { /** Access token lifetime in ms. Short values let tests exercise the refresh path. */ accessTokenTtlMs?: number; + /** Disable authentication when a test only needs a reachable HTTP MCP server. */ + requireAuth?: boolean; } export interface DummyMcpOAuthServer { @@ -108,6 +110,7 @@ export async function startDummyMcpOAuthServer( options: StartDummyMcpOAuthServerOptions = {}, ): Promise { const accessTokenTtlMs = options.accessTokenTtlMs ?? 60_000; + const requireAuth = options.requireAuth ?? true; const registeredClientIds = new Set(); const authCodes = new Map(); @@ -334,7 +337,7 @@ export async function startDummyMcpOAuthServer( const record = token ? accessTokens.get(token) : undefined; const isValid = record !== undefined && record.expiresAt > Date.now(); - if (!isValid) { + if (requireAuth && !isValid) { res.writeHead(401, { 'content-type': 'text/plain', 'www-authenticate': `Bearer resource_metadata="${mcpUrl}/.well-known/oauth-protected-resource"`, diff --git a/tests/unit/cli/agent-help.test.ts b/tests/unit/cli/agent-help.test.ts index c9ab2ece..192c8473 100644 --- a/tests/unit/cli/agent-help.test.ts +++ b/tests/unit/cli/agent-help.test.ts @@ -178,9 +178,11 @@ describe('findMetaByCommand', () => { expect(meta!.command).toBe('status'); }); - test('resolves the interactive MCP OAuth command', () => { - const meta = findMetaByCommand('mcp auth https://mcp.tradingview.com/mcp'); - expect(meta?.command).toBe('mcp auth'); + test('resolves the public MCP setup commands', () => { + const addMeta = findMetaByCommand('mcp add tradingview https://mcp.tradingview.com/mcp'); + const reauthMeta = findMetaByCommand('mcp reauth tradingview'); + expect(addMeta?.command).toBe('mcp add'); + expect(reauthMeta?.command).toBe('mcp reauth'); }); test('resolves deprecated "workspace status" alias to status meta', () => { diff --git a/tests/unit/core/mcp-proxy.test.ts b/tests/unit/core/mcp-proxy.test.ts index 018c6f37..683a231d 100644 --- a/tests/unit/core/mcp-proxy.test.ts +++ b/tests/unit/core/mcp-proxy.test.ts @@ -34,6 +34,15 @@ describe('shouldProxy', () => { expect(shouldProxy('my-api', 'codex', config)).toBe(true); }); + test('returns true for every client when a server uses the wildcard', () => { + const config: McpProxyConfig = { + clients: [], + servers: { 'my-api': { proxy: ['*'] } }, + }; + expect(shouldProxy('my-api', 'claude', config)).toBe(true); + expect(shouldProxy('my-api', 'future-client', config)).toBe(true); + }); + test('returns true when client is in both default and per-server', () => { const config: McpProxyConfig = { clients: ['claude'], diff --git a/tests/unit/core/mcp-servers.test.ts b/tests/unit/core/mcp-servers.test.ts index 13146608..082d221a 100644 --- a/tests/unit/core/mcp-servers.test.ts +++ b/tests/unit/core/mcp-servers.test.ts @@ -92,7 +92,7 @@ describe('addWorkspaceMcpServer', () => { expect(cfg.clients).toEqual(['claude']); }); - test('persists server-scoped proxy intent without widening global proxy clients', async () => { + test('persists dynamic server-scoped proxy intent without widening global proxy clients', async () => { await addWorkspaceMcpServer( 'wtgkb', { type: 'http', url: 'https://knowledge.mcp.wtg.zone' }, @@ -101,13 +101,13 @@ describe('addWorkspaceMcpServer', () => { const result = await setWorkspaceMcpServerProxy('wtgkb', dir); expect(result.success).toBe(true); - expect(result.proxyClients).toEqual(['claude']); + expect(result.proxyClients).toEqual(['*']); const cfg = readWorkspace(dir); expect(cfg.mcpProxy).toEqual({ clients: [], servers: { - wtgkb: { proxy: ['claude'] }, + wtgkb: { proxy: ['*'] }, }, }); }); From d5073dd4c3f606c36afc1e78027dd7289c4e6588 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 19 Sep 2026 11:17:32 +1000 Subject: [PATCH 03/16] feat(mcp): run generated bridge through npx --- CHANGELOG.md | 3 ++ .../docs/getting-started/installation.mdx | 4 ++- .../content/docs/docs/guides/mcp-proxy.mdx | 30 +++++++++++-------- src/core/mcp-proxy.ts | 11 +++++-- tests/e2e/mcp-add-proxy.test.ts | 29 +++++++++++++----- tests/unit/core/mcp-proxy-cli.test.ts | 24 ++++++++++----- tests/unit/core/mcp-proxy.test.ts | 21 +++++++++---- 7 files changed, 86 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2c41d8b..8d0e608f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ - Added automatic OAuth login to `allagents mcp add` and named credential renewal with `allagents mcp reauth`, including local loopback completion and remote callback URL paste with strict redirect and state validation. +- Generated HTTP MCP bridges now invoke the current pinned AllAgents version + through cached `npx`, so managed MCP connections do not require a global + AllAgents installation. - Pi and OMP as file-sync clients at project and user scope, including native runtime skill paths and agent instructions. diff --git a/docs/src/content/docs/docs/getting-started/installation.mdx b/docs/src/content/docs/docs/getting-started/installation.mdx index 35f01cde..716a9978 100644 --- a/docs/src/content/docs/docs/getting-started/installation.mdx +++ b/docs/src/content/docs/docs/getting-started/installation.mdx @@ -22,7 +22,9 @@ npx allagents ``` :::note -If you plan to use [HTTP MCP servers](/docs/guides/mcp-proxy/), install AllAgents globally (npm or bun) instead. Your MCP clients launch AllAgents' generated bridge directly, so `allagents` must remain resolvable on `PATH`; running setup through `npx` alone is not sufficient. +HTTP MCP servers also work when setup is run through `npx`. Generated client +configs invoke a pinned AllAgents version through `npx`, which reuses npm's +package cache after the first launch. ::: ## Using bun diff --git a/docs/src/content/docs/docs/guides/mcp-proxy.mdx b/docs/src/content/docs/docs/guides/mcp-proxy.mdx index acaadf7a..2944839a 100644 --- a/docs/src/content/docs/docs/guides/mcp-proxy.mdx +++ b/docs/src/content/docs/docs/guides/mcp-proxy.mdx @@ -101,13 +101,21 @@ The generated client config may contain an internal invocation like: ```json { "tradingview": { - "command": "allagents", - "args": ["mcp", "proxy", "https://mcp.tradingview.com/mcp"] + "command": "npx", + "args": [ + "-y", + "allagents@", + "mcp", + "proxy", + "https://mcp.tradingview.com/mcp" + ] } } ``` -This is generated plumbing, not a setup command. +This is generated plumbing, not a setup command. AllAgents pins the package +version that created the config, and `npx` reuses npm's package cache on later +launches. ### Plugin-Provided Servers @@ -148,17 +156,13 @@ files by hand. ## Prerequisites -Install `allagents` globally so AI clients can launch it from their generated -MCP configuration: +The generated bridge requires Node.js with `npx` available. A global AllAgents +installation is not required: both setup and the generated MCP connection can +use the npm cache. -```bash -npm install -g allagents -# or -bun install -g allagents -``` - -Running `npx allagents` for setup is not sufficient because it does not leave -an `allagents` executable on `PATH` for clients to launch later. +The first bridge launch downloads the pinned AllAgents package if that version +is not already cached. Later launches reuse the cached package. Environments +that must work offline should prime the cache before disconnecting. ## Scope diff --git a/src/core/mcp-proxy.ts b/src/core/mcp-proxy.ts index 66b1b35d..7f0ce0f0 100644 --- a/src/core/mcp-proxy.ts +++ b/src/core/mcp-proxy.ts @@ -1,4 +1,5 @@ import type { McpProxyConfig } from '../models/workspace-config.js'; +import packageJson from '../../package.json'; /** * Determine if a server+client pair should be proxied. @@ -43,7 +44,13 @@ function toProxiedConfig( url: string, headers?: Record, ): Record { - const args = ['mcp', 'proxy', url]; + const args = [ + '-y', + `allagents@${packageJson.version}`, + 'mcp', + 'proxy', + url, + ]; if (headers) { for (const [key, value] of Object.entries(headers)) { args.push('--header', `${key}=${value}`); @@ -51,7 +58,7 @@ function toProxiedConfig( } return { - command: 'allagents', + command: 'npx', args, }; } diff --git a/tests/e2e/mcp-add-proxy.test.ts b/tests/e2e/mcp-add-proxy.test.ts index 098d6017..b574c424 100644 --- a/tests/e2e/mcp-add-proxy.test.ts +++ b/tests/e2e/mcp-add-proxy.test.ts @@ -7,6 +7,9 @@ import { type DummyMcpOAuthServer, startDummyMcpOAuthServer, } from '../helpers/dummy-mcp-oauth-server.js'; +import packageJson from '../../package.json'; + +const packageRef = `allagents@${packageJson.version}`; interface CliResult { exitCode: number; @@ -103,24 +106,36 @@ clients: }); const claudeConfig = JSON.parse(readFileSync(join(workspaceDir, '.mcp.json'), 'utf-8')); - expect(claudeConfig.mcpServers.deepwiki.command).toBe('allagents'); + expect(claudeConfig.mcpServers.deepwiki.command).toBe('npx'); expect(claudeConfig.mcpServers.deepwiki.args).toEqual([ + '-y', + packageRef, 'mcp', 'proxy', dummy.mcpUrl, ]); const codexConfig = readFileSync(join(workspaceDir, '.codex', 'config.toml'), 'utf-8'); - expect(codexConfig).toContain('proxy'); - expect(codexConfig).toContain(dummy.mcpUrl); + expect(codexConfig).toContain('npx'); + expect(codexConfig).toContain(packageRef); const vscodeConfig = JSON.parse(readFileSync(join(workspaceDir, '.vscode', 'mcp.json'), 'utf-8')); - expect(vscodeConfig.servers.deepwiki.command).toBe('allagents'); - expect(vscodeConfig.servers.deepwiki.args[0]).toBe('mcp'); + expect(vscodeConfig.servers.deepwiki.command).toBe('npx'); + expect(vscodeConfig.servers.deepwiki.args.slice(0, 4)).toEqual([ + '-y', + packageRef, + 'mcp', + 'proxy', + ]); const copilotConfig = JSON.parse(readFileSync(join(workspaceDir, '.copilot', 'mcp-config.json'), 'utf-8')); - expect(copilotConfig.mcpServers.deepwiki.command).toBe('allagents'); - expect(copilotConfig.mcpServers.deepwiki.args[0]).toBe('mcp'); + expect(copilotConfig.mcpServers.deepwiki.command).toBe('npx'); + expect(copilotConfig.mcpServers.deepwiki.args.slice(0, 4)).toEqual([ + '-y', + packageRef, + 'mcp', + 'proxy', + ]); const rerun = await runCli(workspaceDir, homeDir, ['mcp', 'update']); expect(rerun.exitCode).toBe(0); diff --git a/tests/unit/core/mcp-proxy-cli.test.ts b/tests/unit/core/mcp-proxy-cli.test.ts index 761002ab..76d8e65e 100644 --- a/tests/unit/core/mcp-proxy-cli.test.ts +++ b/tests/unit/core/mcp-proxy-cli.test.ts @@ -7,6 +7,9 @@ import { buildCodexMcpAddArgs } from '../../../src/core/codex-mcp.js'; import { syncVscodeMcpConfig } from '../../../src/core/vscode-mcp.js'; import { applyMcpProxy } from '../../../src/core/mcp-proxy.js'; import type { McpProxyConfig } from '../../../src/models/workspace-config.js'; +import packageJson from '../../../package.json'; + +const packageRef = `allagents@${packageJson.version}`; describe('CLI args with proxy transform', () => { test('buildClaudeMcpAddArgs handles proxied HTTP config', () => { @@ -19,8 +22,8 @@ describe('CLI args with proxy transform', () => { const args = buildClaudeMcpAddArgs('deepwiki', proxiedConfig); expect(args).toEqual([ - 'mcp', 'add', '--scope', 'user', 'deepwiki', '--', 'allagents', - 'mcp', 'proxy', 'https://mcp.deepwiki.com/mcp', + 'mcp', 'add', '--scope', 'user', 'deepwiki', '--', 'npx', + '-y', packageRef, 'mcp', 'proxy', 'https://mcp.deepwiki.com/mcp', ]); }); @@ -34,8 +37,8 @@ describe('CLI args with proxy transform', () => { const args = buildCodexMcpAddArgs('deepwiki', proxiedConfig); expect(args).toEqual([ - 'mcp', 'add', 'deepwiki', '--', 'allagents', - 'mcp', 'proxy', 'https://mcp.deepwiki.com/mcp', + 'mcp', 'add', 'deepwiki', '--', 'npx', + '-y', packageRef, 'mcp', 'proxy', 'https://mcp.deepwiki.com/mcp', ]); }); }); @@ -57,8 +60,8 @@ describe('syncVscodeMcpConfig with serverOverrides', () => { test('writes proxied stdio config when serverOverrides is provided', () => { const proxiedServers = new Map([ ['deepwiki', { - command: 'allagents', - args: ['mcp', 'proxy', 'https://mcp.deepwiki.com/mcp'], + command: 'npx', + args: ['-y', packageRef, 'mcp', 'proxy', 'https://mcp.deepwiki.com/mcp'], }], ]); @@ -68,7 +71,12 @@ describe('syncVscodeMcpConfig with serverOverrides', () => { expect(result.addedServers).toEqual(['deepwiki']); const written = JSON.parse(readFileSync(configPath, 'utf-8')); - expect(written.servers.deepwiki.command).toBe('allagents'); - expect(written.servers.deepwiki.args[0]).toBe('mcp'); + expect(written.servers.deepwiki.command).toBe('npx'); + expect(written.servers.deepwiki.args.slice(0, 4)).toEqual([ + '-y', + packageRef, + 'mcp', + 'proxy', + ]); }); }); diff --git a/tests/unit/core/mcp-proxy.test.ts b/tests/unit/core/mcp-proxy.test.ts index 683a231d..7e2a2116 100644 --- a/tests/unit/core/mcp-proxy.test.ts +++ b/tests/unit/core/mcp-proxy.test.ts @@ -7,6 +7,9 @@ import { applyMcpProxy, } from '../../../src/core/mcp-proxy.js'; import type { McpProxyConfig } from '../../../src/models/workspace-config.js'; +import packageJson from '../../../package.json'; + +const packageRef = `allagents@${packageJson.version}`; function makeTempDir(): string { const dir = join(tmpdir(), `allagents-mcp-proxy-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -68,8 +71,14 @@ describe('applyMcpProxy', () => { const config: McpProxyConfig = { clients: ['claude'] }; const result = applyMcpProxy(servers, 'claude', config); expect(result.get('deepwiki')).toEqual({ - command: 'allagents', - args: ['mcp', 'proxy', 'https://mcp.deepwiki.com/mcp'], + command: 'npx', + args: [ + '-y', + packageRef, + 'mcp', + 'proxy', + 'https://mcp.deepwiki.com/mcp', + ], }); }); @@ -98,7 +107,7 @@ describe('applyMcpProxy', () => { ]); const config: McpProxyConfig = { clients: ['copilot'] }; const result = applyMcpProxy(servers, 'copilot', config); - expect((result.get('http-server') as Record).command).toBe('allagents'); + expect((result.get('http-server') as Record).command).toBe('npx'); expect((result.get('stdio-server') as Record).command).toBe('npx'); expect((result.get('stdio-server') as Record).args).toEqual(['some-mcp']); }); @@ -113,7 +122,7 @@ describe('applyMcpProxy', () => { servers: { 'my-api': { proxy: ['codex'] } }, }; const result = applyMcpProxy(servers, 'codex', config); - expect((result.get('my-api') as Record).command).toBe('allagents'); + expect((result.get('my-api') as Record).command).toBe('npx'); expect(result.get('other-api')).toEqual({ url: 'https://other.example.com/mcp' }); }); @@ -130,8 +139,10 @@ describe('applyMcpProxy', () => { const config: McpProxyConfig = { clients: ['claude'] }; const result = applyMcpProxy(servers, 'claude', config); expect(result.get('secure-api')).toEqual({ - command: 'allagents', + command: 'npx', args: [ + '-y', + packageRef, 'mcp', 'proxy', 'https://api.example.com/mcp', From 6a7c49c7bc5e38833b864ccc7bea65f51abff5e6 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 19 Sep 2026 11:39:11 +1000 Subject: [PATCH 04/16] fix(mcp): use Copilot project config path --- CHANGELOG.md | 6 ++++++ docs/src/content/docs/docs/guides/agent-portability.mdx | 2 +- docs/src/content/docs/docs/guides/mcp-proxy.mdx | 2 +- docs/src/content/docs/docs/reference/configuration.mdx | 2 +- src/core/mcp-sync.ts | 2 +- tests/e2e/copilot-project-mcp.test.ts | 8 ++++---- tests/e2e/mcp-add-proxy.test.ts | 2 +- tests/e2e/plugin-skills.test.ts | 4 +--- 8 files changed, 16 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d0e608f..48c3117c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ **Migration**: Replace `pin:` with `ref:` in plugin objects and `--pin` with `--ref` in scripts. This is a clean cutover; the old names are not accepted. +### Fixed + +- Project-scoped Copilot MCP servers are now written to `.github/mcp.json`, + which Copilot CLI discovers, instead of the unsupported + `.copilot/mcp-config.json` project path. + ### Added - Added the official TradingView MCP plugin with OAuth-backed access to market diff --git a/docs/src/content/docs/docs/guides/agent-portability.mdx b/docs/src/content/docs/docs/guides/agent-portability.mdx index ab4b479c..1ca8b95c 100644 --- a/docs/src/content/docs/docs/guides/agent-portability.mdx +++ b/docs/src/content/docs/docs/guides/agent-portability.mdx @@ -124,7 +124,7 @@ Running `allagents update` syncs agents, skills, hooks, and MCP servers to both | Hooks | `.claude/hooks/` | `.github/hooks/` | | Commands / Prompts | `.claude/commands/` | `.github/prompts/` | | Agent file | `CLAUDE.md` | `AGENTS.md` | -| MCP servers | `.claude/mcp.json` | `.copilot/mcp-config.json` | +| MCP servers | `.claude/mcp.json` | `.github/mcp.json` | | WORKSPACE-RULES | Injected in `CLAUDE.md` | Injected in `AGENTS.md` | ### Skills Are the Strongest Interop Point diff --git a/docs/src/content/docs/docs/guides/mcp-proxy.mdx b/docs/src/content/docs/docs/guides/mcp-proxy.mdx index 2944839a..9efcb164 100644 --- a/docs/src/content/docs/docs/guides/mcp-proxy.mdx +++ b/docs/src/content/docs/docs/guides/mcp-proxy.mdx @@ -176,7 +176,7 @@ During `allagents update`, routed servers are written to each client's project-l |--------|------------| | Claude | `.mcp.json` | | VS Code | `.vscode/mcp.json` | -| Copilot | `.copilot/mcp-config.json` | +| Copilot | `.github/mcp.json` | | Codex | `.codex/config.toml` | ### User Scope diff --git a/docs/src/content/docs/docs/reference/configuration.mdx b/docs/src/content/docs/docs/reference/configuration.mdx index 4f922e38..ab6dd7e6 100644 --- a/docs/src/content/docs/docs/reference/configuration.mdx +++ b/docs/src/content/docs/docs/reference/configuration.mdx @@ -527,7 +527,7 @@ mcpServers: Manage these entries declaratively in `workspace.yaml`, or via the [`allagents mcp` commands](/docs/reference/cli/#mcp-commands). Workspace-level servers override any plugin-supplied server with the same name (with a warning). -Servers AllAgents adds are tracked in `.allagents/sync-state.json`; pre-existing user-managed servers in client MCP configs (`.mcp.json`, `.vscode/mcp.json`, `.copilot/mcp-config.json`, `.codex/config.toml`) are never touched. +Servers AllAgents adds are tracked in `.allagents/sync-state.json`; pre-existing user-managed servers in client MCP configs (`.mcp.json`, `.vscode/mcp.json`, `.github/mcp.json`, `.codex/config.toml`) are never touched. ## Advanced MCP Routing diff --git a/src/core/mcp-sync.ts b/src/core/mcp-sync.ts index 405cfbb1..14d0318a 100644 --- a/src/core/mcp-sync.ts +++ b/src/core/mcp-sync.ts @@ -90,7 +90,7 @@ function buildSyncSpecs(workspacePath: string): McpSyncSpec[] { { client: 'copilot', scope: 'copilot', - configPath: join(workspacePath, '.copilot', 'mcp-config.json'), + configPath: join(workspacePath, '.github', 'mcp.json'), syncFn: syncClaudeMcpConfig, }, ]; diff --git a/tests/e2e/copilot-project-mcp.test.ts b/tests/e2e/copilot-project-mcp.test.ts index fc41a175..d0425f5f 100644 --- a/tests/e2e/copilot-project-mcp.test.ts +++ b/tests/e2e/copilot-project-mcp.test.ts @@ -32,7 +32,7 @@ describe('copilot project-scoped MCP sync e2e', () => { rmSync(testDir, { recursive: true, force: true }); }); - test('sync writes MCP servers to project .copilot/mcp-config.json when copilot client is configured', async () => { + test('sync writes MCP servers to project .github/mcp.json when copilot client is configured', async () => { writeFileSync( join(testDir, '.allagents', 'workspace.yaml'), `repositories: @@ -48,7 +48,7 @@ clients: expect(result.success).toBe(true); - const mcpConfigPath = join(testDir, '.copilot', 'mcp-config.json'); + const mcpConfigPath = join(testDir, '.github', 'mcp.json'); expect(existsSync(mcpConfigPath)).toBe(true); const mcpConfig = JSON.parse(readFileSync(mcpConfigPath, 'utf-8')); @@ -80,7 +80,7 @@ clients: expect(result.success).toBe(true); expect(result.mcpResults?.copilot).toBeUndefined(); - const mcpConfigPath = join(testDir, '.copilot', 'mcp-config.json'); + const mcpConfigPath = join(testDir, '.github', 'mcp.json'); expect(existsSync(mcpConfigPath)).toBe(false); }); @@ -118,7 +118,7 @@ clients: expect(result3.mcpResults?.copilot?.removed).toBe(1); expect(result3.mcpResults?.copilot?.removedServers).toContain('deepwiki'); - const mcpConfig = JSON.parse(readFileSync(join(testDir, '.copilot', 'mcp-config.json'), 'utf-8')); + const mcpConfig = JSON.parse(readFileSync(join(testDir, '.github', 'mcp.json'), 'utf-8')); expect(mcpConfig.mcpServers.deepwiki).toBeUndefined(); }); }); diff --git a/tests/e2e/mcp-add-proxy.test.ts b/tests/e2e/mcp-add-proxy.test.ts index b574c424..6634b8fd 100644 --- a/tests/e2e/mcp-add-proxy.test.ts +++ b/tests/e2e/mcp-add-proxy.test.ts @@ -128,7 +128,7 @@ clients: 'proxy', ]); - const copilotConfig = JSON.parse(readFileSync(join(workspaceDir, '.copilot', 'mcp-config.json'), 'utf-8')); + const copilotConfig = JSON.parse(readFileSync(join(workspaceDir, '.github', 'mcp.json'), 'utf-8')); expect(copilotConfig.mcpServers.deepwiki.command).toBe('npx'); expect(copilotConfig.mcpServers.deepwiki.args.slice(0, 4)).toEqual([ '-y', diff --git a/tests/e2e/plugin-skills.test.ts b/tests/e2e/plugin-skills.test.ts index 10335f97..968c58d2 100644 --- a/tests/e2e/plugin-skills.test.ts +++ b/tests/e2e/plugin-skills.test.ts @@ -333,9 +333,7 @@ description: Blog watcher existsSync(join(tmpDir, '.github', 'skills', 'ediprod', 'SKILL.md')), ).toBe(true); expect(existsSync(join(tmpDir, '.github', 'hooks'))).toBe(false); - expect(existsSync(join(tmpDir, '.copilot', 'mcp-config.json'))).toBe( - false, - ); + expect(existsSync(join(tmpDir, '.github', 'mcp.json'))).toBe(false); } finally { if (originalTestHome === undefined) { delete process.env.ALLAGENTS_TEST_HOME; From dd00719ec225d3e8aa014d7c6548d8c96637502f Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 19 Sep 2026 13:53:35 +1000 Subject: [PATCH 05/16] feat(mcp): add scoped destinations and profile OAuth --- .../plans/2026-09-19-scoped-mcp-profiles.md | 228 ++++++ README.md | 6 +- .../schemas/v1/project-workspace.schema.json | 6 +- .../schemas/v1/user-workspace.schema.json | 12 +- .../content/docs/docs/guides/mcp-proxy.mdx | 113 +-- docs/src/content/docs/docs/reference/cli.mdx | 125 +++- .../docs/docs/reference/configuration.mdx | 30 +- src/cli/agent-help.ts | 3 + src/cli/commands/mcp.ts | 653 ++++++++++++++---- src/cli/help.ts | 2 + src/cli/metadata/mcp.ts | 144 +++- src/cli/metadata/profile.ts | 6 +- src/core/claude-mcp.ts | 83 ++- src/core/codex-mcp.ts | 95 ++- src/core/mcp-http-stdio-proxy.ts | 136 ++-- src/core/mcp-proxy.ts | 51 +- src/core/mcp-servers.ts | 561 ++++++++++----- src/core/mcp-sync.ts | 195 ++++-- src/core/profile/adapters/mcp.ts | 25 +- src/core/profile/files.ts | 2 +- src/core/profile/manager.ts | 91 ++- src/core/profile/plan.ts | 112 +-- src/core/sync.ts | 501 +++++--------- src/core/user-mcp-sync.ts | 143 ++++ src/core/vscode-mcp.ts | 73 +- src/models/workspace-config.ts | 90 ++- src/utils/workspace-parser.ts | 57 +- tests/e2e/mcp-add-proxy.test.ts | 350 +++++++++- tests/e2e/mcp-proxy-oauth.test.ts | 80 +++ tests/unit/cli/agent-help.test.ts | 10 + tests/unit/core/claude-mcp.test.ts | 81 ++- tests/unit/core/codex-mcp.test.ts | 24 + tests/unit/core/mcp-http-stdio-proxy.test.ts | 76 +- tests/unit/core/mcp-proxy.test.ts | 73 ++ tests/unit/core/mcp-servers.test.ts | 451 +++++++----- tests/unit/core/mcp-sync-user.test.ts | 130 ++++ tests/unit/core/profile/codex.test.ts | 20 + tests/unit/core/profile/manager.test.ts | 217 +++++- .../models/workspace-config-mcp-proxy.test.ts | 10 +- .../models/workspace-config-profiles.test.ts | 42 ++ 40 files changed, 3876 insertions(+), 1231 deletions(-) create mode 100644 .claude/plans/2026-09-19-scoped-mcp-profiles.md create mode 100644 src/core/user-mcp-sync.ts create mode 100644 tests/unit/core/mcp-sync-user.test.ts diff --git a/.claude/plans/2026-09-19-scoped-mcp-profiles.md b/.claude/plans/2026-09-19-scoped-mcp-profiles.md new file mode 100644 index 00000000..8c88b7ac --- /dev/null +++ b/.claude/plans/2026-09-19-scoped-mcp-profiles.md @@ -0,0 +1,228 @@ +# Scoped MCP Destinations and Profile OAuth + +## Goal + +Make MCP declarations, synchronization, proxying, and OAuth state operate consistently across three explicit destinations: + +- project: default or `--scope project` +- ordinary user config: `--scope user` +- named profile: `--profile ` + +Profiles are first-class destinations, not client-name aliases. Project and user declarations continue to share ordinary OAuth credentials by URL; each profile owns an isolated credential cache under its profile root. + +## Product contract + +- `--scope` and `--profile` are mutually exclusive on every public MCP command. +- Omitting both selects the current project, except from the home directory where the project path aliases the ordinary user config and resolves to user scope. Explicit `--scope project` is rejected at that alias. +- A profile selector is singular and must name a declared profile. Profile MCP server names use `[A-Za-z0-9_.-]{1,100}`. +- `mcp add --client` is repeatable and remains comma-compatible. Values are trimmed, validated, deduplicated in first-seen order, and explicit empty segments are rejected. +- `mcp add` validates first, then persists the server declaration and server-local proxy intent in one atomically replaced document before synchronization. +- `mcp list` and `mcp get` read only inline declarations for the selected destination; they do not merge plugin-provided servers and redact header, environment, URL credential, and sensitive query values. +- `mcp reauth --profile ` removes and recreates only that profile's credentials. +- `mcp update --scope user` performs MCP-only reconciliation, not a full user workspace sync. +- Profile MCP reconciliation stays inside the profile plan/apply ownership model because Codex and OpenCode combine settings and MCP in one managed artifact. +- A declared but uninstalled profile may be edited, listed, or inspected without being implicitly installed. Synchronization is skipped with an explicit result. Installed profiles reconcile through the normal profile planner. +- Profile deletion removes the fixed profile-owned OAuth subtree before state deletion. Partial cleanup fails closed and remains retryable. +- Profile HTTP headers use exact `${ENV_VAR}` references. Generated bridges preserve only the environment binding and resolve its value at connection time. +- Local credential deletion does not revoke an authorization grant at the remote provider. + +## Configuration model + +Project and ordinary user destinations use top-level `mcpServers` and `mcpProxy`. + +Profiles use destination-local fields: + +```yaml +profiles: + markets: + clients: + - name: codex + - name: copilot + mcpServers: + tradingview: + type: http + url: https://mcp.tradingview.com/mcp + clients: [codex, copilot] + mcpProxy: + servers: + tradingview: + proxy: [codex, copilot] +``` + +`profiles..mcpProxy` reuses the ordinary proxy schema. Proxy client selectors must be `*` or a client declared by that profile. The global `mcpProxy.clients` list becomes optional with an empty default so server-local routing does not require `clients: []` noise. + +## Ownership and paths + +Generated client files remain destination-native: + +| Destination | Codex | Copilot | +| --- | --- | --- | +| project | `.codex/config.toml` | `.github/mcp.json` | +| user | `~/.codex/config.toml` | `~/.copilot/mcp-config.json` | +| profile | `~/.allagents/profiles//clients/codex/home/.config.toml` | `~/.allagents/profiles//clients/copilot/home/mcp-config.json` | + +OAuth cache ownership: + +- project/user: `~/.allagents/oauth-proxy//` +- profile: `~/.allagents/profiles//oauth-proxy//` + +Profile bridge commands include the hidden selector: + +```text +npx -y allagents@ mcp proxy --profile +``` + +Ordinary project/user bridge commands remain byte-for-byte unchanged and omit `--profile`. + +## Architecture + +Introduce one validated destination discriminant resolved at the CLI boundary: + +```ts +type McpDestination = + | { kind: 'project'; workspacePath: string; configPath: string } + | { kind: 'user'; configPath: string } + | { kind: 'profile'; name: ProfileName; configPath: string }; +``` + +Declaration access and mutation hide storage differences. A mutation acquires a destination-file lock, loads once with the scope-correct parser, selects the top-level or profile-local container, validates the destination-specific server and server-name schemas, updates the server and server-local proxy policy in one in-memory document, validates the entire document, and atomically replaces the destination file once. + +Synchronization stays separate: + +- project delegates to existing `syncMcpOnly`; +- user delegates to a new `syncUserMcpOnly`, extracted from the full user sync's existing adapter logic; +- installed profile delegates to profile plan/apply update; declared-only profile returns an explicit skipped reconciliation. + +Profile planning filters and normalizes a client's MCP declarations before applying its profile-local proxy policy. The effective map is then passed to both settings and MCP serializers so combined artifacts remain correct. + +## Implementation units + +### U1 — Destination schemas and atomic declarations + +**Files** +- `src/models/workspace-config.ts` +- `src/utils/workspace-parser.ts` +- `src/core/mcp-servers.ts` +- profile/schema/declaration tests + +**Change** +- Add optional/default-empty global proxy clients and profile-local `mcpProxy` validation. +- Add `McpDestination` resolution with scope/profile exclusion and profile-name validation. +- Generalize get/list/add/remove into destination-aware, atomic operations. +- Preserve unrelated user/profile YAML and remove obsolete project-only mutation APIs after callers migrate. + +**Proof** +- Tests for user/profile access, atomic server-plus-proxy writes, concurrent update serialization, symbolic-link rejection, invalid profile selectors, profile-not-found, preservation, and proxy pruning. + +### U2 — Profile-aware bridge and OAuth ownership + +**Files** +- `src/core/mcp-proxy.ts` +- `src/core/mcp-http-stdio-proxy.ts` +- `src/cli/commands/mcp.ts` hidden proxy path +- proxy/OAuth tests + +**Change** +- Add a validated optional profile scope to generated bridges and runtime OAuth resolution. +- Preserve profile header secrets as `--header-env
=` bindings and resolve them only at the connection boundary. +- Keep ordinary cache behavior unchanged and never persist resolved profile secret values. + +**Proof** +- Exact argv tests, ordinary/profile path tests, reset isolation, traversal rejection, OAuth E2E coverage. + +### U3 — Profile materialization and lifecycle cleanup + +**Files** +- `src/core/profile/plan.ts` +- `src/core/profile/manager.ts` +- `src/core/profile/files.ts` only if a shared safe-removal helper is needed +- profile planner/manager/adapter tests + +**Change** +- Filter each client’s servers before proxy transformation. +- Serialize effective proxied maps through existing adapters and report truthful disclosures. +- Remove only `/oauth-proxy` during profile teardown, including declared-only profiles, before state deletion; reject symlink/non-directory roots and retain state on failure. + +**Proof** +- Exact Codex/Copilot materialization, cross-profile isolation, removal cleanup, unrelated-file preservation, hostile symlink failure. + +### U4 — Ordinary user MCP-only synchronization + +**Files** +- `src/core/mcp-sync.ts` +- `src/core/sync.ts` +- sync/state tests + +**Change** +- Extract one reusable user MCP adapter orchestrator from full user sync. +- Add `syncUserMcpOnly` without plugin artifact/native/profile side effects. +- Preserve name-based ownership, unrelated sync state, and unattempted or failed client ownership. + +**Proof** +- User Codex/Copilot destinations, selector filtering, preservation of untracked entries and unrelated state, profiles ignored by ordinary sync, missing/invalid config behavior, dry-run behavior. + +### U5 — Public command routing + +**Files** +- `src/cli/commands/mcp.ts` +- `src/cli/metadata/mcp.ts` +- CLI/E2E tests + +**Change** +- Add shared destination flags to add/remove/list/get/reauth/update. +- Route declaration access, authentication, mutation, and reconciliation through one resolved destination. +- Parse repeatable plus CSV-compatible `--client` values strictly. +- Add stable destination information to JSON results and destination-aware human output. + +**Proof** +- Default project compatibility; explicit project/user/profile flows; scope/profile conflict; repeatable/mixed client forms; unknown/empty selector rejection before auth or mutation; profile reauth isolation. + +### U6 — Documentation and generated schema + +**Files** +- `README.md` +- `docs/src/content/docs/docs/guides/mcp-proxy.mdx` +- `docs/src/content/docs/docs/reference/cli.mdx` +- `docs/src/content/docs/docs/reference/configuration.mdx` +- generated workspace schemas + +**Change** +- Document destination semantics, repeatable clients, profile-local proxy configuration, exact file/cache paths, generated `--profile`, and local reset versus remote revocation. +- Remove stale `allagents update --scope` documentation. +- Regenerate the user workspace JSON schema. + +**Proof** +- Schema generator and docs build succeed; examples match executable command help. + +## Verification + +Focused red/green checks are run per unit. Final verification: + +```bash +bun run build +bun run typecheck +bun run lint +bun run docs:build +bun test +bun run test:e2e +``` + +Manual isolated-home E2E: + +1. Project add/update produces `.codex/config.toml` and `.github/mcp.json`. +2. User add/update produces `~/.codex/config.toml` and `~/.copilot/mcp-config.json` without running a full workspace sync. +3. Installed profile add/update produces the profile Codex/Copilot files with generated `--profile ` bridge arguments. +4. Two profiles targeting the same URL resolve different OAuth cache directories; project and user resolve the shared ordinary directory. +5. `mcp reauth --profile` removes only the selected profile credentials. +6. Profile removal deletes its OAuth subtree while preserving unrelated residue safely. +7. Real Codex and Copilot clients can invoke a TradingView MCP tool through the materialized bridge where installed credentials permit it. + +## Risks controlled + +- No profile-qualified global client IDs; policy remains destination-local. +- No direct profile file writes outside the ownership-aware planner. +- No full user sync from an MCP-only command. +- No profile-root recursive deletion; only the fixed OAuth subtree is lifecycle-owned. +- No selector widening from empty repeatable arguments. +- No cache-path construction from an unvalidated profile name. +- No separate declaration/proxy writes that can leave partial configuration. diff --git a/README.md b/README.md index 3e6e8589..bbca1031 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,9 @@ clients: | `allagents profile remove --yes` | Remove unchanged managed profile resources | | `allagents skill add ` | Add a skill from a repo (plural `skills` alias supported) | | `allagents skill list` | List skills and status | -| `allagents mcp add ` | Add, authenticate, and sync an MCP server | -| `allagents mcp reauth ` | Reauthenticate a configured HTTP MCP server | -| `allagents mcp list` | List workspace MCP servers | +| `allagents mcp add [--scope user \| --profile ]` | Add, authenticate, and sync an MCP server | +| `allagents mcp reauth [--scope user \| --profile ]` | Reauthenticate an HTTP MCP server in one destination | +| `allagents mcp list [--scope user \| --profile ]` | List MCP declarations in one destination | | `allagents workspace status` | Show workspace state | | `allagents self update` | Update AllAgents CLI | diff --git a/docs/public/schemas/v1/project-workspace.schema.json b/docs/public/schemas/v1/project-workspace.schema.json index e3e35307..1dc9933f 100644 --- a/docs/public/schemas/v1/project-workspace.schema.json +++ b/docs/public/schemas/v1/project-workspace.schema.json @@ -315,7 +315,8 @@ "type": "array", "items": { "type": "string" - } + }, + "default": [] }, "servers": { "type": "object", @@ -336,9 +337,6 @@ } } }, - "required": [ - "clients" - ], "additionalProperties": true }, "mcpServers": { diff --git a/docs/public/schemas/v1/user-workspace.schema.json b/docs/public/schemas/v1/user-workspace.schema.json index 20c15212..067c2b2a 100644 --- a/docs/public/schemas/v1/user-workspace.schema.json +++ b/docs/public/schemas/v1/user-workspace.schema.json @@ -318,7 +318,8 @@ "type": "array", "items": { "type": "string" - } + }, + "default": [] }, "servers": { "type": "object", @@ -339,9 +340,6 @@ } } }, - "required": [ - "clients" - ], "additionalProperties": true }, "mcpServers": { @@ -1038,7 +1036,13 @@ "additionalProperties": false } ] + }, + "propertyNames": { + "pattern": "^[A-Za-z0-9_.-]{1,100}$" } + }, + "mcpProxy": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/mcpProxy" } }, "required": [ diff --git a/docs/src/content/docs/docs/guides/mcp-proxy.mdx b/docs/src/content/docs/docs/guides/mcp-proxy.mdx index 9efcb164..54abb8bf 100644 --- a/docs/src/content/docs/docs/guides/mcp-proxy.mdx +++ b/docs/src/content/docs/docs/guides/mcp-proxy.mdx @@ -4,8 +4,8 @@ description: Add, authenticate, and share HTTP MCP servers across AI clients. --- AllAgents acts as the MCP client for HTTP servers it manages. One command -connects to the server, handles OAuth when required, saves the workspace -configuration, and syncs every selected AI client: +connects to the server, handles OAuth when required, saves the selected project, +user, or profile declaration, and syncs every selected AI client: ```bash allagents mcp add tradingview https://mcp.tradingview.com/mcp @@ -26,8 +26,8 @@ For an HTTP server, `mcp add`: 1. Connects as the AllAgents MCP client 2. Opens a browser if the server requires OAuth -3. Verifies the MCP connection before changing `workspace.yaml` -4. Stores the server under `mcpServers` +3. Verifies the MCP connection before changing the selected declaration +4. Stores the server under that destination's `mcpServers` 5. Routes the selected AI clients through AllAgents and syncs their configs Public servers such as DeepWiki complete without a browser. OAuth servers such @@ -56,26 +56,43 @@ the workspace; rerun it in an interactive terminal. ```bash allagents mcp add internal https://mcp.internal.corp \ --header Authorization=Bearer-token \ - --client claude,copilot + --client claude \ + --client codex,copilot ``` -`--client` limits both the server sync and AllAgents routing to those clients. -Without `--client`, routing stays dynamic so clients added to the workspace -later receive the same AllAgents-managed connection. +`--client` is repeatable and comma-compatible. It limits both server sync and +AllAgents routing; duplicate values are ignored in first-seen order. Without +`--client`, routing stays dynamic so clients added to the destination later +receive the same AllAgents-managed connection. `--header` values are sent only to the MCP server origin, not to OAuth discovery or identity-provider origins. +Profile declarations accept only exact environment references for header values: + +```bash +allagents mcp add tradingview https://mcp.tradingview.com/mcp \ + --profile markets \ + --header 'Authorization=${TRADINGVIEW_TOKEN}' +``` + +Generated profile bridge arguments store the header-to-variable binding, not +the resolved secret. AllAgents resolves the variable immediately before the MCP +connection and fails clearly when it is missing. + ## Reauthenticate Use the configured server name, not its URL: ```bash allagents mcp reauth tradingview +allagents mcp reauth tradingview --scope user +allagents mcp reauth tradingview --profile markets ``` -`reauth` clears that server's cached OAuth registration and tokens, then runs a -fresh connection and login. Reconnect the AI client afterward if it already -had an MCP session open. +`reauth` clears only the selected destination's cached OAuth registration and +tokens, then runs a fresh connection and login. It does not revoke the grant at +the remote provider. Reconnect the AI client afterward if it already had an MCP +session open. ## Stdio Servers @@ -113,6 +130,10 @@ The generated client config may contain an internal invocation like: } ``` +For a named profile, the generated arguments also include `--profile `. +That hidden selector makes every client inside one profile share the same +profile-owned credentials without sharing them with another profile. + This is generated plumbing, not a setup command. AllAgents pins the package version that created the config, and `npx` reuses npm's package cache on later launches. @@ -120,7 +141,7 @@ launches. ### Plugin-Provided Servers Servers declared by plugins are not added through `mcp add`. To route those -through AllAgents, use the advanced `mcpProxy` workspace setting: +through AllAgents, use the advanced destination-local `mcpProxy` setting: ```yaml mcpProxy: @@ -133,26 +154,35 @@ mcpProxy: - copilot ``` -The top-level `clients` list applies to every plugin-provided HTTP server. -Per-server lists add clients for only the named server. Stdio servers are -never transformed. +The optional `clients` list applies to every HTTP server in that destination. +Per-server lists add clients for only the named server, and `*` selects every +eligible client. Profiles place the same shape beside their own `mcpServers` +under `profiles..mcpProxy`; profile policy never uses qualified global +client IDs. Stdio servers are never transformed. ## OAuth Cache AllAgents caches OAuth client registration, tokens, PKCE verifier, and -discovery metadata per server: +discovery metadata per server URL. Project and ordinary user declarations share +the ordinary cache: ```text ~/.allagents/oauth-proxy// - client-info.json - tokens.json - code-verifier.txt - discovery.json ``` -Later connections reuse valid tokens and refresh expired access tokens when a -refresh token is available. Use `mcp reauth ` instead of deleting cache -files by hand. +Each named profile owns an isolated cache: + +```text +~/.allagents/profiles//oauth-proxy// +``` + +Both contain `client-info.json`, `tokens.json`, `code-verifier.txt`, and +`discovery.json`. Clients inside one profile share those files; different +profiles do not. Later connections reuse valid tokens and refresh expired access +tokens when possible. `mcp reauth --profile ` resets only that profile. +Removing an installed or declared-only profile removes its OAuth subtree with +its other profile-owned runtime state. Cleanup rejects symbolic-link or +non-directory OAuth roots and retains retryable state on failure. ## Prerequisites @@ -164,21 +194,22 @@ The first bridge launch downloads the pinned AllAgents package if that version is not already cached. Later launches reuse the cached package. Environments that must work offline should prime the cache before disconnecting. -## Scope - -HTTP MCP routing works with both project-scoped and user-scoped syncs. - -### Project Scope - -During `allagents update`, routed servers are written to each client's project-level MCP config file: - -| Client | Config File | -|--------|------------| -| Claude | `.mcp.json` | -| VS Code | `.vscode/mcp.json` | -| Copilot | `.github/mcp.json` | -| Codex | `.codex/config.toml` | - -### User Scope - -When using `--scope user`, routed servers are synced via client CLI commands (`claude mcp add`, `codex mcp add`) to user-level config, making them available across all projects. +## Destinations + +HTTP MCP routing supports project, ordinary user, and named profile +destinations. Omit destination flags for project scope, use `--scope user` for +ordinary user configuration, or `--profile ` for a profile. `--scope` and +`--profile` cannot be combined. From the home directory, the project path +aliases the user workspace, so an unflagged command resolves to user scope and +explicit `--scope project` is rejected. + +| Destination | Codex | Copilot | +|-------------|-------|---------| +| Project | `.codex/config.toml` | `.github/mcp.json` | +| User | `~/.codex/config.toml` | `~/.copilot/mcp-config.json` | +| Profile | `~/.allagents/profiles//clients/codex/home/.config.toml` | `~/.allagents/profiles//clients/copilot/home/mcp-config.json` | + +Project and ordinary user updates are MCP-only. Updating an installed profile +uses the ownership-aware profile reconciler because some clients combine MCP +and settings in one file. Editing a declared but uninstalled profile does not +implicitly install it. diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index 11bc64cc..01ad1d94 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -6,7 +6,7 @@ description: Complete reference for AllAgents CLI commands. ## Top-Level Commands ```bash -allagents update [--offline] [--dry-run] [--client ] [--scope ] [--profile ...] +allagents update [--offline] [--dry-run] [--client ] [--profile ...] allagents status ``` @@ -19,10 +19,9 @@ Updates plugins in the workspace using non-destructive sync. By default, remote | `--offline` | Use cached plugins without fetching latest from remote | | `--dry-run` | Preview changes without applying them | | `-c, --client ` | Sync only the specified client (e.g., `opencode`, `claude`) | -| `-s, --scope ` | Sync scope: `project` (default) or `user` | | `--profile ` | Reconcile only this installed global profile; repeatable | -When `--scope user` is used, sync targets the user-level workspace at `~/.allagents/workspace.yaml` and installs plugins to user directories (`~/.claude/`, `~/.codex/`, etc.) instead of the project. +Ordinary user, installed profile, and current project reconciliation are selected automatically; `allagents update` does not take a scope flag. Without `--profile`, update independently attempts the user workspace, every installed profile that still has a declaration, and the current project @@ -31,8 +30,7 @@ the command exits nonzero after all applicable work completes. Using one or more `--profile` filters validates the entire selected set before mutation, updates only those installed and still-declared profiles, and skips -ordinary user and project sync. `--profile` cannot be combined with `--scope` -or `--client`. +ordinary user and project sync. `--profile` cannot be combined with `--client`. **Non-destructive behavior:** - First sync overlays files without deleting existing user files @@ -407,104 +405,157 @@ Local plugin sources are listed separately in `data.skippedLocalSources`. The JS ## MCP Commands ```bash -allagents mcp add [options] -allagents mcp reauth -allagents mcp remove -allagents mcp list -allagents mcp get -allagents mcp update [--offline] +allagents mcp add [options] [--scope project|user | --profile ] +allagents mcp reauth [--scope project|user | --profile ] +allagents mcp remove [--scope project|user | --profile ] +allagents mcp list [--scope project|user | --profile ] +allagents mcp get [--scope project|user | --profile ] +allagents mcp update [--offline] [--scope project|user | --profile ] ``` -Manage MCP servers at the workspace level. Servers are persisted in a top-level `mcpServers:` field in `workspace.yaml` (parallel to `mcpProxy:`) and synced to all configured clients that support project-scoped MCP (`claude`, `codex`, `vscode`, `copilot`). +Every MCP command selects one declaration destination. With no selector, +AllAgents uses the current project's `.allagents/workspace.yaml`. From the home +directory that path aliases the ordinary user config, so an unflagged command +resolves to user scope and explicit `--scope project` is rejected. Use +`--scope user` to select top-level MCP fields in `~/.allagents/workspace.yaml`; +`--profile ` selects `profiles.` in that same file. +`--scope` and `--profile` are mutually exclusive. + +Codex and Copilot materialize the selected destination at these exact paths: + +| Destination | Codex | Copilot CLI | +|-------------|-------|-------------| +| Project | `.codex/config.toml` | `.github/mcp.json` | +| User | `~/.codex/config.toml` | `~/.copilot/mcp-config.json` | +| Profile | `~/.allagents/profiles//clients/codex/home/.config.toml` | `~/.allagents/profiles//clients/copilot/home/mcp-config.json` | ### mcp add -Add a new MCP server to `workspace.yaml` and immediately sync it to all -configured clients. For HTTP servers, AllAgents connects first, completes OAuth -when required, and routes selected clients through its built-in MCP client. +Add a new MCP server to the selected destination and immediately reconcile it. +For HTTP servers, AllAgents connects first, completes OAuth when required, and +routes selected clients through its built-in MCP client. | Flag | Description | |------|-------------| | `--transport ` | Transport type: `http` or `stdio` (auto-detected from URL by default) | | `--arg ` | Argument for the stdio command (repeatable) | | `-e, --env ` | Environment variable for stdio transport (repeatable) | -| `--header ` | HTTP header for http transport (repeatable) | -| `--client ` | Comma-separated list of clients that should receive this server (default: all project-scoped clients) | +| `--header ` | HTTP header for HTTP transport (repeatable) | +| `--client ` | Client selector; repeatable and comma-compatible. Values are deduplicated in first-seen order. Defaults to every eligible client in the selected destination. | +| `--scope ` | Destination scope: `project` (default) or `user` | +| `--profile ` | Named profile destination; cannot be combined with `--scope` | | `-f, --force` | Replace an existing server with the same name | -**Transport auto-detection:** if `` starts with `http://` or `https://`, http transport is selected; otherwise stdio is selected. Passing `--transport stdio` with a URL, or `--transport http` with a non-URL command, is rejected. +**Transport auto-detection:** if `` starts with `http://` or +`https://`, HTTP transport is selected; otherwise stdio is selected. Passing +`--transport stdio` with a URL, or `--transport http` with a non-URL command, +is rejected. Non-interactive HTTP adds perform the same connection check without starting a browser. They can use cached credentials, but fail before mutation when fresh OAuth consent is required. +Profile header values must be exact environment references such as +`'Authorization=${TRADINGVIEW_TOKEN}'`. Generated bridge configs retain only +the header-to-variable binding; the value is resolved when the bridge connects. +Missing variables fail before a request is sent. + ```bash -# HTTP server +# Project HTTP server allagents mcp add deepwiki https://mcp.deepwiki.com/mcp -# HTTP server with headers and client filter -allagents mcp add internal https://mcp.internal.corp --header Authorization=Bearer-token --client claude,copilot +# Repeatable and comma-compatible client filters +allagents mcp add internal https://mcp.internal.corp \ + --header Authorization=Bearer-token \ + --client claude --client codex,copilot + +# User destination +allagents mcp add deepwiki https://mcp.deepwiki.com/mcp --scope user + +# Profile destination +allagents mcp add tradingview https://mcp.tradingview.com/mcp --profile markets # stdio server with args and env vars allagents mcp add gh-server npx --arg=-y --arg=@modelcontextprotocol/server-github -e GH_TOKEN=ghp_xxx -# Replace an existing server (update workflow) +# Replace an existing server allagents mcp add deepwiki https://new.example.com --force ``` ### mcp reauth -Force a fresh OAuth login for a workspace-managed HTTP MCP server. Pass the -configured server name: +Force a fresh OAuth login for an HTTP MCP server in the selected destination: ```bash allagents mcp reauth tradingview +allagents mcp reauth tradingview --scope user +allagents mcp reauth tradingview --profile markets ``` -AllAgents clears the cached OAuth credentials for that server, opens a browser, -and verifies a new connection. If the browser is on another device, paste the -complete loopback callback URL into the waiting terminal prompt. +AllAgents clears only the selected credential cache, opens a browser, and +verifies a new connection. Project and ordinary user declarations share the +ordinary URL-keyed cache. Each named profile has an isolated cache. Removing a +local cache does not revoke the grant at the remote authorization provider. If +the browser is on another device, paste the complete loopback callback URL into +the waiting terminal prompt. The HTTP-to-stdio bridge written into generated client configs is an internal implementation detail; users do not need to run it directly. - ### mcp remove -Remove a server from `workspace.yaml` and unsync it from all clients. Only servers AllAgents added are removed; pre-existing user-managed servers in client MCP configs are preserved. +Remove a server from the selected declaration destination and reconcile its +client configs. Only entries tracked by AllAgents are removed; pre-existing +user-managed servers in client MCP configs are preserved. ```bash allagents mcp remove deepwiki +allagents mcp remove deepwiki --scope user +allagents mcp remove tradingview --profile markets ``` ### mcp list -List all MCP servers defined in `workspace.yaml`. +List inline MCP declarations from exactly one selected destination. Header, +environment, URL credential, and sensitive query values are redacted in both +human and JSON output. ```bash allagents mcp list +allagents mcp list --profile markets ``` ### mcp get -Print the `workspace.yaml` definition for a specific server as YAML. +Print one inline declaration from exactly one selected destination as redacted +YAML, or as redacted structured data with `--json`. ```bash allagents mcp get deepwiki +allagents mcp get tradingview --profile markets ``` -Exits with status 1 if the server is not defined in `workspace.yaml`. +Exits with status 1 if the server is not defined at that destination. +Plugin-provided declarations are not included by `list` or `get`. ### mcp update -Re-sync MCP servers only, without touching skills, agents, hooks, or other plugin artifacts. Useful when you've edited `workspace.yaml`'s `mcpServers:` block manually and want to push the changes to clients without running a full workspace sync. +Reconcile MCP servers without touching skills, agents, hooks, or other plugin +artifacts. Project and ordinary user destinations run MCP-only reconciliation. +An installed profile runs its ownership-aware profile reconciliation because +some clients store settings and MCP in the same managed file. A declared but +uninstalled profile is not implicitly installed. | Flag | Description | |------|-------------| | `--offline` | Use cached plugins without fetching from remote marketplaces | +| `--scope ` | Destination scope: `project` (default) or `user` | +| `--profile ` | Named profile destination; cannot be combined with `--scope` | ```bash allagents mcp update +allagents mcp update --scope user +allagents mcp update --profile markets allagents mcp update --offline ``` @@ -512,10 +563,10 @@ To modify a server definition, use `allagents mcp add ... --force`. ### Ownership model -AllAgents only tracks MCP servers it added. This means: +AllAgents only tracks MCP servers it added: -- `mcp add` marks the server as AllAgents-owned in `.allagents/sync-state.json`. -- `mcp remove` only removes servers from client MCP configs that AllAgents originally added. User-managed servers (added manually to `.mcp.json`, `.vscode/mcp.json`, etc.) are never touched. -- If you manually add a server to a client config first, and then run `mcp add` with the same name, the existing user-managed entry is left alone (AllAgents will skip it with a warning). +- `mcp add` records ownership in the selected destination's sync or profile state. +- `mcp remove` only removes servers from client MCP configs that AllAgents originally added. User-managed servers are never touched. +- If a same-name client entry existed before AllAgents managed it, the entry remains user-owned and is skipped with a warning. See [CLAUDE.md — MCP Server Sync](https://github.com/allagentsdev/allagents/blob/main/CLAUDE.md) for the full ownership rule. diff --git a/docs/src/content/docs/docs/reference/configuration.mdx b/docs/src/content/docs/docs/reference/configuration.mdx index ab6dd7e6..a8c7bc2b 100644 --- a/docs/src/content/docs/docs/reference/configuration.mdx +++ b/docs/src/content/docs/docs/reference/configuration.mdx @@ -172,6 +172,13 @@ profiles: command: review-mcp env: REVIEW_TOKEN: ${REVIEW_TOKEN} + tradingview: + type: http + url: https://mcp.tradingview.com/mcp + mcpProxy: + servers: + tradingview: + proxy: [copilot] codex-review: clients: @@ -229,6 +236,7 @@ profiles: | `plugins[].clients` | No | Restrict the plugin to named clients in this profile | | `plugins[].skills` | No | Skill allowlist or `{ exclude: [...] }` where the adapter supports filtering | | `profiles..mcpServers` | No | Profile-scoped stdio or HTTP MCP declarations | +| `profiles..mcpProxy` | No | Profile-local HTTP-to-stdio routing policy; selectors must be clients declared by that profile | Relative local plugin sources resolve from the user's home directory, not the current project. Profile names and launcher names are safe command basenames; @@ -249,6 +257,12 @@ headers accept exact `${ENV_VAR}` references only, and credential-bearing command arguments must use the same exact form. Resolved secret values are never written to plans, launchers, profile state, or generated configuration. +Profile MCP is materialized inside the profile root. Codex writes +`clients/codex/home/.config.toml`; Copilot writes +`clients/copilot/home/mcp-config.json`. Proxied HTTP servers share OAuth only +inside that profile, under `oauth-proxy/`. Removing the profile removes +that profile-owned OAuth state. + OpenCode profile settings accept `model`, `small_model`, `default_agent`, `username`, `share`, `autoupdate`, `snapshot`, `subagent_depth`, `logLevel`, `disabled_providers`, and `enabled_providers`. All other keys fail validation. @@ -488,7 +502,7 @@ This differs from [plugin skill deduplication](/docs/guides/plugins/#duplicate-s ## MCP Servers -The optional top-level `mcpServers` field defines MCP servers managed directly by the workspace. Servers defined here are synced to all configured project-scoped MCP clients (`claude`, `codex`, `vscode`, `copilot`). +The optional top-level `mcpServers` field defines MCP servers managed directly by a project or ordinary user workspace. Project servers sync to configured project MCP clients; user servers sync to the corresponding user destinations. ```yaml mcpServers: @@ -523,11 +537,15 @@ mcpServers: | `command` | stdio only | Executable for the stdio command | | `args` | No | Command arguments array (stdio transport only) | | `env` | No | Environment variables map (stdio transport only) | -| `clients` | No | Subset of project-scoped clients that should receive this server. Defaults to all configured clients. | +| `clients` | No | Subset of clients in this destination that should receive this server. Defaults to all configured clients. | -Manage these entries declaratively in `workspace.yaml`, or via the [`allagents mcp` commands](/docs/reference/cli/#mcp-commands). Workspace-level servers override any plugin-supplied server with the same name (with a warning). +Manage these entries declaratively in the selected `workspace.yaml`, or via the +[`allagents mcp` commands](/docs/reference/cli/#mcp-commands). Workspace-level +servers override plugin-supplied servers with the same name, with a warning. -Servers AllAgents adds are tracked in `.allagents/sync-state.json`; pre-existing user-managed servers in client MCP configs (`.mcp.json`, `.vscode/mcp.json`, `.github/mcp.json`, `.codex/config.toml`) are never touched. +Ownership is tracked separately for project, ordinary user, and each named +profile. Pre-existing user-managed entries in client configs are never claimed +or removed. ## Advanced MCP Routing @@ -549,9 +567,9 @@ mcpProxy: | Field | Required | Description | |-------|----------|-------------| -| `clients` | Yes | Clients where all HTTP servers are proxied to stdio | +| `clients` | No | Clients where all HTTP servers are proxied to stdio; defaults to an empty list | | `servers` | No | Per-server overrides with additional client lists | -| `servers..proxy` | Yes (per entry) | Additional clients for this server, or `*` for every current and future project MCP client | +| `servers..proxy` | Yes (per entry) | Additional clients for this server, or `*` for every current and future eligible client | Only servers with HTTP transport (`url` field) are transformed. Stdio servers pass through unchanged. diff --git a/src/cli/agent-help.ts b/src/cli/agent-help.ts index 18d44588..938ac548 100644 --- a/src/cli/agent-help.ts +++ b/src/cli/agent-help.ts @@ -100,6 +100,9 @@ function formatForAgent(meta: AgentCommandMeta) { if (meta.outputSchema) { result.output_schema = meta.outputSchema; } + if (meta.interaction) { + result.interaction = meta.interaction; + } if (meta.jsonFields && meta.jsonFields.length > 0) { result.json_fields = [...meta.jsonFields]; } diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts index 410c33f9..18459015 100644 --- a/src/cli/commands/mcp.ts +++ b/src/cli/commands/mcp.ts @@ -10,28 +10,40 @@ import { string, } from 'cmd-ts'; import { dump } from 'js-yaml'; -import { - addWorkspaceMcpServer, - buildMcpServerConfigFromFlags, - clearWorkspaceMcpServerProxy, - getWorkspaceMcpServer, - listWorkspaceMcpServers, - parseKeyValuePairs, - removeWorkspaceMcpServer, - setWorkspaceMcpServerProxy, -} from '../../core/mcp-servers.js'; +import { getHomeDir } from '../../constants.js'; import { type ConnectHttpMcpServerOptions, connectHttpMcpServer, runHttpMcpStdioProxy, validateOAuthCallbackUrl, } from '../../core/mcp-http-stdio-proxy.js'; -import { syncMcpOnly } from '../../core/mcp-sync.js'; +import { + addMcpServer, + buildMcpServerConfigFromFlags, + getMcpServer, + listMcpServers, + type McpDestination, + parseKeyValuePairs, + removeMcpServer, + resolveMcpDestination, +} from '../../core/mcp-servers.js'; +import { + type SyncMcpOnlyResult, + syncMcpOnly, + syncUserMcpOnly, +} from '../../core/mcp-sync.js'; +import { + type ProfileApplyResult, + updateInstalledProfiles, +} from '../../core/profile/index.js'; import { type ClientType, ClientTypeSchema, type McpServerConfig, + ProfileDeclarationSchema, } from '../../models/workspace-config.js'; +import { parseUserWorkspaceConfig } from '../../utils/workspace-parser.js'; +import { buildProfileData, formatProfileResult } from '../format-profile.js'; import { formatMcpResult } from '../format-sync.js'; import { buildDescription, conciseSubcommands } from '../help.js'; import { isJsonMode, jsonOutput } from '../json-output.js'; @@ -49,24 +61,164 @@ import { terminalSafe } from '../terminal-output.js'; // Helpers // ============================================================================= -function parseClientFilter(input: string): ClientType[] { - const items = input - .split(',') - .map((s) => s.trim()) - .filter(Boolean); +const destinationArgs = { + scope: option({ + type: optional(string), + long: 'scope', + description: "Declaration scope: 'project' (default) or 'user'", + }), + profile: option({ + type: optional(string), + long: 'profile', + description: 'Named profile declaration destination', + }), +}; + +interface DestinationFlags { + scope?: string | undefined; + profile?: string | undefined; +} + +function resolveCommandDestination( + commandName: string, + flags: DestinationFlags, +): McpDestination { + try { + return resolveMcpDestination({ + cwd: process.cwd(), + ...(flags.scope === undefined ? {} : { scope: flags.scope }), + ...(flags.profile === undefined ? {} : { profile: flags.profile }), + }); + } catch (error) { + exitWithError( + commandName, + error instanceof Error ? error.message : String(error), + ); + } +} + +function serializeDestination( + destination: McpDestination, +): { kind: 'project' | 'user' } | { kind: 'profile'; name: string } { + return destination.kind === 'profile' + ? { kind: 'profile', name: destination.name } + : { kind: destination.kind }; +} + +function destinationDisplay(destination: McpDestination): string { + switch (destination.kind) { + case 'project': + return 'workspace.yaml'; + case 'user': + return 'the user workspace'; + case 'profile': + return `profile '${terminalSafe(destination.name)}'`; + } +} + +function parseClientFilter(inputs: string[]): ClientType[] | undefined { + if (inputs.length === 0) return undefined; + const result: ClientType[] = []; - for (const item of items) { - const parsed = ClientTypeSchema.safeParse(item); - if (!parsed.success) { - throw new Error( - `Invalid client '${item}'. Valid clients: ${ClientTypeSchema.options.join(', ')}`, - ); + const seen = new Set(); + for (const input of inputs) { + for (const segment of input.split(',')) { + const item = segment.trim(); + if (!item) { + throw new Error('--client values cannot contain empty segments'); + } + const parsed = ClientTypeSchema.safeParse(item); + if (!parsed.success) { + throw new Error( + `Invalid client '${item}'. Valid clients: ${ClientTypeSchema.options.join(', ')}`, + ); + } + if (!seen.has(parsed.data)) { + seen.add(parsed.data); + result.push(parsed.data); + } } - result.push(parsed.data); } return result; } +const REDACTED_VALUE = '[REDACTED]'; +function isSensitiveCredentialName(value: string): boolean { + const normalized = value.replace(/[^A-Za-z0-9]/g, '').toLowerCase(); + return ( + normalized === 'key' || + /(?:authorization|auth|credentials?|password|passwd|secrets?|signature|tokens?|accesstoken|refreshtoken|apikey|accesskey|privatekey)$/.test( + normalized, + ) + ); +} + +function redactUrlCredentials(value: string): string { + try { + const url = new URL(value); + if (url.username) url.username = REDACTED_VALUE; + if (url.password) url.password = REDACTED_VALUE; + for (const key of url.searchParams.keys()) { + if (isSensitiveCredentialName(key)) { + url.searchParams.set(key, REDACTED_VALUE); + } + } + return url.toString(); + } catch { + return value; + } +} + +function redactMcpArguments(args: string[]): string[] { + let redactNext = false; + return args.map((argument) => { + if (redactNext) { + redactNext = false; + return REDACTED_VALUE; + } + const assignment = /^([^=]+)=(.*)$/.exec(argument); + if (assignment && isSensitiveCredentialName(assignment[1] as string)) { + return `${assignment[1]}=${REDACTED_VALUE}`; + } + if (/^Bearer\s+\S+/i.test(argument)) { + return `Bearer ${REDACTED_VALUE}`; + } + if (/^https?:\/\//i.test(argument)) { + return redactUrlCredentials(argument); + } + if ( + argument.startsWith('-') && + isSensitiveCredentialName(argument.replace(/^-+/, '')) + ) { + redactNext = true; + } + return argument; + }); +} + +function redactMcpServerConfig(config: McpServerConfig): McpServerConfig { + if ('url' in config) { + return { + ...config, + url: redactUrlCredentials(config.url), + ...(config.headers && { + headers: Object.fromEntries( + Object.keys(config.headers).map((key) => [key, REDACTED_VALUE]), + ), + }), + }; + } + return { + ...config, + ...(config.args && { args: redactMcpArguments(config.args) }), + ...(config.env && { + env: Object.fromEntries( + Object.keys(config.env).map((key) => [key, REDACTED_VALUE]), + ), + }), + }; +} + function exitWithError(command: string, error: string): never { if (isJsonMode()) { jsonOutput({ success: false, command, error }); @@ -77,8 +229,7 @@ function exitWithError(command: string, error: string): never { } /** - * Shared flag parsing for `mcp add` / `mcp update`. Exits with a user-friendly - * error if any flag is invalid. + * Parse and validate flags shared by MCP server declarations. */ function buildConfigFromAddFlags( commandName: string, @@ -87,7 +238,7 @@ function buildConfigFromAddFlags( args: string[], env: string[], header: string[], - client: string | undefined, + client: string[], ): McpServerConfig { if (transport && transport !== 'http' && transport !== 'stdio') { exitWithError( @@ -103,12 +254,10 @@ function buildConfigFromAddFlags( if ('error' in headerResult) exitWithError(commandName, headerResult.error); let clients: ClientType[] | undefined; - if (client) { - try { - clients = parseClientFilter(client); - } catch (e) { - exitWithError(commandName, e instanceof Error ? e.message : String(e)); - } + try { + clients = parseClientFilter(client); + } catch (e) { + exitWithError(commandName, e instanceof Error ? e.message : String(e)); } const buildOpts: Parameters[0] = { @@ -132,6 +281,7 @@ async function connectConfiguredHttpServer( mode: { resetCredentials: boolean; allowAuthorization: boolean; + profile?: string; }, ): Promise { try { @@ -139,6 +289,7 @@ async function connectConfiguredHttpServer( headers: headers ?? {}, resetCredentials: mode.resetCredentials, allowAuthorization: mode.allowAuthorization, + ...(mode.profile ? { profile: mode.profile } : {}), }; if (mode.allowAuthorization) { options.callbackUrlReader = async ({ redirectUrl, state, signal }) => { @@ -176,10 +327,52 @@ async function connectConfiguredHttpServer( async function getConfiguredMcpServer( commandName: string, + destination: McpDestination, name: string, ): Promise { try { - return await getWorkspaceMcpServer(name, process.cwd()); + return await getMcpServer(destination, name); + } catch (error) { + exitWithError( + commandName, + error instanceof Error ? error.message : String(error), + ); + } +} + +async function validateProfileAddCandidate( + commandName: string, + destination: McpDestination, + name: string, + config: McpServerConfig, +): Promise { + if (destination.kind !== 'profile') return; + + try { + const workspace = await parseUserWorkspaceConfig(destination.configPath); + const profile = workspace.profiles?.[destination.name]; + if (!profile) { + exitWithError( + commandName, + `Profile '${destination.name}' is not declared`, + ); + } + const validation = ProfileDeclarationSchema.safeParse({ + ...profile, + mcpServers: { + ...profile.mcpServers, + [name]: config, + }, + }); + if (!validation.success) { + const issues = validation.error.issues.map( + (issue) => ` - ${issue.path.join('.')}: ${issue.message}`, + ); + exitWithError( + commandName, + `Invalid MCP server config:\n${issues.join('\n')}`, + ); + } } catch (error) { exitWithError( commandName, @@ -188,41 +381,122 @@ async function getConfiguredMcpServer( } } +type DestinationSync = + | { kind: 'mcp'; result: SyncMcpOnlyResult } + | { kind: 'profile'; result: ProfileApplyResult | null }; + +async function reconcileDestination( + commandName: string, + destination: McpDestination, + offline: boolean, +): Promise { + if (destination.kind !== 'profile') { + const result = + destination.kind === 'project' + ? await syncMcpOnly(destination.workspacePath, { offline }) + : await syncUserMcpOnly({ offline }); + if (!result.success) { + exitWithError(commandName, result.error ?? 'MCP sync failed'); + } + return { kind: 'mcp', result }; + } + let results: readonly ProfileApplyResult[]; + try { + results = await updateInstalledProfiles([destination.name], { + offline, + homeDir: getHomeDir(), + userConfigPath: destination.configPath, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message === `Profile '${destination.name}' is not installed`) { + return { kind: 'profile', result: null }; + } + exitWithError(commandName, message); + } + const result = results[0]; + if (!result) { + exitWithError( + commandName, + `Profile '${destination.name}' was not reconciled`, + ); + } + if (!result.success) { + exitWithError( + commandName, + result.error ?? `Profile '${destination.name}' update failed`, + ); + } + return { kind: 'profile', result }; +} + +function profileSyncData( + sync: Extract, +): { status: 'not-installed' } | Record { + return sync.result + ? buildProfileData(sync.result) + : { status: 'not-installed' }; +} + +function printMcpSyncResult(result: SyncMcpOnlyResult): void { + for (const [scope, scopeResult] of Object.entries(result.mcpResults)) { + if (!scopeResult) continue; + const lines = formatMcpResult(scopeResult, scope); + if (lines.length > 0) { + console.log(''); + for (const line of lines) console.log(line); + } + } + for (const warning of result.warnings) { + console.log(` \u26A0 ${warning}`); + } +} + +function printProfileSyncResult( + destination: Extract, + sync: Extract, +): void { + if (!sync.result) { + console.log( + `Profile '${terminalSafe(destination.name)}' is not installed; skipped client reconciliation.`, + ); + return; + } + console.log(''); + for (const line of formatProfileResult(sync.result)) console.log(line); +} + /** - * Run MCP-only sync after a mutation and print per-scope results. Always runs - * offline because the mutation only affects local workspace.yaml and does not - * require refreshing plugins from remote marketplaces. + * Reconcile only the selected destination after a declaration mutation. */ async function runPostMutationSync( commandName: string, + destination: McpDestination, successMessage: string, jsonExtra: Record, ): Promise { - const syncResult = await syncMcpOnly(process.cwd(), { offline: true }); - if (!syncResult.success) { - exitWithError(commandName, syncResult.error ?? 'MCP sync failed'); - } + const sync = await reconcileDestination(commandName, destination, true); if (isJsonMode()) { jsonOutput({ success: true, command: commandName, - data: { ...jsonExtra, mcpResults: syncResult.mcpResults }, + data: { + ...jsonExtra, + destination: serializeDestination(destination), + ...(sync.kind === 'mcp' + ? { mcpResults: sync.result.mcpResults } + : { sync: profileSyncData(sync) }), + }, }); return; } console.log(successMessage); - for (const [scope, result] of Object.entries(syncResult.mcpResults)) { - if (!result) continue; - const lines = formatMcpResult(result, scope); - if (lines.length > 0) { - console.log(''); - for (const line of lines) console.log(line); - } - } - for (const warning of syncResult.warnings) { - console.log(` \u26A0 ${warning}`); + if (sync.kind === 'mcp') { + printMcpSyncResult(sync.result); + } else if (destination.kind === 'profile') { + printProfileSyncResult(destination, sync); } } @@ -285,10 +559,10 @@ const addArgs = { long: 'header', description: 'HTTP header KEY=VALUE (repeatable)', }), - client: option({ - type: optional(string), + client: multioption({ + type: array(string), long: 'client', - description: 'Comma-separated list of client filters', + description: 'Client filter (repeatable; comma-separated values accepted)', }), }; @@ -297,6 +571,7 @@ const mcpAddCmd = command({ description: buildDescription(mcpAddMeta), args: { ...addArgs, + ...destinationArgs, force: flag({ long: 'force', short: 'f', @@ -312,7 +587,13 @@ const mcpAddCmd = command({ header, client, force, + scope, + profile, }) => { + const destination = resolveCommandDestination('mcp add', { + scope, + profile, + }); const config = buildConfigFromAddFlags( 'mcp add', commandOrUrl, @@ -322,64 +603,48 @@ const mcpAddCmd = command({ header, client, ); - const existing = await getConfiguredMcpServer('mcp add', name); + const existing = await getConfiguredMcpServer('mcp add', destination, name); if (existing && !force) { exitWithError( 'mcp add', - `MCP server '${name}' already exists in workspace.yaml. Use --force to replace it.`, + `MCP server '${name}' already exists in ${destinationDisplay(destination)}. Use --force to replace it.`, ); } + await validateProfileAddCandidate('mcp add', destination, name, config); if ('url' in config) { const allowAuthorization = !isJsonMode() && Boolean(process.stdin.isTTY); - await connectConfiguredHttpServer( - 'mcp add', - config.url, - config.headers, - { resetCredentials: false, allowAuthorization }, - ); + await connectConfiguredHttpServer('mcp add', config.url, config.headers, { + resetCredentials: false, + allowAuthorization, + ...(destination.kind === 'profile' + ? { profile: destination.name } + : {}), + }); } - const addResult = await addWorkspaceMcpServer( - name, - config, - process.cwd(), + const addResult = await addMcpServer(destination, name, config, { force, - ); - if (!addResult.success) + proxy: + 'url' in config + ? { + ...(config.clients === undefined + ? {} + : { clients: config.clients }), + } + : false, + }); + if (!addResult.success) { exitWithError('mcp add', addResult.error ?? 'Unknown error'); - - if ('url' in config) { - const proxyResult = await setWorkspaceMcpServerProxy( - name, - process.cwd(), - config.clients, - ); - if (!proxyResult.success) { - exitWithError( - 'mcp add', - proxyResult.error ?? 'Failed to configure AllAgents MCP routing', - ); - } - } else if (force) { - const clearResult = await clearWorkspaceMcpServerProxy( - name, - process.cwd(), - ); - if (!clearResult.success) { - exitWithError( - 'mcp add', - clearResult.error ?? 'Failed to clear AllAgents MCP routing', - ); - } } await runPostMutationSync( 'mcp add', - `\u2713 Added MCP server '${name}' to workspace.yaml`, + destination, + `\u2713 Added MCP server '${terminalSafe(name)}' to ${destinationDisplay(destination)}`, { name, - config: addResult.config, + config: redactMcpServerConfig(addResult.config ?? config), }, ); }, @@ -394,14 +659,21 @@ const mcpRemoveCmd = command({ description: buildDescription(mcpRemoveMeta), args: { name: positional({ type: string, displayName: 'name' }), + ...destinationArgs, }, - handler: async ({ name }) => { - const removeResult = await removeWorkspaceMcpServer(name, process.cwd()); - if (!removeResult.success) + handler: async ({ name, scope, profile }) => { + const destination = resolveCommandDestination('mcp remove', { + scope, + profile, + }); + const removeResult = await removeMcpServer(destination, name); + if (!removeResult.success) { exitWithError('mcp remove', removeResult.error ?? 'Unknown error'); + } await runPostMutationSync( 'mcp remove', - `\u2713 Removed MCP server '${name}' from workspace.yaml`, + destination, + `\u2713 Removed MCP server '${terminalSafe(name)}' from ${destinationDisplay(destination)}`, { name }, ); }, @@ -416,26 +688,34 @@ const mcpReauthCmd = command({ description: buildDescription(mcpReauthMeta), args: { name: positional({ type: string, displayName: 'name' }), + ...destinationArgs, }, - handler: async ({ name }) => { - if (isJsonMode() || !process.stdin.isTTY) { + handler: async ({ name, scope, profile }) => { + const destination = resolveCommandDestination('mcp reauth', { + scope, + profile, + }); + const config = await getConfiguredMcpServer( + 'mcp reauth', + destination, + name, + ); + if (!config) { exitWithError( 'mcp reauth', - 'OAuth login requires an interactive terminal', + `MCP server '${name}' is not defined in ${destinationDisplay(destination)}`, ); } - - const config = await getConfiguredMcpServer('mcp reauth', name); - if (!config) { + if (!('url' in config)) { exitWithError( 'mcp reauth', - `MCP server '${name}' is not defined in workspace.yaml`, + `MCP server '${name}' uses stdio and cannot be reauthenticated`, ); } - if (!('url' in config)) { + if (isJsonMode() || !process.stdin.isTTY) { exitWithError( 'mcp reauth', - `MCP server '${name}' uses stdio and cannot be reauthenticated`, + 'OAuth login requires an interactive terminal', ); } @@ -443,10 +723,16 @@ const mcpReauthCmd = command({ 'mcp reauth', config.url, config.headers, - { resetCredentials: true, allowAuthorization: true }, + { + resetCredentials: true, + allowAuthorization: true, + ...(destination.kind === 'profile' + ? { profile: destination.name } + : {}), + }, ); console.log( - `\u2713 Reauthenticated MCP server '${terminalSafe(name)}'`, + `\u2713 Reauthenticated MCP server '${terminalSafe(name)}' in ${destinationDisplay(destination)}`, ); }, }); @@ -465,13 +751,51 @@ const mcpProxyCmd = command({ long: 'header', description: 'HTTP header KEY=VALUE (repeatable)', }), + headerEnv: multioption({ + type: array(string), + long: 'header-env', + description: 'HTTP header KEY=ENV_VAR reference (repeatable)', + }), + profile: option({ + type: optional(string), + long: 'profile', + description: 'Profile-owned OAuth credential scope', + }), }, - handler: async ({ serverUrl, header }) => { + handler: async ({ serverUrl, header, headerEnv, profile }) => { const headerResult = parseKeyValuePairs(header, '--header'); if ('error' in headerResult) { exitWithError('mcp proxy', headerResult.error); } - await runHttpMcpStdioProxy(serverUrl, headerResult.values); + const headerEnvResult = parseKeyValuePairs(headerEnv, '--header-env'); + if ('error' in headerEnvResult) { + exitWithError('mcp proxy', headerEnvResult.error); + } + const environmentHeaders: Record = {}; + for (const [key, value] of Object.entries(headerEnvResult.values)) { + const reference = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(value); + const variable = reference?.[1] ?? value; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(variable)) { + exitWithError( + 'mcp proxy', + `Invalid environment variable '${value}' for header '${key}'`, + ); + } + environmentHeaders[key] = `\${${variable}}`; + } + const destination = + profile === undefined + ? undefined + : resolveCommandDestination('mcp proxy', { profile }); + await runHttpMcpStdioProxy( + serverUrl, + { ...headerResult.values, ...environmentHeaders }, + { + ...(destination?.kind === 'profile' + ? { profile: destination.name } + : {}), + }, + ); }, }); @@ -482,37 +806,55 @@ const mcpProxyCmd = command({ const mcpListCmd = command({ name: 'list', description: buildDescription(mcpListMeta), - args: {}, - handler: async () => { + args: destinationArgs, + handler: async ({ scope, profile }) => { + const destination = resolveCommandDestination('mcp list', { + scope, + profile, + }); let servers: Record; try { - servers = await listWorkspaceMcpServers(process.cwd()); + servers = await listMcpServers(destination); } catch (e) { exitWithError('mcp list', e instanceof Error ? e.message : String(e)); } const names = Object.keys(servers); + const redactedServers = Object.fromEntries( + Object.entries(servers).map(([name, config]) => [ + name, + redactMcpServerConfig(config), + ]), + ); if (isJsonMode()) { jsonOutput({ success: true, command: 'mcp list', - data: { servers, total: names.length }, + data: { + destination: serializeDestination(destination), + servers: redactedServers, + total: names.length, + }, }); return; } if (names.length === 0) { - console.log('No MCP servers defined in workspace.yaml.'); + console.log( + `No MCP servers defined in ${destinationDisplay(destination)}.`, + ); console.log(''); console.log('Add one with:'); console.log(' allagents mcp add '); return; } - console.log(`MCP servers (${names.length}):`); + console.log( + `MCP servers in ${destinationDisplay(destination)} (${names.length}):`, + ); console.log(''); for (const name of names) { - const config = servers[name]; + const config = redactedServers[name]; if (!config) continue; for (const line of serverToDisplay(name, config)) { console.log(` ${line}`); @@ -531,31 +873,36 @@ const mcpGetCmd = command({ description: buildDescription(mcpGetMeta), args: { name: positional({ type: string, displayName: 'name' }), + ...destinationArgs, }, - handler: async ({ name }) => { - let config: McpServerConfig | null; - try { - config = await getWorkspaceMcpServer(name, process.cwd()); - } catch (e) { - exitWithError('mcp get', e instanceof Error ? e.message : String(e)); - } + handler: async ({ name, scope, profile }) => { + const destination = resolveCommandDestination('mcp get', { + scope, + profile, + }); + const config = await getConfiguredMcpServer('mcp get', destination, name); if (!config) { exitWithError( 'mcp get', - `MCP server '${name}' not found in workspace.yaml`, + `MCP server '${name}' not found in ${destinationDisplay(destination)}`, ); } + const redactedConfig = redactMcpServerConfig(config); if (isJsonMode()) { jsonOutput({ success: true, command: 'mcp get', - data: { name, config }, + data: { + destination: serializeDestination(destination), + name, + config: redactedConfig, + }, }); return; } - console.log(dump({ [name]: config }, { lineWidth: -1 }).trimEnd()); + console.log(dump({ [name]: redactedConfig }, { lineWidth: -1 }).trimEnd()); }, }); @@ -571,34 +918,56 @@ const mcpUpdateCmd = command({ long: 'offline', description: 'Use cached plugins without fetching from remote', }), + ...destinationArgs, }, - handler: async ({ offline }) => { - const result = await syncMcpOnly(process.cwd(), { offline }); - if (!result.success) { - exitWithError('mcp update', result.error ?? 'MCP sync failed'); - } + handler: async ({ offline, scope, profile }) => { + const destination = resolveCommandDestination('mcp update', { + scope, + profile, + }); + const sync = await reconcileDestination('mcp update', destination, offline); if (isJsonMode()) { jsonOutput({ success: true, command: 'mcp update', - data: { mcpResults: result.mcpResults, warnings: result.warnings }, + data: { + destination: serializeDestination(destination), + ...(sync.kind === 'mcp' + ? { + mcpResults: sync.result.mcpResults, + warnings: sync.result.warnings, + } + : { sync: profileSyncData(sync) }), + }, }); return; } - const hasAnyChanges = Object.values(result.mcpResults).some( - (r) => - r && - (r.added > 0 || r.overwritten > 0 || r.removed > 0 || r.skipped > 0), + if (sync.kind === 'profile') { + if (destination.kind === 'profile') { + printProfileSyncResult(destination, sync); + } + return; + } + + const hasAnyChanges = Object.values(sync.result.mcpResults).some( + (result) => + result && + (result.added > 0 || + result.overwritten > 0 || + result.removed > 0 || + result.skipped > 0), ); if (!hasAnyChanges) { console.log('No MCP server changes.'); } else { - for (const [scope, mcpResult] of Object.entries(result.mcpResults)) { + for (const [resultScope, mcpResult] of Object.entries( + sync.result.mcpResults, + )) { if (!mcpResult) continue; - const lines = formatMcpResult(mcpResult, scope); + const lines = formatMcpResult(mcpResult, resultScope); if (lines.length > 0) { for (const line of lines) console.log(line); console.log(''); @@ -606,9 +975,9 @@ const mcpUpdateCmd = command({ } } - if (result.warnings.length > 0) { + if (sync.result.warnings.length > 0) { console.log('Warnings:'); - for (const warning of result.warnings) { + for (const warning of sync.result.warnings) { console.log(` \u26A0 ${warning}`); } } diff --git a/src/cli/help.ts b/src/cli/help.ts index dc7366fd..3f37d244 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -32,6 +32,8 @@ export interface AgentCommandMeta extends CommandMeta { positionals?: CommandPositional[]; options?: CommandOption[]; outputSchema?: Record; + /** Whether an agent can invoke the command without a human interaction step. */ + interaction?: 'none' | 'conditional' | 'required'; /** * Allowlist of fields that may be requested via `--json=,`. * diff --git a/src/cli/metadata/mcp.ts b/src/cli/metadata/mcp.ts index d0b470c8..e6cd476d 100644 --- a/src/cli/metadata/mcp.ts +++ b/src/cli/metadata/mcp.ts @@ -1,24 +1,54 @@ import type { AgentCommandMeta } from '../help.js'; +const destinationOptions: NonNullable = [ + { + flag: '--scope', + type: 'string', + choices: ['project', 'user'], + description: + "Declaration destination: 'project' (default) or ordinary 'user' configuration; cannot be combined with --profile", + }, + { + flag: '--profile', + type: 'string', + description: + 'Named profile declaration destination; cannot be combined with --scope', + }, +]; + +const destinationOutput = { + kind: 'project | user | profile', + name: 'string?', +}; + +const reconciliationOutput = { + mcpResults: 'object?', + sync: 'object?', +}; + export const mcpAddMeta: AgentCommandMeta = { command: 'mcp add', description: 'Add an MCP server, authenticate, and sync it to clients', whenToUse: - 'When adding a new MCP server that AllAgents should connect to and manage for configured clients', + 'When adding or replacing an MCP server in the current project, ordinary user configuration, or a declared profile. Agent and non-interactive use works for public servers or valid cached credentials; fresh OAuth consent requires a human rerun.', examples: [ 'allagents mcp add deepwiki https://mcp.deepwiki.com/mcp', 'allagents mcp add my-server npx --arg=-y --arg=@my/mcp-server', 'allagents mcp add gh-api npx -e GH_TOKEN=abc123 --arg=-y --arg=@modelcontextprotocol/server-github', - 'allagents mcp add deepwiki https://mcp.deepwiki.com/mcp --client claude,copilot', + 'allagents mcp add deepwiki https://mcp.deepwiki.com/mcp --client claude,copilot --client codex', + 'allagents mcp add personal npx --scope user', + 'allagents mcp add trading https://example.com/mcp --profile markets', + 'allagents --json mcp add trading https://example.com/mcp --profile markets', ], expectedOutput: - 'For HTTP servers, connects and completes OAuth when needed, adds the server to workspace.yaml, and routes selected clients through AllAgents. Stdio servers are added directly. Exit 0 on success, 1 on failure.', + 'Validates and adds the declaration to the selected project, user, or profile destination, authenticates HTTP servers when needed, records proxy routing atomically, and reconciles installed clients. JSON output redacts credential values. Declared-only profiles are not implicitly installed. Exit 0 on success, 1 on failure.', + interaction: 'conditional', positionals: [ { name: 'name', type: 'string', required: true, - description: 'Server name (unique within workspace.yaml)', + description: 'Server name (unique within the selected destination)', }, { name: 'commandOrUrl', @@ -56,7 +86,7 @@ export const mcpAddMeta: AgentCommandMeta = { flag: '--client', type: 'string', description: - 'Comma-separated list of clients that should receive this server (default: all project-scoped clients)', + 'Client filter (repeatable; each value may be comma-separated; duplicates are removed in first-seen order)', }, { flag: '--force', @@ -64,87 +94,147 @@ export const mcpAddMeta: AgentCommandMeta = { type: 'boolean', description: 'Replace an existing server with the same name', }, + ...destinationOptions, ], + outputSchema: { + destination: destinationOutput, + name: 'string', + config: 'object', + ...reconciliationOutput, + }, }; export const mcpRemoveMeta: AgentCommandMeta = { command: 'mcp remove', - description: 'Remove an MCP server from workspace.yaml and all clients', - whenToUse: 'When you no longer need an MCP server that AllAgents added', - examples: ['allagents mcp remove deepwiki'], + description: + 'Remove an MCP server from a project, user, or profile destination', + whenToUse: + 'When you no longer need an MCP server declaration in the selected destination', + examples: [ + 'allagents mcp remove deepwiki', + 'allagents mcp remove deepwiki --scope user', + 'allagents mcp remove trading --profile markets', + 'allagents --json mcp remove trading --profile markets', + ], expectedOutput: - 'Removes the server from workspace.yaml and unsyncs it from all configured clients. Exit 0 on success, 1 if the server is not defined in workspace.yaml.', + 'Removes the server declaration and proxy intent from the selected destination, then reconciles that destination. Declared-only profiles remain uninstalled. Exit 0 on success, 1 if the server is not defined.', positionals: [ { name: 'name', type: 'string', required: true, - description: 'Server name to remove', + description: 'Server name to remove from the selected destination', }, ], + options: [...destinationOptions], + outputSchema: { + destination: destinationOutput, + name: 'string', + ...reconciliationOutput, + }, }; export const mcpListMeta: AgentCommandMeta = { command: 'mcp list', - description: 'List MCP servers defined in workspace.yaml', + description: + 'List MCP servers declared in a project, user, or profile destination', whenToUse: - 'To inspect MCP servers AllAgents is managing at the workspace level', - examples: ['allagents mcp list'], + 'To inspect inline MCP declarations in exactly one selected destination without merging plugin-provided servers', + examples: [ + 'allagents mcp list', + 'allagents mcp list --scope user', + 'allagents mcp list --profile markets', + 'allagents --json mcp list --scope user', + ], expectedOutput: - 'Prints a table of workspace-defined MCP servers with transport, target, and client filter. Exit 0 on success.', + 'Prints MCP declarations from the selected project, ordinary user, or named profile destination with transport, target, and client filter. Header, environment, URL credential, and sensitive query values are redacted in human and JSON output. Exit 0 on success.', + options: [...destinationOptions], + outputSchema: { + destination: destinationOutput, + servers: 'record', + total: 'number', + }, }; export const mcpGetMeta: AgentCommandMeta = { command: 'mcp get', - description: 'Show the workspace definition for an MCP server', - whenToUse: 'To see how an MCP server is configured in workspace.yaml', - examples: ['allagents mcp get deepwiki'], + description: + 'Show one MCP declaration from a project, user, or profile destination', + whenToUse: + 'To inspect how one MCP server is declared in exactly one selected destination', + examples: [ + 'allagents mcp get deepwiki', + 'allagents mcp get deepwiki --scope user', + 'allagents mcp get trading --profile markets', + 'allagents --json mcp get trading --profile markets', + ], expectedOutput: - 'Prints the server config (YAML). Exit 0 on success, 1 if not found.', + 'Prints the selected server declaration as YAML or structured JSON with header, environment, URL credential, and sensitive query values redacted. Exit 0 on success, 1 if it is not found in that destination.', positionals: [ { name: 'name', type: 'string', required: true, - description: 'Server name', + description: 'Server name in the selected destination', }, ], + options: [...destinationOptions], + outputSchema: { + destination: destinationOutput, + name: 'string', + config: 'redacted MCP server config', + }, }; export const mcpReauthMeta: AgentCommandMeta = { command: 'mcp reauth', description: 'Reauthenticate a configured HTTP MCP server', whenToUse: - 'When a workspace-managed HTTP MCP server needs a fresh OAuth login', + 'Human-operated only: use when an HTTP MCP server in a project, ordinary user, or named profile destination needs a fresh OAuth login. JSON and non-interactive execution are rejected.', examples: [ 'allagents mcp reauth tradingview', - 'allagents mcp reauth secure-api', + 'allagents mcp reauth secure-api --scope user', + 'allagents mcp reauth tradingview --profile markets', ], expectedOutput: - 'Clears cached OAuth credentials for the named server, opens a browser for login, accepts a pasted callback URL when the browser is remote, and verifies the connection. Exit 0 on success, 1 on cancellation or failure.', + 'Clears cached OAuth credentials owned by the selected destination, opens a browser for login, accepts a pasted callback URL when the browser is remote, and verifies the connection. Profile credentials remain isolated. Exit 0 on success, 1 on cancellation or failure.', positionals: [ { name: 'name', type: 'string', required: true, - description: 'Workspace-managed HTTP MCP server name', + description: 'HTTP MCP server name in the selected destination', }, ], + options: [...destinationOptions], + interaction: 'required', }; export const mcpUpdateMeta: AgentCommandMeta = { command: 'mcp update', - description: 'Sync MCP servers only, without touching other artifacts', + description: + 'Reconcile MCP servers for one project, user, or profile destination', whenToUse: - "When you've edited workspace.yaml's mcpServers block or plugin .mcp.json files and want to re-sync only MCP servers without re-running the full workspace sync. To modify a server definition use 'mcp add --force'.", - examples: ['allagents mcp update', 'allagents mcp update --offline'], + "After editing MCP declarations for a project, ordinary user configuration, or named profile. Project and user destinations run MCP-only reconciliation; installed profiles use the full profile ownership planner. To modify a server definition use 'mcp add --force'.", + examples: [ + 'allagents mcp update', + 'allagents mcp update --scope user', + 'allagents mcp update --profile markets', + 'allagents mcp update --offline', + 'allagents --json mcp update --profile markets', + ], expectedOutput: - 'Runs the MCP portion of sync for all project-scoped clients. Prints per-scope added/updated/removed counts. Exit 0 on success, 1 on failure.', + 'Reconciles the selected destination and prints client results. A declared-only profile is reported as not installed and is not implicitly installed. Exit 0 on success, 1 on failure.', options: [ { flag: '--offline', type: 'boolean', description: 'Use cached plugins without fetching from remote', }, + ...destinationOptions, ], + outputSchema: { + destination: destinationOutput, + ...reconciliationOutput, + }, }; diff --git a/src/cli/metadata/profile.ts b/src/cli/metadata/profile.ts index e835b2ef..23524b62 100644 --- a/src/cli/metadata/profile.ts +++ b/src/cli/metadata/profile.ts @@ -202,9 +202,9 @@ export const profileStatusMeta: AgentCommandMeta = { export const profileRemoveMeta: AgentCommandMeta = { command: 'profile remove', - description: 'Remove an installed global profile safely', + description: 'Remove an installed or declared global profile safely', whenToUse: - 'When you want AllAgents to remove resources it owns for one installed user profile while retaining referenced resources', + 'When you want AllAgents to remove resources and profile-owned OAuth credentials for one installed or declared user profile while retaining referenced resources', examples: [ 'allagents profile remove work', 'allagents profile remove work --dry-run', @@ -212,7 +212,7 @@ export const profileRemoveMeta: AgentCommandMeta = { 'allagents --json profile remove work --yes', ], expectedOutput: - 'Displays a redacted deterministic removal plan, asks before applying unless --yes is supplied, and reports removed, retained, unchanged, or failed resources.', + 'Displays a redacted deterministic removal plan, asks before applying unless --yes is supplied, removes profile-owned OAuth credentials even for a declared-only profile, and reports removed, retained, unchanged, or failed resources.', positionals: profileNamePositional, options: mutationOptions, outputSchema: { diff --git a/src/core/claude-mcp.ts b/src/core/claude-mcp.ts index 7318008b..a6d3cbcc 100644 --- a/src/core/claude-mcp.ts +++ b/src/core/claude-mcp.ts @@ -1,11 +1,11 @@ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import JSON5 from 'json5'; -import { executeCommand } from './native/types.js'; import type { NativeCommandResult } from './native/types.js'; -import { collectMcpServers } from './vscode-mcp.js'; -import type { McpMergeResult } from './vscode-mcp.js'; +import { executeCommand } from './native/types.js'; import type { ValidatedPlugin } from './sync.js'; +import type { McpMergeResult } from './vscode-mcp.js'; +import { collectMcpServers } from './vscode-mcp.js'; type ExecuteFn = ( binary: string, @@ -46,7 +46,16 @@ export function buildClaudeMcpAddArgs( ): string[] | null { // HTTP-based if (typeof config.url === 'string') { - return ['mcp', 'add', '--transport', 'http', '--scope', scope, name, config.url]; + return [ + 'mcp', + 'add', + '--transport', + 'http', + '--scope', + scope, + name, + config.url, + ]; } // stdio-based (command + args) @@ -131,12 +140,15 @@ export function syncClaudeMcpConfig( const content = readFileSync(configPath, 'utf-8'); existingConfig = JSON5.parse(content); } catch { - result.warnings.push(`Could not parse existing ${configPath}, starting fresh`); + result.warnings.push( + `Could not parse existing ${configPath}, starting fresh`, + ); existingConfig = {}; } } - const existingServers = (existingConfig.mcpServers as Record) ?? {}; + const existingServers = + (existingConfig.mcpServers as Record) ?? {}; // Process plugin servers: add new, update tracked, skip user-managed conflicts for (const [name, config] of pluginServers) { @@ -171,7 +183,10 @@ export function syncClaudeMcpConfig( if (hasTracking) { const currentServerNames = new Set(pluginServers.keys()); for (const trackedName of previouslyTracked) { - if (!currentServerNames.has(trackedName) && trackedName in existingServers) { + if ( + !currentServerNames.has(trackedName) && + trackedName in existingServers + ) { delete existingServers[trackedName]; result.removed++; result.removedServers.push(trackedName); @@ -180,14 +195,19 @@ export function syncClaudeMcpConfig( } // Write back if there were changes and not dry-run - const hasChanges = result.added > 0 || result.overwritten > 0 || result.removed > 0; + const hasChanges = + result.added > 0 || result.overwritten > 0 || result.removed > 0; if (hasChanges && !dryRun) { existingConfig.mcpServers = existingServers; const dir = dirname(configPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(configPath, `${JSON.stringify(existingConfig, null, 2)}\n`, 'utf-8'); + writeFileSync( + configPath, + `${JSON.stringify(existingConfig, null, 2)}\n`, + 'utf-8', + ); result.configPath = configPath; } @@ -206,7 +226,11 @@ export function syncClaudeMcpConfig( if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(configPath, `${JSON.stringify(existingConfig, null, 2)}\n`, 'utf-8'); + writeFileSync( + configPath, + `${JSON.stringify(existingConfig, null, 2)}\n`, + 'utf-8', + ); result.configPath = configPath; } } @@ -252,6 +276,7 @@ export async function syncClaudeMcpServersViaCli( overwrittenServers: [], removedServers: [], trackedServers: [], + authoritative: true, }; // Skip entirely when there are no MCP servers to sync and nothing to remove @@ -265,9 +290,22 @@ export async function syncClaudeMcpServersViaCli( result.warnings.push( `Claude CLI not available: ${versionResult.error ?? 'unknown error'}`, ); + result.authoritative = false; + result.trackedServers = [...previouslyTracked]; return result; } + function isMissingClaudeMcpServer( + result: NativeCommandResult, + name: string, + ): boolean { + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp( + `^(?:Error:\\s*)?No MCP server found with name:\\s*${escapedName}\\.?$`, + 'i', + ).test(`${result.output}\n${result.error ?? ''}`.trim()); + } + // Process plugin servers: add new, skip existing user-managed for (const [name, config] of pluginServers) { // Check if server already exists via `claude mcp get ` @@ -294,6 +332,8 @@ export async function syncClaudeMcpServersViaCli( result.warnings.push( `Unsupported MCP server config for '${name}', skipping`, ); + result.authoritative = false; + if (previouslyTracked.has(name)) result.trackedServers.push(name); continue; } @@ -303,6 +343,8 @@ export async function syncClaudeMcpServersViaCli( result.warnings.push( `Failed to add MCP server '${name}': ${addResult.error ?? 'unknown error'}`, ); + result.authoritative = false; + if (previouslyTracked.has(name)) result.trackedServers.push(name); continue; } } @@ -320,15 +362,32 @@ export async function syncClaudeMcpServersViaCli( if (!currentServerNames.has(trackedName)) { // Check if it still exists before trying to remove const getResult = await exec('claude', ['mcp', 'get', trackedName]); + if ( + !getResult.success && + !isMissingClaudeMcpServer(getResult, trackedName) + ) { + result.warnings.push( + `Failed to inspect MCP server '${trackedName}': ${getResult.error ?? 'unknown error'}`, + ); + result.authoritative = false; + result.trackedServers.push(trackedName); + continue; + } if (getResult.success) { if (!dryRun) { const removeResult = await exec('claude', [ - 'mcp', 'remove', trackedName, '--scope', 'user', + 'mcp', + 'remove', + trackedName, + '--scope', + 'user', ]); if (!removeResult.success) { result.warnings.push( `Failed to remove MCP server '${trackedName}': ${removeResult.error ?? 'unknown error'}`, ); + result.authoritative = false; + result.trackedServers.push(trackedName); continue; } } diff --git a/src/core/codex-mcp.ts b/src/core/codex-mcp.ts index 769b6c48..2a6c5748 100644 --- a/src/core/codex-mcp.ts +++ b/src/core/codex-mcp.ts @@ -1,10 +1,10 @@ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; -import { executeCommand } from './native/types.js'; import type { NativeCommandResult } from './native/types.js'; +import { executeCommand } from './native/types.js'; import type { ValidatedPlugin } from './sync.js'; -import { collectMcpServers } from './vscode-mcp.js'; import type { McpMergeResult } from './vscode-mcp.js'; +import { collectMcpServers } from './vscode-mcp.js'; type ExecuteFn = ( binary: string, @@ -89,6 +89,7 @@ export async function syncCodexMcpServers( overwrittenServers: [], removedServers: [], trackedServers: [], + authoritative: true, }; // Skip calling codex CLI entirely when there are no MCP servers to sync and nothing to remove @@ -102,6 +103,8 @@ export async function syncCodexMcpServers( result.warnings.push( `Codex CLI not available or 'codex mcp list' failed: ${listResult.error ?? 'unknown error'}`, ); + result.authoritative = false; + result.trackedServers = [...previouslyTracked]; return result; } @@ -111,6 +114,8 @@ export async function syncCodexMcpServers( existingNames = new Set(parsed.map((s) => s.name)); } catch { result.warnings.push('Failed to parse codex mcp list output'); + result.authoritative = false; + result.trackedServers = [...previouslyTracked]; return result; } @@ -135,6 +140,8 @@ export async function syncCodexMcpServers( result.warnings.push( `Unsupported MCP server config for '${name}', skipping`, ); + result.authoritative = false; + if (previouslyTracked.has(name)) result.trackedServers.push(name); continue; } @@ -144,6 +151,8 @@ export async function syncCodexMcpServers( result.warnings.push( `Failed to add MCP server '${name}': ${addResult.error ?? 'unknown error'}`, ); + result.authoritative = false; + if (previouslyTracked.has(name)) result.trackedServers.push(name); continue; } } @@ -172,6 +181,8 @@ export async function syncCodexMcpServers( result.warnings.push( `Failed to remove MCP server '${trackedName}': ${removeResult.error ?? 'unknown error'}`, ); + result.authoritative = false; + result.trackedServers.push(trackedName); continue; } } @@ -192,8 +203,10 @@ export async function syncCodexMcpServers( * Convert a TOML value to its string representation. */ function toTomlValue(value: unknown): string { - if (typeof value === 'string') return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (typeof value === 'string') + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + if (typeof value === 'number' || typeof value === 'boolean') + return String(value); if (Array.isArray(value)) return `[${value.map(toTomlValue).join(', ')}]`; return `"${String(value)}"`; } @@ -201,7 +214,10 @@ function toTomlValue(value: unknown): string { /** * Generate TOML for a single MCP server entry. */ -export function serverToToml(name: string, config: Record): string { +export function serverToToml( + name: string, + config: Record, +): string { const lines: string[] = [`[mcp_servers.${name}]`]; const envEntries: [string, unknown][] = []; @@ -244,12 +260,18 @@ export function parseCodexConfigToml(content: string): { for (const line of lines) { // Match [mcp_servers.] or [mcp_servers..env] - const sectionMatch = line.match(/^\[mcp_servers\.([^\].]+?)(?:\.[^\]]+)?\]$/); + const sectionMatch = line.match( + /^\[mcp_servers\.([^\].]+?)(?:\.[^\]]+)?\]$/, + ); if (sectionMatch) { // Save previous server if any if (currentServer) { - serverSections.set(currentServer, (serverSections.get(currentServer) ?? '') + - (serverSections.has(currentServer) ? '\n' : '') + currentLines.join('\n')); + serverSections.set( + currentServer, + (serverSections.get(currentServer) ?? '') + + (serverSections.has(currentServer) ? '\n' : '') + + currentLines.join('\n'), + ); } currentServer = sectionMatch[1] ?? null; if (currentServer) serverNames.add(currentServer); @@ -262,8 +284,12 @@ export function parseCodexConfigToml(content: string): { if (otherSectionMatch) { // Save previous server if any if (currentServer) { - serverSections.set(currentServer, (serverSections.get(currentServer) ?? '') + - (serverSections.has(currentServer) ? '\n' : '') + currentLines.join('\n')); + serverSections.set( + currentServer, + (serverSections.get(currentServer) ?? '') + + (serverSections.has(currentServer) ? '\n' : '') + + currentLines.join('\n'), + ); currentServer = null; currentLines = []; } @@ -280,13 +306,20 @@ export function parseCodexConfigToml(content: string): { // Save last server if any if (currentServer) { - serverSections.set(currentServer, (serverSections.get(currentServer) ?? '') + - (serverSections.has(currentServer) ? '\n' : '') + currentLines.join('\n')); + serverSections.set( + currentServer, + (serverSections.get(currentServer) ?? '') + + (serverSections.has(currentServer) ? '\n' : '') + + currentLines.join('\n'), + ); } return { serverNames, - nonMcpContent: nonMcpLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(), + nonMcpContent: nonMcpLines + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(), serverSections, }; } @@ -342,12 +375,17 @@ export function syncCodexProjectMcpConfig( try { existingContent = readFileSync(configPath, 'utf-8'); } catch { - result.warnings.push(`Could not read existing ${configPath}, starting fresh`); + result.warnings.push( + `Could not read existing ${configPath}, starting fresh`, + ); } } - const { serverNames: existingNames, nonMcpContent, serverSections } = - parseCodexConfigToml(existingContent); + const { + serverNames: existingNames, + nonMcpContent, + serverSections, + } = parseCodexConfigToml(existingContent); // Track which servers to keep in the final output const finalServers = new Map(serverSections); @@ -357,12 +395,18 @@ export function syncCodexProjectMcpConfig( if (existingNames.has(name)) { if (hasTracking && previouslyTracked.has(name)) { // We own it — overwrite with new config - finalServers.set(name, serverToToml(name, config as Record)); + finalServers.set( + name, + serverToToml(name, config as Record), + ); result.overwritten++; result.overwrittenServers.push(name); result.trackedServers.push(name); } else if (force) { - finalServers.set(name, serverToToml(name, config as Record)); + finalServers.set( + name, + serverToToml(name, config as Record), + ); result.overwritten++; result.overwrittenServers.push(name); result.trackedServers.push(name); @@ -372,7 +416,10 @@ export function syncCodexProjectMcpConfig( result.skippedServers.push(name); } } else { - finalServers.set(name, serverToToml(name, config as Record)); + finalServers.set( + name, + serverToToml(name, config as Record), + ); result.added++; result.addedServers.push(name); result.trackedServers.push(name); @@ -383,7 +430,10 @@ export function syncCodexProjectMcpConfig( if (hasTracking) { const currentServerNames = new Set(pluginServers.keys()); for (const trackedName of previouslyTracked) { - if (!currentServerNames.has(trackedName) && finalServers.has(trackedName)) { + if ( + !currentServerNames.has(trackedName) && + finalServers.has(trackedName) + ) { finalServers.delete(trackedName); result.removed++; result.removedServers.push(trackedName); @@ -392,7 +442,8 @@ export function syncCodexProjectMcpConfig( } // Write back if changes occurred - const hasChanges = result.added > 0 || result.overwritten > 0 || result.removed > 0; + const hasChanges = + result.added > 0 || result.overwritten > 0 || result.removed > 0; if (hasChanges && !dryRun) { const parts: string[] = []; if (nonMcpContent) { diff --git a/src/core/mcp-http-stdio-proxy.ts b/src/core/mcp-http-stdio-proxy.ts index 8b199e2d..e6d8866f 100644 --- a/src/core/mcp-http-stdio-proxy.ts +++ b/src/core/mcp-http-stdio-proxy.ts @@ -1,46 +1,68 @@ +import { spawn } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { createServer, type IncomingMessage, type ServerResponse, } from 'node:http'; -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { constants as fsConstants } from 'node:fs'; -import { access } from 'node:fs/promises'; -import { join, dirname } from 'node:path'; -import { spawn } from 'node:child_process'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { dirname, join } from 'node:path'; import { - type OAuthDiscoveryState, type OAuthClientProvider, + type OAuthDiscoveryState, UnauthorizedError, } from '@modelcontextprotocol/sdk/client/auth.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import type { - FetchLike, - Transport, -} from '@modelcontextprotocol/sdk/shared/transport.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens, } from '@modelcontextprotocol/sdk/shared/auth.js'; -import { getHomeDir } from '../constants.js'; +import type { + FetchLike, + Transport, +} from '@modelcontextprotocol/sdk/shared/transport.js'; import { + CallToolRequestSchema as CallToolSchema, GetPromptRequestSchema as GetPromptSchema, ListPromptsRequestSchema as ListPromptsSchema, - ListResourceTemplatesRequestSchema as ListResourceTemplatesSchema, ListResourcesRequestSchema as ListResourcesSchema, + ListResourceTemplatesRequestSchema as ListResourceTemplatesSchema, ListToolsRequestSchema as ListToolsSchema, ReadResourceRequestSchema as ReadResourceSchema, - CallToolRequestSchema as CallToolSchema, } from '@modelcontextprotocol/sdk/types.js'; +import { getHomeDir } from '../constants.js'; +import { ProfileNameSchema } from '../models/workspace-config.js'; const AUTH_TIMEOUT_MS = 5 * 60 * 1000; export const AUTH_URL_LOG_PREFIX = 'If the browser does not open, visit: '; +const ENVIRONMENT_REFERENCE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; + +export function resolveMcpHeaderReferences( + headers: Record, + environment: NodeJS.ProcessEnv = process.env, +): Record { + return Object.fromEntries( + Object.entries(headers).map(([key, value]) => { + const reference = ENVIRONMENT_REFERENCE.exec(value); + if (!reference) return [key, value]; + const variable = reference[1] as string; + const resolved = environment[variable]; + if (resolved === undefined) { + throw new Error( + `MCP header '${key}' references missing environment variable '${variable}'`, + ); + } + return [key, resolved]; + }), + ); +} + export interface OAuthCallbackRequest { authorizationUrl: URL; redirectUrl: string; @@ -135,12 +157,21 @@ export function hashServerUrl(serverUrl: string): string { return createHash('sha256').update(serverUrl).digest('hex').slice(0, 16); } -function getCacheDir(serverUrl: string): string { +export function getMcpOAuthCacheDir( + serverUrl: string, + profile?: string, +): string { + const hash = hashServerUrl(serverUrl); + if (!profile) { + return join(getHomeDir(), '.allagents', 'oauth-proxy', hash); + } return join( getHomeDir(), '.allagents', + 'profiles', + ProfileNameSchema.parse(profile), 'oauth-proxy', - hashServerUrl(serverUrl), + hash, ); } @@ -160,9 +191,9 @@ function getMcpFetch( } const mergedHeaders = new Headers(headers); - new Headers(init?.headers).forEach((value, key) => - mergedHeaders.set(key, value), - ); + new Headers(init?.headers).forEach((value, key) => { + mergedHeaders.set(key, value); + }); return fetch(input, { ...init, @@ -189,7 +220,7 @@ async function readJsonFile(path: string): Promise { } async function writePrivateFile(path: string, content: string): Promise { - await mkdir(dirname(path), { recursive: true }); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); await writeFile(path, content, { encoding: 'utf-8', mode: 0o600 }); } @@ -289,6 +320,12 @@ function tryOpenBrowser(url: string): Promise { }); } +interface OAuthProviderOptions { + callbackUrlReader?: OAuthCallbackUrlReader; + allowAuthorization?: boolean; + profile?: string; +} + class FileOAuthClientProvider implements OAuthClientProvider { private readonly clientInfoPath: string; private readonly tokensPath: string; @@ -301,20 +338,23 @@ class FileOAuthClientProvider implements OAuthClientProvider { private codeVerifierValue: string | undefined = undefined; private pendingAuth: Promise | undefined = undefined; private authorizationUnavailable = false; + private readonly callbackUrlReader: OAuthCallbackUrlReader | undefined; + private readonly allowAuthorization: boolean; private readonly stateValue = randomUUID(); constructor( private readonly port: number, serverUrl: string, - private readonly callbackUrlReader?: OAuthCallbackUrlReader, - private readonly allowAuthorization = true, + options: OAuthProviderOptions = {}, ) { - const cacheDir = getCacheDir(serverUrl); + const cacheDir = getMcpOAuthCacheDir(serverUrl, options.profile); this.clientInfoPath = join(cacheDir, 'client-info.json'); this.tokensPath = join(cacheDir, 'tokens.json'); this.verifierPath = join(cacheDir, 'code-verifier.txt'); this.discoveryPath = join(cacheDir, 'discovery.json'); this.redirectUriValue = `http://127.0.0.1:${port}/callback`; + this.callbackUrlReader = options.callbackUrlReader; + this.allowAuthorization = options.allowAuthorization ?? true; } get redirectUrl(): string { @@ -430,9 +470,7 @@ class FileOAuthClientProvider implements OAuthClientProvider { async waitForAuthCode(): Promise { if (this.authorizationUnavailable) { - throw new Error( - 'OAuth authorization requires an interactive terminal', - ); + throw new Error('OAuth authorization requires an interactive terminal'); } if (!this.pendingAuth) { throw new Error('OAuth authorization has not been started'); @@ -440,7 +478,6 @@ class FileOAuthClientProvider implements OAuthClientProvider { return this.pendingAuth; } - private waitForAuthorizationCode(authorizationUrl: URL): Promise { const { promise, resolve, reject } = Promise.withResolvers(); const readerAbortController = new AbortController(); @@ -557,10 +594,9 @@ class FileOAuthClientProvider implements OAuthClientProvider { async function buildOAuthProvider( serverUrl: string, - callbackUrlReader: OAuthCallbackUrlReader | undefined, - allowAuthorization: boolean, + options: OAuthProviderOptions = {}, ): Promise { - const cacheDir = getCacheDir(serverUrl); + const cacheDir = getMcpOAuthCacheDir(serverUrl, options.profile); const cachedClientInfo = await readJsonFile( join(cacheDir, 'client-info.json'), ); @@ -570,12 +606,7 @@ async function buildOAuthProvider( port = await findFreePort(); } - const provider = new FileOAuthClientProvider( - port, - serverUrl, - callbackUrlReader, - allowAuthorization, - ); + const provider = new FileOAuthClientProvider(port, serverUrl, options); await provider.load(); return provider; } @@ -598,17 +629,16 @@ interface RemoteConnection { transport: StreamableHTTPClientTransport; } +interface RemoteTransportOptions extends OAuthProviderOptions { + headers?: Record; +} + async function connectRemoteTransport( serverUrl: string, - headers: Record, - callbackUrlReader?: OAuthCallbackUrlReader, - allowAuthorization = true, + options: RemoteTransportOptions = {}, ): Promise { - const provider = await buildOAuthProvider( - serverUrl, - callbackUrlReader, - allowAuthorization, - ); + const provider = await buildOAuthProvider(serverUrl, options); + const headers = resolveMcpHeaderReferences(options.headers ?? {}); const client = new Client( { name: 'AllAgents', @@ -647,6 +677,7 @@ export interface ConnectHttpMcpServerOptions { callbackUrlReader?: OAuthCallbackUrlReader; resetCredentials?: boolean; allowAuthorization?: boolean; + profile?: string; } export async function connectHttpMcpServer( @@ -654,13 +685,14 @@ export async function connectHttpMcpServer( options: ConnectHttpMcpServerOptions = {}, ): Promise { if (options.resetCredentials) { - await rm(getCacheDir(serverUrl), { recursive: true, force: true }); + await rm(getMcpOAuthCacheDir(serverUrl, options.profile), { + recursive: true, + force: true, + }); } const { client, transport } = await connectRemoteTransport( serverUrl, - options.headers ?? {}, - options.callbackUrlReader, - options.allowAuthorization ?? true, + options, ); try { await transport.terminateSession(); @@ -672,8 +704,12 @@ export async function connectHttpMcpServer( export async function runHttpMcpStdioProxy( serverUrl: string, headers: Record = {}, + options: { profile?: string } = {}, ): Promise { - const { client: remote } = await connectRemoteTransport(serverUrl, headers); + const { client: remote } = await connectRemoteTransport(serverUrl, { + headers, + ...options, + }); const local = new Server( { name: 'AllAgents', diff --git a/src/core/mcp-proxy.ts b/src/core/mcp-proxy.ts index 7f0ce0f0..0c871984 100644 --- a/src/core/mcp-proxy.ts +++ b/src/core/mcp-proxy.ts @@ -1,5 +1,11 @@ -import type { McpProxyConfig } from '../models/workspace-config.js'; import packageJson from '../../package.json'; +import { + type McpProxyConfig, + type McpServerConfig, + ProfileNameSchema, +} from '../models/workspace-config.js'; + +const ENVIRONMENT_REFERENCE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; /** * Determine if a server+client pair should be proxied. @@ -43,23 +49,30 @@ function isHttpServer( function toProxiedConfig( url: string, headers?: Record, -): Record { - const args = [ - '-y', - `allagents@${packageJson.version}`, - 'mcp', - 'proxy', - url, - ]; + profile?: string, +): McpServerConfig { + const args = ['-y', `allagents@${packageJson.version}`, 'mcp', 'proxy', url]; + const env: Record = {}; + if (profile) { + args.push('--profile', ProfileNameSchema.parse(profile)); + } if (headers) { for (const [key, value] of Object.entries(headers)) { - args.push('--header', `${key}=${value}`); + const reference = ENVIRONMENT_REFERENCE.exec(value); + if (reference) { + const variable = reference[1] as string; + args.push('--header-env', `${key}=${variable}`); + env[variable] = value; + } else { + args.push('--header', `${key}=${value}`); + } } } return { command: 'npx', args, + ...(Object.keys(env).length > 0 && { env }), }; } @@ -68,15 +81,23 @@ function toProxiedConfig( * Returns a new Map with HTTP configs rewritten to stdio where applicable. * Non-HTTP servers and non-proxied clients are passed through unchanged. */ -export function applyMcpProxy( - servers: Map, +export function applyMcpProxy( + servers: Map, client: string, config: McpProxyConfig, -): Map { - const result = new Map(); + options: { profile?: string } = {}, +): Map { + const result = new Map(); for (const [name, serverConfig] of servers) { if (isHttpServer(serverConfig) && shouldProxy(name, client, config)) { - result.set(name, toProxiedConfig(serverConfig.url, serverConfig.headers)); + result.set( + name, + toProxiedConfig( + serverConfig.url, + serverConfig.headers, + options.profile, + ), + ); } else { result.set(name, serverConfig); } diff --git a/src/core/mcp-servers.ts b/src/core/mcp-servers.ts index dc29cb54..6745294c 100644 --- a/src/core/mcp-servers.ts +++ b/src/core/mcp-servers.ts @@ -1,17 +1,66 @@ +import { randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { + lstat, + mkdir, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; import { dump } from 'js-yaml'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; -import type { - ClientType, - McpServerConfig, - WorkspaceConfig, +import { + type ClientType, + type McpServerConfig, + McpServerConfigSchema, + type ProfileDeclaration, + ProfileMcpServerConfigSchema, + type ProfileName, + ProfileNameSchema, + type WorkspaceConfig, } from '../models/workspace-config.js'; -import { McpServerConfigSchema } from '../models/workspace-config.js'; +import { + type EditableUserWorkspaceConfig, + parseUserWorkspaceConfigDocumentForEdit, + parseWorkspaceConfigForEdit, + validateProjectWorkspaceConfig, + validateUserWorkspaceConfig, +} from '../utils/workspace-parser.js'; +import { + ensureUserWorkspace, + getUserWorkspaceConfigPath, + isUserConfigPath, +} from './user-workspace.js'; import { ensureWorkspace } from './workspace-modify.js'; -import { parseWorkspaceConfigForEdit } from '../utils/workspace-parser.js'; +export type McpDestination = + | { + kind: 'project'; + workspacePath: string; + configPath: string; + } + | { + kind: 'user'; + configPath: string; + } + | { + kind: 'profile'; + name: ProfileName; + configPath: string; + }; + +export interface ResolveMcpDestinationOptions { + cwd?: string; + scope?: string; + profile?: string; +} + +export interface AddMcpServerOptions { + force?: boolean; + proxy?: false | { clients?: ClientType[] }; +} /** * Result of add/remove/update operations on workspace mcpServers. @@ -23,163 +72,339 @@ export interface McpServerModifyResult { config?: McpServerConfig; } -export interface McpProxyModifyResult { - success: boolean; - error?: string; - proxyClients?: string[]; -} +type EditableMcpProxy = { + clients?: string[]; + servers?: Record; +}; -function removeServerScopedProxyIntent( - workspaceConfig: WorkspaceConfig, - name: string, -): void { - if (!workspaceConfig.mcpProxy?.servers?.[name]) { - return; - } +type EditableMcpContainer = { + mcpServers?: Record; + mcpProxy?: EditableMcpProxy; +}; + +const LOCK_TIMEOUT_MS = 5_000; +const STALE_LOCK_MS = 30_000; + +async function readLockOwner(lockPath: string): Promise { + return readFile(join(lockPath, 'owner'), 'utf8').catch(() => null); +} - delete workspaceConfig.mcpProxy.servers[name]; - if (Object.keys(workspaceConfig.mcpProxy.servers).length === 0) { - workspaceConfig.mcpProxy.servers = undefined; +async function withDestinationLock( + configPath: string, + mutate: () => Promise, +): Promise { + const lockPath = `${configPath}.lock`; + const breakPath = `${lockPath}.break`; + const token = `${process.pid}:${randomUUID()}`; + const deadline = Date.now() + LOCK_TIMEOUT_MS; + let acquired = false; + while (!acquired) { + let created = false; + try { + await mkdir(lockPath, { mode: 0o700 }); + created = true; + await writeFile(join(lockPath, 'owner'), token, { + encoding: 'utf8', + mode: 0o600, + }); + acquired = true; + } catch (error) { + if (created) { + await rm(lockPath, { recursive: true, force: true }).catch( + () => undefined, + ); + } + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + const lockStats = await lstat(lockPath).catch(() => null); + if (lockStats && Date.now() - lockStats.mtimeMs > STALE_LOCK_MS) { + let breaker = false; + try { + await mkdir(breakPath, { mode: 0o700 }); + breaker = true; + const currentStats = await lstat(lockPath).catch(() => null); + if ( + currentStats && + Date.now() - currentStats.mtimeMs > STALE_LOCK_MS + ) { + await rm(lockPath, { recursive: true, force: true }); + } + } catch (breakError) { + if ((breakError as NodeJS.ErrnoException).code !== 'EEXIST') { + throw breakError; + } + } finally { + if (breaker) { + await rm(breakPath, { recursive: true, force: true }).catch( + () => undefined, + ); + } + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting to update ${configPath}`); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + continue; + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting to update ${configPath}`); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } } - if ( - workspaceConfig.mcpProxy.clients.length === 0 && - !workspaceConfig.mcpProxy.servers - ) { - workspaceConfig.mcpProxy = undefined; + try { + return await mutate(); + } finally { + if ((await readLockOwner(lockPath)) === token) { + await rm(lockPath, { recursive: true, force: true }).catch( + () => undefined, + ); + } } } +type EditableMcpDocument = WorkspaceConfig | EditableUserWorkspaceConfig; + function getConfigPath(workspacePath: string): string { return join(workspacePath, CONFIG_DIR, WORKSPACE_CONFIG_FILE); } -async function readConfig(configPath: string): Promise { - return parseWorkspaceConfigForEdit(configPath); +function parseProfileName(name: string): ProfileName { + const validation = ProfileNameSchema.safeParse(name); + if (!validation.success) { + const detail = + validation.error.issues[0]?.message ?? 'Invalid portable profile name'; + throw new Error(`Invalid profile name '${name}': ${detail}`); + } + return validation.data; } -async function writeConfig( - configPath: string, - config: WorkspaceConfig, -): Promise { - await writeFile(configPath, dump(config, { lineWidth: -1 }), 'utf-8'); +function validateDestination(destination: McpDestination): void { + if (destination.kind === 'profile') { + parseProfileName(destination.name); + } } - /** - * Validate a server config via the McpServerConfigSchema. Returns a - * user-friendly error on failure. + * Resolve the declaration document selected by the MCP command flags. */ -function validateServerConfig( - config: unknown, -): { valid: true; data: McpServerConfig } | { valid: false; error: string } { - const result = McpServerConfigSchema.safeParse(config); - if (!result.success) { - const issues = result.error.issues.map( - (i) => ` - ${i.path.join('.')}: ${i.message}`, - ); +export function resolveMcpDestination( + options: ResolveMcpDestinationOptions = {}, +): McpDestination { + if (options.scope !== undefined && options.profile !== undefined) { + throw new Error('--scope and --profile cannot be used together'); + } + + if ( + options.scope !== undefined && + options.scope !== 'project' && + options.scope !== 'user' + ) { + throw new Error(`Invalid MCP scope '${String(options.scope)}'`); + } + + if (options.profile !== undefined) { return { - valid: false, - error: `Invalid MCP server config:\n${issues.join('\n')}`, + kind: 'profile', + name: parseProfileName(options.profile), + configPath: getUserWorkspaceConfigPath(), }; } - return { valid: true, data: result.data }; -} -/** - * Add a new MCP server entry to workspace.yaml. Fails if a server with the - * given name already exists (unless `force` is set). - */ -export async function addWorkspaceMcpServer( - name: string, - config: McpServerConfig, - workspacePath: string = process.cwd(), - force = false, -): Promise { - const validation = validateServerConfig(config); - if (!validation.valid) { - return { success: false, error: validation.error }; + if (options.scope === 'user') { + return { + kind: 'user', + configPath: getUserWorkspaceConfigPath(), + }; } - try { - await ensureWorkspace(workspacePath); - const configPath = getConfigPath(workspacePath); - const workspaceConfig = await readConfig(configPath); - workspaceConfig.mcpServers ??= {}; - - if (workspaceConfig.mcpServers[name] && !force) { - return { - success: false, - error: `MCP server '${name}' already exists in workspace.yaml. Pass --force to replace it.`, - }; + const workspacePath = options.cwd ?? process.cwd(); + if (isUserConfigPath(workspacePath)) { + if (options.scope === 'project') { + throw new Error( + '--scope project cannot be used from the home directory because it aliases the user workspace; run from a project directory or use --scope user', + ); } - - workspaceConfig.mcpServers[name] = validation.data; - await writeConfig(configPath, workspaceConfig); - return { success: true, config: validation.data }; - } catch (error) { return { - success: false, - error: error instanceof Error ? error.message : String(error), + kind: 'user', + configPath: getUserWorkspaceConfigPath(), }; } + return { + kind: 'project', + workspacePath, + configPath: getConfigPath(workspacePath), + }; } -/** - * Persist server-scoped MCP proxy intent for a workspace-defined server. - * Keeps any existing workspace-wide proxy defaults unchanged. - */ -export async function setWorkspaceMcpServerProxy( - name: string, - workspacePath: string = process.cwd(), - proxyClients?: ClientType[], -): Promise { +async function readDestinationConfig( + destination: McpDestination, +): Promise { + return destination.kind === 'project' + ? parseWorkspaceConfigForEdit(destination.configPath) + : parseUserWorkspaceConfigDocumentForEdit(destination.configPath); +} + +function selectDestinationContainer( + destination: McpDestination, + document: EditableMcpDocument, +): EditableMcpContainer { + if (destination.kind !== 'profile') { + return document as EditableMcpContainer; + } + + const profile = (document as EditableUserWorkspaceConfig).profiles?.[ + destination.name + ]; + if (!profile) { + throw new Error(`Profile '${destination.name}' is not declared`); + } + return profile as ProfileDeclaration & EditableMcpContainer; +} + +async function writeDestinationConfig( + destination: McpDestination, + document: EditableMcpDocument, +): Promise { + if (destination.kind === 'project') { + validateProjectWorkspaceConfig(document, destination.configPath); + } else { + validateUserWorkspaceConfig(document, destination.configPath); + } + const existing = await lstat(destination.configPath).catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + if (existing?.isSymbolicLink()) { + throw new Error( + `Refusing to replace symbolic-link workspace config: ${destination.configPath}`, + ); + } + if (existing && !existing.isFile()) { + throw new Error( + `Workspace config is not a regular file: ${destination.configPath}`, + ); + } + const mode = + existing?.mode ?? (destination.kind === 'project' ? 0o644 : 0o600); + const temporaryPath = join( + dirname(destination.configPath), + `.${basename(destination.configPath)}.${process.pid}.${randomUUID()}.tmp`, + ); try { - await ensureWorkspace(workspacePath); - const configPath = getConfigPath(workspacePath); - const workspaceConfig = await readConfig(configPath); + await writeFile(temporaryPath, dump(document, { lineWidth: -1 }), { + encoding: 'utf-8', + mode, + }); + await rename(temporaryPath, destination.configPath); + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } +} - if (!workspaceConfig.mcpServers || !(name in workspaceConfig.mcpServers)) { - return { - success: false, - error: `MCP server '${name}' not found in workspace.yaml`, - }; +async function ensureDestinationForAdd( + destination: McpDestination, +): Promise { + if (destination.kind === 'project') { + await ensureWorkspace(destination.workspacePath); + } else if ( + destination.kind === 'user' && + !existsSync(destination.configPath) && + resolve(destination.configPath) === resolve(getUserWorkspaceConfigPath()) + ) { + await ensureUserWorkspace(); + } +} + +function removeServerScopedProxyIntent( + container: EditableMcpContainer, + name: string, +): void { + const proxy = container.mcpProxy; + if (!proxy) return; + + if (proxy.servers) { + delete proxy.servers[name]; + if (Object.keys(proxy.servers).length === 0) { + delete proxy.servers; } + } + if ((proxy.clients?.length ?? 0) === 0 && !proxy.servers) { + delete container.mcpProxy; + } +} - const resolvedClients = [...new Set(proxyClients ?? ['*'])]; - workspaceConfig.mcpProxy ??= { clients: [] }; - workspaceConfig.mcpProxy.clients ??= []; - workspaceConfig.mcpProxy.servers ??= {}; - workspaceConfig.mcpProxy.servers[name] = { proxy: resolvedClients }; +function applyServerScopedProxyIntent( + container: EditableMcpContainer, + name: string, + proxy: AddMcpServerOptions['proxy'], +): void { + if (proxy === undefined) return; + if (proxy === false) { + removeServerScopedProxyIntent(container, name); + return; + } - await writeConfig(configPath, workspaceConfig); - return { success: true, proxyClients: resolvedClients }; - } catch (error) { + const resolvedClients = [...new Set(proxy.clients ?? ['*'])]; + container.mcpProxy ??= {}; + container.mcpProxy.servers ??= {}; + container.mcpProxy.servers[name] = { proxy: resolvedClients }; +} + +function validateServerConfig( + destination: McpDestination, + config: unknown, +): { valid: true; data: McpServerConfig } | { valid: false; error: string } { + const result = + destination.kind === 'profile' + ? ProfileMcpServerConfigSchema.safeParse(config) + : McpServerConfigSchema.safeParse(config); + if (!result.success) { + const issues = result.error.issues.map( + (issue) => ` - ${issue.path.join('.')}: ${issue.message}`, + ); return { - success: false, - error: error instanceof Error ? error.message : String(error), + valid: false, + error: `Invalid MCP server config:\n${issues.join('\n')}`, }; } + return { valid: true, data: result.data }; } -export async function clearWorkspaceMcpServerProxy( +/** + * Add or replace one inline MCP declaration and its server-local proxy intent + * in a single validated document write. + */ +export async function addMcpServer( + destination: McpDestination, name: string, - workspacePath: string = process.cwd(), -): Promise { + config: McpServerConfig, + options: AddMcpServerOptions = {}, +): Promise { try { - await ensureWorkspace(workspacePath); - const configPath = getConfigPath(workspacePath); - const workspaceConfig = await readConfig(configPath); - - if (!workspaceConfig.mcpServers || !(name in workspaceConfig.mcpServers)) { - return { - success: false, - error: `MCP server '${name}' not found in workspace.yaml`, - }; + validateDestination(destination); + const validation = validateServerConfig(destination, config); + if (!validation.valid) { + return { success: false, error: validation.error }; } - removeServerScopedProxyIntent(workspaceConfig, name); - await writeConfig(configPath, workspaceConfig); - return { success: true }; + await ensureDestinationForAdd(destination); + return await withDestinationLock(destination.configPath, async () => { + const document = await readDestinationConfig(destination); + const container = selectDestinationContainer(destination, document); + container.mcpServers ??= {}; + + if (container.mcpServers[name] && !options.force) { + return { + success: false, + error: `MCP server '${name}' already exists in workspace.yaml. Pass --force to replace it.`, + }; + } + + container.mcpServers[name] = validation.data; + applyServerScopedProxyIntent(container, name, options.proxy); + await writeDestinationConfig(destination, document); + return { success: true, config: validation.data }; + }); } catch (error) { return { success: false, @@ -189,39 +414,40 @@ export async function clearWorkspaceMcpServerProxy( } /** - * Remove an MCP server entry from workspace.yaml. Returns success: false if - * the server is not defined in workspace.yaml (it may still exist in a plugin). + * Remove one inline MCP declaration and its server-local proxy intent. */ -export async function removeWorkspaceMcpServer( +export async function removeMcpServer( + destination: McpDestination, name: string, - workspacePath: string = process.cwd(), ): Promise { - const configPath = getConfigPath(workspacePath); - if (!existsSync(configPath)) { - return { - success: false, - error: `${CONFIG_DIR}/${WORKSPACE_CONFIG_FILE} not found in ${workspacePath}`, - }; - } - try { - const workspaceConfig = await readConfig(configPath); - if (!workspaceConfig.mcpServers || !(name in workspaceConfig.mcpServers)) { + validateDestination(destination); + if (!existsSync(destination.configPath)) { return { success: false, - error: `MCP server '${name}' not found in workspace.yaml`, + error: `${CONFIG_DIR}/${WORKSPACE_CONFIG_FILE} not found`, }; } - delete workspaceConfig.mcpServers[name]; - if (Object.keys(workspaceConfig.mcpServers).length === 0) { - workspaceConfig.mcpServers = undefined; - } - - removeServerScopedProxyIntent(workspaceConfig, name); - - await writeConfig(configPath, workspaceConfig); - return { success: true }; + return await withDestinationLock(destination.configPath, async () => { + const document = await readDestinationConfig(destination); + const container = selectDestinationContainer(destination, document); + if (!container.mcpServers || !(name in container.mcpServers)) { + return { + success: false, + error: `MCP server '${name}' not found in workspace.yaml`, + }; + } + + delete container.mcpServers[name]; + if (Object.keys(container.mcpServers).length === 0) { + delete container.mcpServers; + } + removeServerScopedProxyIntent(container, name); + + await writeDestinationConfig(destination, document); + return { success: true }; + }); } catch (error) { return { success: false, @@ -231,29 +457,40 @@ export async function removeWorkspaceMcpServer( } /** - * Read an MCP server entry from workspace.yaml. Returns null if it is not - * defined there (it may still be defined by a plugin). + * Read one inline MCP declaration from exactly the selected destination. */ -export async function getWorkspaceMcpServer( +export async function getMcpServer( + destination: McpDestination, name: string, - workspacePath: string = process.cwd(), ): Promise { - const configPath = getConfigPath(workspacePath); - if (!existsSync(configPath)) return null; - const workspaceConfig = await readConfig(configPath); - return workspaceConfig.mcpServers?.[name] ?? null; + validateDestination(destination); + if (!existsSync(destination.configPath)) { + if (destination.kind === 'profile') { + throw new Error(`Profile '${destination.name}' is not declared`); + } + return null; + } + const document = await readDestinationConfig(destination); + const container = selectDestinationContainer(destination, document); + return container.mcpServers?.[name] ?? null; } /** - * List all MCP servers defined in workspace.yaml. + * List inline MCP declarations from exactly the selected destination. */ -export async function listWorkspaceMcpServers( - workspacePath: string = process.cwd(), +export async function listMcpServers( + destination: McpDestination, ): Promise> { - const configPath = getConfigPath(workspacePath); - if (!existsSync(configPath)) return {}; - const workspaceConfig = await readConfig(configPath); - return workspaceConfig.mcpServers ?? {}; + validateDestination(destination); + if (!existsSync(destination.configPath)) { + if (destination.kind === 'profile') { + throw new Error(`Profile '${destination.name}' is not declared`); + } + return {}; + } + const document = await readDestinationConfig(destination); + const container = selectDestinationContainer(destination, document); + return container.mcpServers ?? {}; } /** diff --git a/src/core/mcp-sync.ts b/src/core/mcp-sync.ts index 14d0318a..d2ed85fc 100644 --- a/src/core/mcp-sync.ts +++ b/src/core/mcp-sync.ts @@ -1,31 +1,28 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; +import { CONFIG_DIR, getHomeDir, WORKSPACE_CONFIG_FILE } from '../constants.js'; +import type { SyncState } from '../models/sync-state.js'; import type { ClientType, + UserWorkspaceConfig, WorkspaceConfig, } from '../models/workspace-config.js'; -import type { SyncState } from '../models/sync-state.js'; -import { - buildPluginSyncPlans, - collectSyncClients, - seedFetchCacheFromMarketplaces, - validateAllPlugins, - type ValidatedPlugin, -} from './sync.js'; -import type { McpMergeResult } from './vscode-mcp.js'; -import { collectMcpServers, syncVscodeMcpConfig } from './vscode-mcp.js'; +import { parseWorkspaceConfig } from '../utils/workspace-parser.js'; import { syncClaudeMcpConfig } from './claude-mcp.js'; import { syncCodexProjectMcpConfig } from './codex-mcp.js'; +import { ensureMarketplacesRegistered } from './marketplace.js'; import { applyMcpProxy } from './mcp-proxy.js'; +import type { ValidatedPlugin } from './sync.js'; import { getPreviouslySyncedMcpServers, loadSyncState, - saveSyncState, type McpScope, + saveSyncState, } from './sync-state.js'; -import { ensureMarketplacesRegistered } from './marketplace.js'; -import { parseWorkspaceConfig } from '../utils/workspace-parser.js'; +import { syncUserMcpAdapters } from './user-mcp-sync.js'; +import { getUserWorkspaceConfig } from './user-workspace.js'; +import type { McpMergeResult } from './vscode-mcp.js'; +import { collectMcpServers, syncVscodeMcpConfig } from './vscode-mcp.js'; import { migrateWorkspaceSkillsV1toV2 } from './workspace-modify.js'; /** @@ -185,6 +182,58 @@ export interface SyncMcpOnlyResult { error?: string; } +interface PreparedMcpOnlySync { + validPlugins: ValidatedPlugin[]; + syncClients: ClientType[]; + warnings: string[]; +} + +async function prepareMcpOnlySync( + config: WorkspaceConfig, + scope: 'project' | 'user', + root: string, + offline: boolean, +): Promise { + // Deferred because sync.ts imports this module's project orchestrator; a + // static reverse import would create an mcp-sync.ts <-> sync.ts load cycle. + const { + buildPluginSyncPlans, + collectSyncClients, + seedFetchCacheFromMarketplaces, + validateAllPlugins, + } = await import('./sync.js'); + const { plans, warnings } = buildPluginSyncPlans( + config.plugins, + config.clients, + scope, + ); + const activePlans = plans.filter( + (plan) => + plan.clients.length > 0 || + (scope === 'project' && plan.nativeClients.length > 0), + ); + const syncClients = collectSyncClients(config.clients, activePlans); + + if (!offline) { + const marketplaceResults = await ensureMarketplacesRegistered( + activePlans.map((plan) => plan.source), + ); + await seedFetchCacheFromMarketplaces(marketplaceResults); + } + + const validatedPlugins = await validateAllPlugins(activePlans, root, offline); + const validPlugins = validatedPlugins.filter( + (plugin): plugin is ValidatedPlugin => plugin.success, + ); + warnings.push( + ...validatedPlugins + .filter((plugin) => !plugin.success) + .map((plugin) => `${plugin.plugin}: ${plugin.error} (skipped)`), + ); + + return { validPlugins, syncClients, warnings }; +} + /** * Standalone MCP-only sync for the `allagents mcp update` command. * @@ -226,43 +275,12 @@ export async function syncMcpOnly( }; } - const warnings: string[] = []; - - const { plans, warnings: planWarnings } = buildPluginSyncPlans( - config.plugins, - config.clients, + const { validPlugins, syncClients, warnings } = await prepareMcpOnlySync( + config, 'project', - ); - warnings.push(...planWarnings); - - const filteredPlans = plans.filter( - (plan) => plan.clients.length > 0 || plan.nativeClients.length > 0, - ); - const syncClients = collectSyncClients(config.clients, filteredPlans); - - // Pre-register marketplaces so that plugin validation can resolve them. - // Skip in offline mode to avoid network calls. - if (!offline) { - const marketplaceResults = await ensureMarketplacesRegistered( - filteredPlans.map((plan) => plan.source), - ); - await seedFetchCacheFromMarketplaces(marketplaceResults); - } - - // Validate plugins so we can read their .mcp.json files - const validatedPlugins = await validateAllPlugins( - filteredPlans, workspacePath, offline, ); - const validPlugins = validatedPlugins.filter( - (v): v is ValidatedPlugin => v.success, - ); - warnings.push( - ...validatedPlugins - .filter((v) => !v.success) - .map((v) => `${v.plugin}: ${v.error} (skipped)`), - ); const previousState = await loadSyncState(workspacePath); @@ -308,3 +326,86 @@ export async function syncMcpOnly( warnings, }; } + +/** + * Reconcile only ordinary user-scoped MCP destinations. + * + * This intentionally omits user workspace migrations, plugin file operations, + * native installs, and profile materialization. + */ +export async function syncUserMcpOnly( + options: { offline?: boolean; dryRun?: boolean } = {}, +): Promise { + const { offline = false, dryRun = false } = options; + let config: UserWorkspaceConfig | null; + try { + config = await getUserWorkspaceConfig(); + } catch (error) { + return { + success: false, + mcpResults: {}, + warnings: [], + error: error instanceof Error ? error.message : String(error), + }; + } + + if (!config) { + return { success: true, mcpResults: {}, warnings: [] }; + } + + const homeDir = getHomeDir(); + const { validPlugins, syncClients, warnings } = await prepareMcpOnlySync( + config, + 'user', + homeDir, + offline, + ); + + const previousState = await loadSyncState(homeDir); + const syncResult = await syncUserMcpAdapters({ + validPlugins, + config, + previousState, + syncClients, + dryRun, + force: false, + }); + warnings.push(...syncResult.warnings); + + if (!dryRun) { + const trackingChanged = Object.entries(syncResult.trackedServers).some( + ([scope, current]) => { + if (!current) return false; + const previous = previousState?.mcpServers?.[scope] ?? []; + return ( + current.length !== previous.length || + current.some((name, index) => name !== previous[index]) + ); + }, + ); + const adaptersChanged = Object.values(syncResult.mcpResults).some( + (result) => + result !== undefined && + (result.added > 0 || result.overwritten > 0 || result.removed > 0), + ); + + if (trackingChanged || adaptersChanged) { + await saveSyncState(homeDir, { + files: previousState?.files ?? {}, + mcpServers: { + ...previousState?.mcpServers, + ...syncResult.trackedServers, + }, + }); + } + } + + return { + success: syncResult.complete, + mcpResults: syncResult.mcpResults, + warnings, + ...(!syncResult.complete && { + error: 'MCP sync was incomplete; retained ownership for failed clients', + }), + }; +} diff --git a/src/core/profile/adapters/mcp.ts b/src/core/profile/adapters/mcp.ts index fae93b63..c07f4816 100644 --- a/src/core/profile/adapters/mcp.ts +++ b/src/core/profile/adapters/mcp.ts @@ -1,10 +1,11 @@ import { - ProfileMcpServerConfigSchema, type ClientType, + type McpServerConfig, + ProfileMcpServerConfigSchema, + ProfileMcpServerNameSchema, } from '../../../models/workspace-config.js'; import type { ProfileSerializationInput } from '../types.js'; -const MCP_NAME_PATTERN = /^[a-zA-Z0-9_.-]{1,100}$/; const SENSITIVE_QUERY_KEY = /(?:^|[-_.])(auth|credential|key|password|secret|signature|token)(?:$|[-_.])/i; const SECRET_REFERENCE_PATTERN = /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/; @@ -13,14 +14,17 @@ const SECRET_REFERENCE_PATTERN = /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/; export function serializeProfileMcpServers( input: ProfileSerializationInput, client: ClientType, -): Record | null { +): Record | null { if (input.mcpServers === undefined) return null; - const selected: Record = {}; + const selected: Record = {}; for (const name of Object.keys(input.mcpServers).sort()) { - if (!MCP_NAME_PATTERN.test(name)) { + const nameValidation = ProfileMcpServerNameSchema.safeParse(name); + if (!nameValidation.success) { throw new Error(`Invalid profile MCP server name '${name}'`); } - const parsed = ProfileMcpServerConfigSchema.safeParse(input.mcpServers[name]); + const parsed = ProfileMcpServerConfigSchema.safeParse( + input.mcpServers[name], + ); if (!parsed.success) { const issue = parsed.error.issues[0]; throw new Error( @@ -37,10 +41,15 @@ export function serializeProfileMcpServers( throw new Error(`Invalid profile MCP server '${name}': URL is invalid`); } if (url.username || url.password) { - throw new Error(`Invalid profile MCP server '${name}': URL contains credentials`); + throw new Error( + `Invalid profile MCP server '${name}': URL contains credentials`, + ); } for (const [key, value] of url.searchParams) { - if (SENSITIVE_QUERY_KEY.test(key) && !SECRET_REFERENCE_PATTERN.test(value)) { + if ( + SENSITIVE_QUERY_KEY.test(key) && + !SECRET_REFERENCE_PATTERN.test(value) + ) { throw new Error( `Invalid profile MCP server '${name}': secret query values must be exact \${ENV_VAR} references`, ); diff --git a/src/core/profile/files.ts b/src/core/profile/files.ts index 32d4bd3a..50960928 100644 --- a/src/core/profile/files.ts +++ b/src/core/profile/files.ts @@ -271,7 +271,7 @@ export async function materializeManagedFile( } preFingerprint = sha256Fingerprint(await readFile(request.path)); const verifiedStats = await existingStats(request.path); - if (!verifiedStats || !verifiedStats.isFile()) { + if (!verifiedStats?.isFile()) { throw new Error(`Profile file destination changed during inspection: ${request.path}`); } const initialIdentity = fileIdentity(stats); diff --git a/src/core/profile/manager.ts b/src/core/profile/manager.ts index c0de08d5..f12eb039 100644 --- a/src/core/profile/manager.ts +++ b/src/core/profile/manager.ts @@ -6,10 +6,11 @@ import type { ProfileState, } from '../../models/profile-state.js'; import { - ProfileNameSchema, type ClientType, type ProfileDeclaration, + ProfileNameSchema, } from '../../models/workspace-config.js'; +import { getProfileAdapter } from './adapters/registry.js'; import { assertSafeProfilePath, fingerprintProfileFile, @@ -17,7 +18,28 @@ import { removeManagedFile, sha256Fingerprint, } from './files.js'; +import type { + ProfileApplyResult, + ProfileApplyStep, + ProfileApplyStepStatus, + ProfilePlan, + ProfilePlanAction, + ProfileRuntimeOptions, + ProfileStatusResult, +} from './index.js'; import { diagnoseLauncherPath, renderProfileLaunchers } from './launcher.js'; +import { + getInternalProfilePlan, + getProfileRoot, + type InternalProfilePlan, + type InternalProfilePlanStep, + type ProfilePlanDependencies, + planProfileOperation, + type ResolvedProfileRuntime, + readOptionalProfileWorkspace, + readProfileWorkspace, + resolveProfileRuntimeOptions, +} from './plan.js'; import { checkpointProfileResource, createProfileState, @@ -27,29 +49,7 @@ import { sanitizeProfileError, saveProfileState, } from './state.js'; -import { - getInternalProfilePlan, - getProfileRoot, - planProfileOperation, - readProfileWorkspace, - readOptionalProfileWorkspace, - resolveProfileRuntimeOptions, - type InternalProfilePlan, - type InternalProfilePlanStep, - type ProfilePlanDependencies, - type ResolvedProfileRuntime, -} from './plan.js'; -import { getProfileAdapter } from './adapters/registry.js'; import { isNativeProfileAdapter, type ProfileAdapter } from './types.js'; -import type { - ProfileApplyResult, - ProfileApplyStep, - ProfileApplyStepStatus, - ProfilePlan, - ProfilePlanAction, - ProfileRuntimeOptions, - ProfileStatusResult, -} from './index.js'; export interface ProfileManagerDependencies extends ProfilePlanDependencies { readonly now?: () => Date; @@ -192,6 +192,23 @@ async function removeEmptyManagedRoot(root: string): Promise { } } +async function removeProfileOAuthProxyRoot(profileRoot: string): Promise { + const oauthProxyRoot = join(profileRoot, 'oauth-proxy'); + await assertSafeProfilePath(profileRoot, oauthProxyRoot); + const stats = await lstat(oauthProxyRoot).catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + if (!stats) return; + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error( + `Profile OAuth proxy root is not a real directory: ${oauthProxyRoot}`, + ); + } + await assertSafeProfilePath(profileRoot, oauthProxyRoot); + await rm(oauthProxyRoot, { recursive: true, force: true }); +} + async function removeOwnedManagedRoot( root: string, expectedRoot: string, @@ -606,18 +623,10 @@ export async function applyProfilePlan( if (plan.operation === 'remove') { if (!retainedManaged) { try { + await removeProfileOAuthProxyRoot(profileRoot); const statePath = getProfileStatePath(profileRoot); await assertSafeProfilePath(profileRoot, statePath); await rm(statePath, { force: true }); - await removeEmptyManagedRoot(profileRoot); - return { - profile: plan.profile, - operation: plan.operation, - status: 'removed', - success: true, - steps: results, - warnings: plan.warnings, - }; } catch (error) { const message = safeError(error); return { @@ -630,6 +639,22 @@ export async function applyProfilePlan( error: message, }; } + const warnings = [...plan.warnings]; + try { + await removeEmptyManagedRoot(profileRoot); + } catch (error) { + warnings.push( + `Profile resources were removed, but the empty profile directory could not be pruned: ${safeError(error)}`, + ); + } + return { + profile: plan.profile, + operation: plan.operation, + status: 'removed', + success: true, + steps: results, + warnings, + }; } state = await saveProfileState(profileRoot, { ...state, @@ -848,7 +873,7 @@ export async function getProfileStatus( let unsupported: string | undefined; for (const client of clients) { const adapter = (dependencies.getAdapter ?? getProfileAdapter)(client); - if (!adapter || !adapter.capabilities.status) { + if (!adapter?.capabilities.status) { unsupported = `Profile client '${client}' is unsupported`; continue; } diff --git a/src/core/profile/plan.ts b/src/core/profile/plan.ts index 1af5b58e..bd304c2d 100644 --- a/src/core/profile/plan.ts +++ b/src/core/profile/plan.ts @@ -1,4 +1,4 @@ -import { lstat, readFile, readdir } from 'node:fs/promises'; +import { lstat, readdir, readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename, join, resolve } from 'node:path'; import type { @@ -6,30 +6,44 @@ import type { ProfileState, } from '../../models/profile-state.js'; import { + type ClientType, getPluginRef, getPluginSource, - type ClientType, type InstallMode, + type McpServerConfig, type ProfileDeclaration, - type ProfilePluginEntry, ProfileNameSchema, + type ProfilePluginEntry, type UserWorkspaceConfig, } from '../../models/workspace-config.js'; +import { parseUserWorkspaceConfig } from '../../utils/workspace-parser.js'; +import { applyMcpProxy } from '../mcp-proxy.js'; import type { NativeInspectionResult, NativeResource, } from '../native/types.js'; import { sanitizeNativeProvenance } from '../native/types.js'; -import { copyPluginToWorkspace, collectPluginSkills } from '../transform.js'; -import { parseUserWorkspaceConfig } from '../../utils/workspace-parser.js'; -import { resolveProfileFileSource } from './source.js'; +import { collectPluginSkills, copyPluginToWorkspace } from '../transform.js'; +import { serializeProfileMcpServers } from './adapters/mcp.js'; import { getProfileAdapter } from './adapters/registry.js'; import { assertSafeProfilePath, fingerprintProfileFile, sha256Fingerprint, } from './files.js'; +import type { + ProfileOperationKind, + ProfilePlan, + ProfilePlanAction, + ProfilePlanClient, + ProfilePlanMcpServer, + ProfilePlanStep, + ProfilePlanStepDetail, + ProfileRuntimeOptions, + ProfileStepKind, +} from './index.js'; import { renderProfileLaunchers } from './launcher.js'; +import { resolveProfileFileSource } from './source.js'; import { hashProfileDeclaration, loadProfileState, @@ -43,17 +57,6 @@ import { type ProfileMarketplaceRegistration, type ProfileResolvedPlugin, } from './types.js'; -import type { - ProfileOperationKind, - ProfilePlan, - ProfilePlanAction, - ProfilePlanClient, - ProfilePlanMcpServer, - ProfilePlanStep, - ProfilePlanStepDetail, - ProfileRuntimeOptions, - ProfileStepKind, -} from './index.js'; export interface ResolvedProfileRuntime { readonly userConfigPath: string; @@ -399,7 +402,6 @@ function sameNativeIdentity( ); } - function findInstalledNativeResource( inspection: NativeInspectionResult, matches: (resource: NativeResource) => boolean, @@ -501,31 +503,17 @@ async function planManagedFile(input: { }; } -function hasSelectedMcp( - declaration: ProfileDeclaration, - client: ClientType, -): boolean { - return Object.values(declaration.mcpServers ?? {}).some( - (server) => !server.clients || server.clients.includes(client), - ); -} - function managedContextRoot(context: ProfileClientContext): string { return context.operationContext.roots?.config ?? context.root; } -const EXACT_SECRET_REFERENCE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; +const SECRET_REFERENCE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; function requestedSecretNames(value: unknown): string[] { const names = new Set(); const visit = (entry: unknown): void => { if (typeof entry === 'string') { - const match = EXACT_SECRET_REFERENCE.exec(entry); - if (match?.[1]) names.add(match[1]); - try { - const url = new URL(entry); - for (const queryValue of url.searchParams.values()) visit(queryValue); - } catch { - // Non-URL strings have already been checked as exact references. + for (const match of entry.matchAll(SECRET_REFERENCE)) { + if (match[1]) names.add(match[1]); } return; } @@ -544,14 +532,10 @@ function requestedSecretNames(value: unknown): string[] { } function mcpDisclosures( - declaration: ProfileDeclaration, - client: ClientType, + mcpServers: Readonly>, ): readonly ProfilePlanMcpServer[] { const servers: ProfilePlanMcpServer[] = []; - for (const [name, server] of Object.entries( - declaration.mcpServers ?? {}, - ).sort(([left], [right]) => left.localeCompare(right))) { - if (server.clients && !server.clients.includes(client)) continue; + for (const [name, server] of Object.entries(mcpServers)) { if ('url' in server) { servers.push({ name, @@ -567,7 +551,7 @@ function mcpDisclosures( command: server.command, args: Object.freeze( (server.args ?? []).map((argument) => - EXACT_SECRET_REFERENCE.test(argument) ? '[REDACTED]' : argument, + argument.replace(SECRET_REFERENCE, '[REDACTED]'), ), ), }, @@ -578,10 +562,6 @@ function mcpDisclosures( return Object.freeze(servers); } - - - - async function planRoot( client: ClientType, context: ProfileClientContext, @@ -700,8 +680,8 @@ export async function planProfileOperation( const priorState = loadedState.status === 'loaded' ? loadedState.state : null; if (operation !== 'remove' && !declaration) throw new Error(`Profile '${profile}' is not declared`); - if (operation === 'remove' && !priorState) - throw new Error(`Profile '${profile}' is not installed`); + if (operation === 'remove' && !priorState && !declaration) + throw new Error(`Profile '${profile}' is not installed or declared`); const desiredClients = declaration ? declaration.clients.map((client) => client.name) @@ -957,9 +937,32 @@ export async function planProfileOperation( } } + const selectedMcpServers = serializeProfileMcpServers( + { + plugins: [], + ...(declaration.mcpServers && { + mcpServers: declaration.mcpServers, + }), + }, + client, + ); + const effectiveMcpServers = + selectedMcpServers === null + ? undefined + : declaration.mcpProxy + ? Object.fromEntries( + applyMcpProxy( + new Map(Object.entries(selectedMcpServers)), + client, + declaration.mcpProxy, + { profile }, + ), + ) + : selectedMcpServers; + const hasMcp = Object.keys(effectiveMcpServers ?? {}).length > 0; + const requiresMcpPrerequisite = - hasSelectedMcp(declaration, client) && - adapter.mcpPrerequisite !== undefined; + hasMcp && adapter.mcpPrerequisite !== undefined; const plannedMcpPrerequisite = adapter.mcpPrerequisite ? nativePlugins.find(({ resource }) => adapter.mcpPrerequisite?.matches(resource), @@ -1272,14 +1275,13 @@ export async function planProfileOperation( ...filePlugins, ], settings: declaredClient.settings, - ...(declaration.mcpServers && { mcpServers: declaration.mcpServers }), + ...(effectiveMcpServers && { mcpServers: effectiveMcpServers }), }; if ( Object.keys(declaredClient.settings).length > 0 && !adapter.capabilities.settings ) throw new Error(`Profile client '${client}' does not support settings`); - const hasMcp = hasSelectedMcp(declaration, client); if (hasMcp && !adapter.capabilities.mcp) throw new Error( `Profile client '${client}' does not support MCP configuration`, @@ -1306,7 +1308,9 @@ export async function planProfileOperation( ...planned, public: { ...planned.public, - detail: { mcpServers: mcpDisclosures(declaration, client) }, + detail: { + mcpServers: mcpDisclosures(effectiveMcpServers ?? {}), + }, }, context, } @@ -1329,7 +1333,7 @@ export async function planProfileOperation( public: { ...planned.public, detail: { - mcpServers: mcpDisclosures(declaration, client), + mcpServers: mcpDisclosures(effectiveMcpServers ?? {}), }, }, ...(requiresMcpPrerequisite && { diff --git a/src/core/sync.ts b/src/core/sync.ts index 3ed87ad4..345888f0 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -1,155 +1,147 @@ import { existsSync, - readFileSync, - writeFileSync, lstatSync, + readFileSync, type Stats, + writeFileSync, } from 'node:fs'; -import { rm, unlink, rmdir, copyFile } from 'node:fs/promises'; -import { join, resolve, dirname, relative } from 'node:path'; +import { copyFile, rm, rmdir, unlink } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; import JSON5 from 'json5'; import { - CONFIG_DIR, - WORKSPACE_CONFIG_FILE, AGENT_FILES, + CONFIG_DIR, getHomeDir, + WORKSPACE_CONFIG_FILE, } from '../constants.js'; -import { parseWorkspaceConfig } from '../utils/workspace-parser.js'; +import type { ClientMapping } from '../models/client-mapping.js'; +import { + CANONICAL_SKILLS_PATH, + CLIENT_MAPPINGS, + isUniversalClient, + resolveClientMappings, + USER_CLIENT_MAPPINGS, +} from '../models/client-mapping.js'; +import type { MarketplaceFileArtifacts } from '../models/marketplace-manifest.js'; +import type { + NativeStateResource, + SyncState, + SyncStateSource, +} from '../models/sync-state.js'; import type { - WorkspaceConfig, ClientType, PluginEntry, - WorkspaceFile, - SyncMode, PluginSkillsConfig, + SyncMode, + WorkspaceConfig, + WorkspaceFile, } from '../models/workspace-config.js'; import { - getPluginClients, + type ClientEntry, + getClientTypes, getEffectivePluginSource, + getPluginClients, getPluginExclude, - getClientTypes, normalizeClientEntry, resolveInstallMode, - type ClientEntry, } from '../models/workspace-config.js'; +import { getEmbeddedMarketplaceFileArtifacts } from '../utils/marketplace-manifest-parser.js'; import { isGitHubUrl, - parseGitHubUrl, parseFileSource, + parseGitHubUrl, stripGitRef, } from '../utils/plugin-path.js'; -import { fetchPlugin, getPluginName, seedFetchCache } from './plugin.js'; -import { - copyPluginToWorkspace, - copyWorkspaceFiles, - collectPluginSkills, - type CopyResult, - findRelocatedGitHubHooks, - dedupeAgentFilesByName, - planAgentOutputs, - type AgentDedupeRecord, - type AgentOutput, - type AgentOutputConflict, - type AgentOutputFailure, - type AgentOutputPlan, -} from './transform.js'; -import { updateAgentFiles } from './workspace-repo.js'; import { - discoverWorkspaceSkills, - writeSkillsIndex, - cleanupSkillsIndex, - groupSkillsByRepo, -} from './repo-skills.js'; -import { - CLIENT_MAPPINGS, - USER_CLIENT_MAPPINGS, - CANONICAL_SKILLS_PATH, - isUniversalClient, - resolveClientMappings, -} from '../models/client-mapping.js'; -import type { ClientMapping } from '../models/client-mapping.js'; -import type { MarketplaceFileArtifacts } from '../models/marketplace-manifest.js'; -import { getEmbeddedMarketplaceFileArtifacts } from '../utils/marketplace-manifest-parser.js'; -import { - resolveSkillNames, getSkillKey, + resolveSkillNames, type SkillEntry, } from '../utils/skill-name-resolver.js'; +import { Stopwatch } from '../utils/stopwatch.js'; +import { parseWorkspaceConfig } from '../utils/workspace-parser.js'; +import { + assertSafeDestination, + clientMappingsFromContexts, + pathIsWithin, + type ResolvedClientContext, + resolveClientContexts, + resolveMappedPath, +} from './client-context.js'; +import { syncCodexProjectHooks } from './codex-hooks.js'; +import { + COPILOT_MANAGED_HOOKS_RELATIVE_PATH, + syncCopilotProjectHooks, +} from './copilot-hooks.js'; +import { processManagedRepos } from './managed-repos.js'; import { - isPluginSpec, - resolvePluginSpecWithAutoRegister, ensureMarketplacesRegistered, - parsePluginSpec, - getMarketplaceOverrides, - getRegistryPath, - getProjectRegistryPath, getMarketplace, getMarketplaceAccessError, + getMarketplaceOverrides, + getProjectRegistryPath, + getRegistryPath, + isPluginSpec, + parsePluginSpec, + resolvePluginSpecWithAutoRegister, } from './marketplace.js'; +import { syncMcpServers as runMcpSync } from './mcp-sync.js'; +import { + getNativeClient, + mergeNativeSyncResults, + type NativeEffect, + type NativeMutationResult, + type NativeOperationContext, + type NativeResource, + type NativeSyncResult, + sanitizeNativeProvenance, +} from './native/index.js'; +import { fetchPlugin, getPluginName, seedFetchCache } from './plugin.js'; +import { + cleanupSkillsIndex, + discoverWorkspaceSkills, + groupSkillsByRepo, + writeSkillsIndex, +} from './repo-skills.js'; import { - loadSyncState, - saveSyncState, - saveNativeStateResources, - getPreviouslySyncedFiles, - getPreviouslySyncedMcpServers, getNativeStateResources, + getPreviouslySyncedFiles, + loadSyncState, nativeStateOwnership, + saveNativeStateResources, + saveSyncState, } from './sync-state.js'; -import type { - NativeStateResource, - SyncState, - SyncStateSource, -} from '../models/sync-state.js'; +import { + type AgentDedupeRecord, + type AgentOutput, + type AgentOutputConflict, + type AgentOutputFailure, + type AgentOutputPlan, + type CopyResult, + collectPluginSkills, + copyPluginToWorkspace, + copyWorkspaceFiles, + dedupeAgentFilesByName, + findRelocatedGitHubHooks, + planAgentOutputs, +} from './transform.js'; +import { syncUserMcpAdapters } from './user-mcp-sync.js'; import { getUserWorkspaceConfig, migrateUserWorkspaceSkillsV1toV2, } from './user-workspace.js'; +import type { McpMergeResult } from './vscode-mcp.js'; import { + computeWorkspaceHash, generateVscodeWorkspace, getWorkspaceOutputPath, - computeWorkspaceHash, reconcileVscodeWorkspaceFolders, } from './vscode-workspace.js'; import { + migrateWorkspaceSkillsV1toV2, setRepositories, updateRepositories, - migrateWorkspaceSkillsV1toV2, } from './workspace-modify.js'; -import { collectMcpServers, syncVscodeMcpConfig } from './vscode-mcp.js'; -import type { McpMergeResult } from './vscode-mcp.js'; -import { applyMcpProxy } from './mcp-proxy.js'; -import { syncCodexMcpServers } from './codex-mcp.js'; -import { syncCodexProjectHooks } from './codex-hooks.js'; -import { - COPILOT_MANAGED_HOOKS_RELATIVE_PATH, - syncCopilotProjectHooks, -} from './copilot-hooks.js'; -import { - syncClaudeMcpConfig, - syncClaudeMcpServersViaCli, -} from './claude-mcp.js'; -import { getCopilotMcpConfigPath } from './copilot-mcp.js'; -import { syncMcpServers as runMcpSync } from './mcp-sync.js'; -import { - getNativeClient, - mergeNativeSyncResults, - sanitizeNativeProvenance, - type NativeEffect, - type NativeMutationResult, - type NativeOperationContext, - type NativeResource, - type NativeSyncResult, -} from './native/index.js'; -import { Stopwatch } from '../utils/stopwatch.js'; -import { processManagedRepos } from './managed-repos.js'; -import { - assertSafeDestination, - clientMappingsFromContexts, - pathIsWithin, - resolveClientContexts, - resolveMappedPath, - type ResolvedClientContext, -} from './client-context.js'; +import { updateAgentFiles } from './workspace-repo.js'; /** * Result of deduplicating clients by skillsPath @@ -453,7 +445,7 @@ export function nativeOperationContext( export function nativeContextIdentity(context: NativeOperationContext): string { if (context.client !== 'omp') return resolve(context.root); const roots = Object.entries(context.roots ?? {}) - .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) .map(([name, path]) => [name, resolve(path)]); return JSON.stringify({ root: resolve(context.root), @@ -519,10 +511,7 @@ export function nativeIdentityMatches( candidate.marketplaceName === targetSpec.marketplaceName, ); } - if ( - requestedSpec?.plugin === target || - resolvedSpec?.plugin === target - ) { + if (requestedSpec?.plugin === target || resolvedSpec?.plugin === target) { return true; } @@ -548,7 +537,8 @@ function nativeSelectionMatches( return ( !selection || selection.targets.some((target) => - nativeIdentityMatches(target, requestedIdentity, resolvedIdentity)) + nativeIdentityMatches(target, requestedIdentity, resolvedIdentity), + ) ); } @@ -584,7 +574,10 @@ async function preflightNativePlans( errors.push(resolution.error ?? `${client} rejected '${plan.source}'`); continue; } - const logicalIdentity = nativeLogicalIdentity(client, resolution.resource); + const logicalIdentity = nativeLogicalIdentity( + client, + resolution.resource, + ); const clientIdentities = desiredIdentities.get(client) ?? new Map(); const duplicate = clientIdentities.get(logicalIdentity); if (duplicate) { @@ -645,14 +638,13 @@ function nativePreflightFailureResult( const resolution = adapter?.resolveSource(plan.source, context, { source: plan.source, }); - const resource: NativeResource = - resolution?.resource ?? { - kind: client === 'pi' ? 'package' : 'plugin', - requestedIdentity: plan.source, - resolvedIdentity: plan.source, - context, - provenance: { source: plan.source }, - }; + const resource: NativeResource = resolution?.resource ?? { + kind: client === 'pi' ? 'package' : 'plugin', + requestedIdentity: plan.source, + resolvedIdentity: plan.source, + context, + provenance: { source: plan.source }, + }; const error = errors.find( (candidate) => @@ -847,10 +839,7 @@ const MANAGED_DIRECTORY_KEYS = [ 'githubPath', ] as const satisfies readonly (keyof ClientMapping)[]; -function resolveTrackedPath( - workspacePath: string, - filePath: string, -): string { +function resolveTrackedPath(workspacePath: string, filePath: string): string { return resolveMappedPath(workspacePath, filePath.replace(/[\\/]$/, '')); } @@ -918,14 +907,7 @@ export async function selectivePurgeWorkspace( const purgedPaths: string[] = []; for (const filePath of previousFiles) { - if ( - !trackedPathIsAllowed( - workspacePath, - filePath, - mapping, - context, - ) - ) { + if (!trackedPathIsAllowed(workspacePath, filePath, mapping, context)) { continue; } const cleanPath = resolveTrackedPath(workspacePath, filePath); @@ -950,7 +932,10 @@ export async function selectivePurgeWorkspace( await unlink(cleanPath); } purgedPaths.push(filePath); - await cleanupEmptyParents(context?.writeRoot ?? workspacePath, cleanPath); + await cleanupEmptyParents( + context?.writeRoot ?? workspacePath, + cleanPath, + ); } catch { // Best effort - continue with other files } @@ -1299,8 +1284,7 @@ export function collectSyncedPaths( } } - const directoryRoots = MANAGED_DIRECTORY_KEYS - .map((key) => mapping[key]) + const directoryRoots = MANAGED_DIRECTORY_KEYS.map((key) => mapping[key]) .filter((path): path is string => path !== undefined) .map((path) => resolveMappedPath(workspacePath, path)); const belongsToDirectory = directoryRoots.some((root) => @@ -1605,7 +1589,8 @@ export function buildPluginSyncPlans( for (const client of pluginClientTypes) { const clientEntry = normalizeClientEntry( clientEntries.find( - (entry) => (typeof entry === 'string' ? entry : entry.name) === client, + (entry) => + (typeof entry === 'string' ? entry : entry.name) === client, ) ?? client, ); if (resolveInstallMode(plugin, clientEntry) === 'file') { @@ -2151,7 +2136,8 @@ async function syncNativePlugins( selection, resource.requestedIdentity, resource.resolvedIdentity, - )); + ), + ); if (selected.length > 0) desiredByClient.set(client, selected); } } @@ -2174,7 +2160,8 @@ async function syncNativePlugins( selection, resource.requestedIdentity, resource.resolvedIdentity, - )) + ), + ) ) { clients.add(client); } @@ -2271,14 +2258,17 @@ async function syncNativePlugins( resource.requestedIdentity, resource.resolvedIdentity, ) - : true); + : true, + ); let inspection = await adapter.inspect(context); if (!inspection.success) { - const affected = desired.length > 0 - ? desired - : tracked.map((resource) => - nativeResourceFromState(resource, context)); + const affected = + desired.length > 0 + ? desired + : tracked.map((resource) => + nativeResourceFromState(resource, context), + ); for (const resource of affected) { effects.push({ action: 'failed', @@ -2928,7 +2918,6 @@ export async function syncWorkspace( workspacePath: string = process.cwd(), options: SyncOptions = {}, ): Promise { - const { offline = false, dryRun = false, @@ -3046,7 +3035,6 @@ export async function syncWorkspace( ); } - // Generic marketplace registration/fetch is needed only by file targets. const filePlans = filteredPlans.filter((plan) => plan.clients.length > 0); const marketplaceResults = await sw.measure('marketplace-registration', () => @@ -3083,7 +3071,9 @@ export async function syncWorkspace( sw.stop('workspace-source-validation'); } - const failedValidations = validatedPlugins.filter((plugin) => !plugin.success); + const failedValidations = validatedPlugins.filter( + (plugin) => !plugin.success, + ); const requiredNativeFailures = failedValidations.filter((plugin) => { const plan = filteredPlans.find( (candidate) => candidate.configurationIndex === plugin.configurationIndex, @@ -3115,7 +3105,9 @@ export async function syncWorkspace( } const validPlugins = validatedPlugins.filter((plugin) => plugin.success); - const filePlugins = validPlugins.filter((plugin) => plugin.clients.length > 0); + const filePlugins = validPlugins.filter( + (plugin) => plugin.clients.length > 0, + ); if (validPlugins.length === 0 && filteredPlans.length > 0) { return failedSyncResult( `All plugins failed validation (workspace unchanged):\n${failedValidations.map((plugin) => ` - ${plugin.plugin}: ${plugin.error}`).join('\n')}`, @@ -3128,9 +3120,7 @@ export async function syncWorkspace( !!config.workspace?.source && !validatedWorkspaceSource; const workspaceFilesSourcePath = validatedWorkspaceSource?.resolved; const workspaceFilesToCopy = - config.workspace && !skipWorkspaceFiles - ? [...config.workspace.files] - : []; + config.workspace && !skipWorkspaceFiles ? [...config.workspace.files] : []; let workspaceFilesGithubCache = new Map(); if (config.workspace && !skipWorkspaceFiles) { if (hasRepositories && workspaceFilesSourcePath) { @@ -3209,10 +3199,7 @@ export async function syncWorkspace( clientContexts, CLIENT_MAPPINGS, ); - const resolvedMappings = resolveClientMappings( - syncClients, - contextMappings, - ); + const resolvedMappings = resolveClientMappings(syncClients, contextMappings); // Step 2b: Get paths that will be purged (for dry-run reporting) // In non-destructive mode, only show files from state (or nothing on first sync) @@ -3509,7 +3496,8 @@ export async function syncWorkspace( (effect) => effect.action === 'failed' || effect.action === 'unknown', ).length ?? 0; const totalFailed = fileFailures + nativeFailures; - const hasFailures = pluginResults.some((result) => !result.success) || + const hasFailures = + pluginResults.some((result) => !result.success) || totalFailed > 0 || nativeResult?.success === false; @@ -3534,28 +3522,24 @@ export async function syncWorkspace( if (!dryRun) { const sources = await buildSourcesProvenance(filePlugins, config.plugins); await sw.measure('persist-state', () => - persistSyncState( - workspacePath, - newStatePaths, - { - ...(vscodeState && { vscodeState }), - ...(codexHookSync.managedHooks && { - codexHooks: codexHookSync.managedHooks, - }), - ...(Object.keys(mcpResults).length > 0 && { - mcpTrackedServers: Object.fromEntries( - Object.entries(mcpResults).map(([scope, r]) => [ - scope, - r.trackedServers, - ]), - ), - }), - ...(writtenSkillsIndexFiles.length > 0 && { - skillsIndex: writtenSkillsIndexFiles, - }), - ...(Object.keys(sources).length > 0 && { sources }), - }, - ), + persistSyncState(workspacePath, newStatePaths, { + ...(vscodeState && { vscodeState }), + ...(codexHookSync.managedHooks && { + codexHooks: codexHookSync.managedHooks, + }), + ...(Object.keys(mcpResults).length > 0 && { + mcpTrackedServers: Object.fromEntries( + Object.entries(mcpResults).map(([scope, r]) => [ + scope, + r.trackedServers, + ]), + ), + }), + ...(writtenSkillsIndexFiles.length > 0 && { + skillsIndex: writtenSkillsIndexFiles, + }), + ...(Object.keys(sources).length > 0 && { sources }), + }), ); } @@ -3590,7 +3574,7 @@ export async function seedFetchCacheFromMarketplaces( if (!result.success || !result.name) continue; const entry = await getMarketplace(result.name); - if (!entry || entry.source.type !== 'github') continue; + if (entry?.source.type !== 'github') continue; if (getMarketplaceAccessError(entry)) continue; // Seed the bare key (owner/repo without branch) @@ -3723,7 +3707,9 @@ export async function syncUserWorkspace( () => validateAllPlugins(pluginPlans, homeDir, offline), `${pluginPlans.length} plugin(s)`, ); - const failedValidations = validatedPlugins.filter((plugin) => !plugin.success); + const failedValidations = validatedPlugins.filter( + (plugin) => !plugin.success, + ); const requiredNativeFailures = failedValidations.filter((plugin) => { const plan = pluginPlans.find( (candidate) => candidate.configurationIndex === plugin.configurationIndex, @@ -3750,7 +3736,9 @@ export async function syncUserWorkspace( ); } const validPlugins = validatedPlugins.filter((plugin) => plugin.success); - const filePlugins = validPlugins.filter((plugin) => plugin.clients.length > 0); + const filePlugins = validPlugins.filter( + (plugin) => plugin.clients.length > 0, + ); const messages: string[] = []; if (validPlugins.length === 0 && pluginPlans.length > 0) { return failedSyncResult( @@ -3842,11 +3830,7 @@ export async function syncUserWorkspace( ); const pluginSkillMaps = buildPluginSkillNameMaps(allSkills); const agentOutputPlan = await sw.measure('agent-output-planning', () => - planValidatedPluginAgentOutputs( - filePlugins, - homeDir, - userContextMappings, - ), + planValidatedPluginAgentOutputs(filePlugins, homeDir, userContextMappings), ); appendAgentOutputConflictWarnings(agentOutputPlan, warnings); const indexedAgentOutputPlan = indexAgentOutputPlan(agentOutputPlan); @@ -3895,129 +3879,20 @@ export async function syncUserWorkspace( `${filePlugins.length} plugin(s)`, ); - // MCP Proxy: prepare transform if configured (user-scoped) - const userMcpProxyConfig = config.mcpProxy; - const userWorkspaceMcpServers = config.mcpServers; - - // Emit collection warnings once across all user-scoped client syncs. - let userCollectWarningsEmitted = false; - function getUserServersForClient(client: ClientType): Map { - const { servers, warnings: collectWarnings } = collectMcpServers( - filePlugins, - userWorkspaceMcpServers, - client, - ); - if (!userCollectWarningsEmitted) { - warnings.push(...collectWarnings); - userCollectWarningsEmitted = true; - } - if (userMcpProxyConfig) { - return applyMcpProxy(servers, client, userMcpProxyConfig); - } - return servers; - } - - // Sync MCP server configs to VS Code if vscode client is configured sw.start('mcp-sync'); - const mcpResults: Record = {}; - if (syncClients.includes('vscode')) { - const trackedMcpServers = getPreviouslySyncedMcpServers( - previousState, - 'vscode', - ); - const vscodeMcpOverrides = getUserServersForClient('vscode'); - const vscodeMcp = syncVscodeMcpConfig(filePlugins, { - dryRun, - force, - trackedServers: trackedMcpServers, - serverOverrides: vscodeMcpOverrides, - }); - if (vscodeMcp.warnings.length > 0) { - warnings.push(...vscodeMcp.warnings); - } - mcpResults.vscode = vscodeMcp; - } - - // Sync MCP servers to Codex CLI if codex client is configured - if (syncClients.includes('codex')) { - const trackedMcpServers = getPreviouslySyncedMcpServers( - previousState, - 'codex', - ); - const codexMcpOverrides = getUserServersForClient('codex'); - const codexMcp = await syncCodexMcpServers(filePlugins, { - dryRun, - trackedServers: trackedMcpServers, - ...(codexMcpOverrides && { serverOverrides: codexMcpOverrides }), - }); - if (codexMcp.warnings.length > 0) { - warnings.push(...codexMcp.warnings); - } - mcpResults.codex = codexMcp; - } - - // Sync MCP servers to Claude Code via CLI if claude client is configured - if (syncClients.includes('claude')) { - const trackedMcpServers = getPreviouslySyncedMcpServers( - previousState, - 'claude', - ); - const claudeMcpOverrides = getUserServersForClient('claude'); - const claudeMcp = await syncClaudeMcpServersViaCli(filePlugins, { - dryRun, - trackedServers: trackedMcpServers, - ...(claudeMcpOverrides && { serverOverrides: claudeMcpOverrides }), - }); - if (claudeMcp.warnings.length > 0) { - warnings.push(...claudeMcp.warnings); - } - mcpResults.claude = claudeMcp; - } - - // Sync MCP servers to Copilot CLI config if copilot client is configured - if (syncClients.includes('copilot')) { - const trackedMcpServers = getPreviouslySyncedMcpServers( - previousState, - 'copilot', - ); - const copilotMcpPath = getCopilotMcpConfigPath(); - const copilotMcpOverrides = getUserServersForClient('copilot'); - const copilotMcp = syncClaudeMcpConfig(filePlugins, { - dryRun, - force, - configPath: copilotMcpPath, - trackedServers: trackedMcpServers, - ...(copilotMcpOverrides && { serverOverrides: copilotMcpOverrides }), - }); - if (copilotMcp.warnings.length > 0) { - warnings.push(...copilotMcp.warnings); - } - mcpResults.copilot = copilotMcp; - } - + const userMcpSyncResult = await syncUserMcpAdapters({ + validPlugins: filePlugins, + config, + previousState, + syncClients, + dryRun, + force, + }); sw.stop('mcp-sync'); - - // Warn about clients that don't support user-scoped MCP sync - const USER_MCP_CLIENTS = new Set([ - 'claude', - 'codex', - 'vscode', - 'copilot', - 'universal', - ]); - const allUserMcpServers = collectMcpServers( - filePlugins, - userWorkspaceMcpServers, - ).servers; - if (allUserMcpServers.size > 0) { - for (const client of syncClients) { - if (!USER_MCP_CLIENTS.has(client)) { - warnings.push( - `MCP servers not synced for ${client} (not supported at user scope)`, - ); - } - } - } + warnings.push(...userMcpSyncResult.warnings); + const mcpResults: Record = { + ...userMcpSyncResult.mcpResults, + }; // Run native CLI installations for user scope const nativeResult = await sw.measure('native-plugin-sync', () => @@ -4078,20 +3953,16 @@ export async function syncUserWorkspace( // Save sync state (including MCP servers and native resources). if (!dryRun) { await sw.measure('persist-state', () => - persistSyncState( - homeDir, - newStatePaths, - { - ...(Object.keys(mcpResults).length > 0 && { - mcpTrackedServers: Object.fromEntries( - Object.entries(mcpResults).map(([scope, r]) => [ - scope, - r.trackedServers, - ]), - ), - }), - }, - ), + persistSyncState(homeDir, newStatePaths, { + ...(Object.keys(mcpResults).length > 0 && { + mcpTrackedServers: Object.fromEntries( + Object.entries(mcpResults).map(([scope, r]) => [ + scope, + r.trackedServers, + ]), + ), + }), + }), ); } diff --git a/src/core/user-mcp-sync.ts b/src/core/user-mcp-sync.ts new file mode 100644 index 00000000..e5b6d63a --- /dev/null +++ b/src/core/user-mcp-sync.ts @@ -0,0 +1,143 @@ +import type { SyncState } from '../models/sync-state.js'; +import type { + ClientType, + WorkspaceConfig, +} from '../models/workspace-config.js'; +import { + syncClaudeMcpConfig, + syncClaudeMcpServersViaCli, +} from './claude-mcp.js'; +import { syncCodexMcpServers } from './codex-mcp.js'; +import { getCopilotMcpConfigPath } from './copilot-mcp.js'; +import { applyMcpProxy } from './mcp-proxy.js'; +import type { ValidatedPlugin } from './sync.js'; +import { getPreviouslySyncedMcpServers, type McpScope } from './sync-state.js'; +import { + collectMcpServers, + type McpMergeResult, + syncVscodeMcpConfig, +} from './vscode-mcp.js'; + +const USER_MCP_CLIENTS: Partial> = { + claude: true, + codex: true, + vscode: true, + copilot: true, + universal: true, +}; + +export interface SyncUserMcpAdaptersOptions { + validPlugins: ValidatedPlugin[]; + config: WorkspaceConfig; + previousState: SyncState | null; + syncClients: ClientType[]; + dryRun?: boolean; + force?: boolean; +} + +export interface SyncUserMcpAdaptersResult { + mcpResults: Partial>; + warnings: string[]; + trackedServers: Partial>; + complete: boolean; +} + +/** + * Reconcile ordinary user-scoped MCP destinations using the same ownership + * semantics as a full user workspace sync. + */ +export async function syncUserMcpAdapters({ + validPlugins, + config, + previousState, + syncClients, + dryRun = false, + force = false, +}: SyncUserMcpAdaptersOptions): Promise { + const warnings: string[] = []; + const mcpResults: Partial> = {}; + const trackedServers: Partial> = {}; + let collectWarningsEmitted = false; + + function getServersForClient(client: ClientType): Map { + const { servers, warnings: collectWarnings } = collectMcpServers( + validPlugins, + config.mcpServers, + client, + ); + if (!collectWarningsEmitted) { + warnings.push(...collectWarnings); + collectWarningsEmitted = true; + } + return config.mcpProxy + ? applyMcpProxy(servers, client, config.mcpProxy) + : servers; + } + + if (syncClients.includes('vscode')) { + const result = syncVscodeMcpConfig(validPlugins, { + dryRun, + force, + trackedServers: getPreviouslySyncedMcpServers(previousState, 'vscode'), + serverOverrides: getServersForClient('vscode'), + }); + warnings.push(...result.warnings); + mcpResults.vscode = result; + trackedServers.vscode = result.trackedServers; + } + + if (syncClients.includes('codex')) { + const result = await syncCodexMcpServers(validPlugins, { + dryRun, + trackedServers: getPreviouslySyncedMcpServers(previousState, 'codex'), + serverOverrides: getServersForClient('codex'), + }); + warnings.push(...result.warnings); + mcpResults.codex = result; + trackedServers.codex = result.trackedServers; + } + + if (syncClients.includes('claude')) { + const result = await syncClaudeMcpServersViaCli(validPlugins, { + dryRun, + trackedServers: getPreviouslySyncedMcpServers(previousState, 'claude'), + serverOverrides: getServersForClient('claude'), + }); + warnings.push(...result.warnings); + mcpResults.claude = result; + trackedServers.claude = result.trackedServers; + } + + if (syncClients.includes('copilot')) { + const result = syncClaudeMcpConfig(validPlugins, { + dryRun, + force, + configPath: getCopilotMcpConfigPath(), + trackedServers: getPreviouslySyncedMcpServers(previousState, 'copilot'), + serverOverrides: getServersForClient('copilot'), + }); + warnings.push(...result.warnings); + mcpResults.copilot = result; + trackedServers.copilot = result.trackedServers; + } + + const allServers = collectMcpServers(validPlugins, config.mcpServers).servers; + if (allServers.size > 0) { + for (const client of syncClients) { + if (!USER_MCP_CLIENTS[client]) { + warnings.push( + `MCP servers not synced for ${client} (not supported at user scope)`, + ); + } + } + } + + return { + mcpResults, + warnings, + trackedServers, + complete: Object.values(mcpResults).every( + (result) => result?.authoritative !== false, + ), + }; +} diff --git a/src/core/vscode-mcp.ts b/src/core/vscode-mcp.ts index d94e74c7..9f9973f2 100644 --- a/src/core/vscode-mcp.ts +++ b/src/core/vscode-mcp.ts @@ -1,10 +1,12 @@ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import JSON5 from 'json5'; import { getHomeDir } from '../constants.js'; +import type { + ClientType, + McpServerConfig, +} from '../models/workspace-config.js'; import type { ValidatedPlugin } from './sync.js'; -import type { ClientType } from '../models/workspace-config.js'; -import type { McpServerConfig } from '../models/workspace-config.js'; /** * Deep equality check for MCP server configs. @@ -44,6 +46,8 @@ export interface McpMergeResult { removedServers: string[]; /** All servers that are now tracked (for saving to sync state) */ trackedServers: string[]; + /** False when the adapter could not determine or apply authoritative state. */ + authoritative?: boolean; /** Path to the config file that was modified (set when changes are written) */ configPath?: string; } @@ -62,7 +66,14 @@ export function getVscodeMcpConfigPath(): string { } const home = getHomeDir(); if (platform === 'darwin') { - return join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'); + return join( + home, + 'Library', + 'Application Support', + 'Code', + 'User', + 'mcp.json', + ); } // Linux return join(home, '.config', 'Code', 'User', 'mcp.json'); @@ -72,7 +83,9 @@ export function getVscodeMcpConfigPath(): string { * Read .mcp.json from a plugin root directory. * Returns the mcpServers object or null if absent/invalid. */ -export function readPluginMcpConfig(pluginPath: string): Record | null { +export function readPluginMcpConfig( + pluginPath: string, +): Record | null { const mcpPath = join(pluginPath, '.mcp.json'); if (!existsSync(mcpPath)) { return null; @@ -80,7 +93,12 @@ export function readPluginMcpConfig(pluginPath: string): Record try { const content = readFileSync(mcpPath, 'utf-8'); const parsed = JSON5.parse(content); - if (parsed && typeof parsed === 'object' && parsed.mcpServers && typeof parsed.mcpServers === 'object') { + if ( + parsed && + typeof parsed === 'object' && + parsed.mcpServers && + typeof parsed.mcpServers === 'object' + ) { return parsed.mcpServers as Record; } return null; @@ -94,7 +112,9 @@ export function readPluginMcpConfig(pluginPath: string): Record * before it is passed to a client sync function. Currently removes `clients` * (a sync filter that should never be written into client MCP configs). */ -function stripWorkspaceMcpMeta(config: McpServerConfig): Record { +function stripWorkspaceMcpMeta( + config: McpServerConfig, +): Record { const { clients: _clients, ...rest } = config as McpServerConfig & { clients?: ClientType[]; }; @@ -126,7 +146,9 @@ export function collectMcpServers( for (const [name, config] of Object.entries(mcpServers)) { if (servers.has(name)) { - warnings.push(`MCP server '${name}' from ${plugin.plugin} conflicts with earlier plugin (skipped)`); + warnings.push( + `MCP server '${name}' from ${plugin.plugin} conflicts with earlier plugin (skipped)`, + ); } else { servers.set(name, config); } @@ -135,7 +157,11 @@ export function collectMcpServers( if (workspaceServers) { for (const [name, config] of Object.entries(workspaceServers)) { - if (targetClient && config.clients && !config.clients.includes(targetClient)) { + if ( + targetClient && + config.clients && + !config.clients.includes(targetClient) + ) { continue; } if (servers.has(name)) { @@ -205,13 +231,16 @@ export function syncVscodeMcpConfig( existingConfig = JSON5.parse(content); } catch { // If invalid, start fresh but warn - result.warnings.push(`Could not parse existing ${configPath}, starting fresh`); + result.warnings.push( + `Could not parse existing ${configPath}, starting fresh`, + ); existingConfig = {}; } } // Get or create the servers object (VS Code uses "servers" key) - const existingServers = (existingConfig.servers as Record) ?? {}; + const existingServers = + (existingConfig.servers as Record) ?? {}; // Process plugin servers: add new, update tracked, skip user-managed conflicts for (const [name, config] of pluginServers) { @@ -253,7 +282,10 @@ export function syncVscodeMcpConfig( if (hasTracking) { const currentServerNames = new Set(pluginServers.keys()); for (const trackedName of previouslyTracked) { - if (!currentServerNames.has(trackedName) && trackedName in existingServers) { + if ( + !currentServerNames.has(trackedName) && + trackedName in existingServers + ) { delete existingServers[trackedName]; result.removed++; result.removedServers.push(trackedName); @@ -262,14 +294,19 @@ export function syncVscodeMcpConfig( } // Write back if there were changes and not dry-run - const hasChanges = result.added > 0 || result.overwritten > 0 || result.removed > 0; + const hasChanges = + result.added > 0 || result.overwritten > 0 || result.removed > 0; if (hasChanges && !dryRun) { existingConfig.servers = existingServers; const dir = dirname(configPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(configPath, `${JSON.stringify(existingConfig, null, 2)}\n`, 'utf-8'); + writeFileSync( + configPath, + `${JSON.stringify(existingConfig, null, 2)}\n`, + 'utf-8', + ); result.configPath = configPath; } @@ -289,7 +326,11 @@ export function syncVscodeMcpConfig( if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(configPath, `${JSON.stringify(existingConfig, null, 2)}\n`, 'utf-8'); + writeFileSync( + configPath, + `${JSON.stringify(existingConfig, null, 2)}\n`, + 'utf-8', + ); result.configPath = configPath; } } diff --git a/src/models/workspace-config.ts b/src/models/workspace-config.ts index 8880e09c..bdc358d2 100644 --- a/src/models/workspace-config.ts +++ b/src/models/workspace-config.ts @@ -340,7 +340,7 @@ export const McpProxyServerSchema = z.object({ * built-in AllAgents HTTP proxy helper */ export const McpProxyConfigSchema = z.object({ - clients: z.array(z.string()), + clients: z.array(z.string()).default([]), servers: z.record(McpProxyServerSchema).optional(), }); @@ -383,14 +383,13 @@ export type McpServerConfig = z.infer; * Portable secret references are preserved verbatim until the selected client * resolves them at runtime. Profile declarations never accept resolved values. */ -const PROFILE_SECRET_REFERENCE_PATTERN = - /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/; +const PROFILE_SECRET_REFERENCE_PATTERN = /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/; export const ProfileSecretReferenceSchema = z .string() .regex( PROFILE_SECRET_REFERENCE_PATTERN, - 'Expected an exact ${ENV_VAR} reference', + `Expected an exact \${ENV_VAR} reference`, ); /** @@ -406,6 +405,13 @@ export const ProfileNameSchema = z export type ProfileName = z.infer; +export const ProfileMcpServerNameSchema = z + .string() + .regex( + /^[A-Za-z0-9_.-]{1,100}$/, + 'Expected 1-100 ASCII letters, numbers, dots, underscores, or hyphens', + ); + /** * Normalize a declared launcher to the command identity which can exist on * every supported platform. Windows companion extensions share one identity. @@ -428,9 +434,7 @@ export const ClaudeProfileSettingsSchema = z }) .strict(); -export type ClaudeProfileSettings = z.infer< - typeof ClaudeProfileSettingsSchema ->; +export type ClaudeProfileSettings = z.infer; export const OpenCodeProfileSettingsSchema = z .object({ @@ -510,9 +514,7 @@ export const CodexProfileSettingsSchema = z }) .strict(); -export type CodexProfileSettings = z.infer< - typeof CodexProfileSettingsSchema ->; +export type CodexProfileSettings = z.infer; /** * Profile clients deliberately use object form only. Each adapter owns a @@ -625,13 +627,12 @@ const PROFILE_SENSITIVE_MCP_FIELD_PATTERN = /(?:^|[-_.])(?:api[-_]?key|auth|authorization|credential|key|password|secret|signature|token)(?:$|[-_.])/i; function isProfileSecretReference(value: string | undefined): boolean { - return ( - value !== undefined && PROFILE_SECRET_REFERENCE_PATTERN.test(value) - ); + return value !== undefined && PROFILE_SECRET_REFERENCE_PATTERN.test(value); } -const ProfileMcpArgumentsSchema = z.array(z.string()).superRefine( - (arguments_, ctx) => { +const ProfileMcpArgumentsSchema = z + .array(z.string()) + .superRefine((arguments_, ctx) => { const invalidIndexes = new Set(); const reject = (index: number) => { if (invalidIndexes.has(index)) return; @@ -639,11 +640,17 @@ const ProfileMcpArgumentsSchema = z.array(z.string()).superRefine( ctx.addIssue({ code: z.ZodIssueCode.custom, path: [index], - message: 'Secret arguments must be exact ${ENV_VAR} references', + message: `Secret arguments must be exact \${ENV_VAR} references`, }); }; for (const [index, argument] of arguments_.entries()) { + if (arguments_[index - 1] === '--header-env') { + if (!/^[^=:\s]+=[A-Za-z_][A-Za-z0-9_]*$/.test(argument)) { + reject(index); + } + continue; + } const separateOption = argument.match(/^(?:--?|\/)([^=:\s]+)$/); const separateOptionName = separateOption?.[1]; if ( @@ -652,9 +659,7 @@ const ProfileMcpArgumentsSchema = z.array(z.string()).superRefine( ) { const credentialIndex = index + 1; if (!isProfileSecretReference(arguments_[credentialIndex])) { - reject( - credentialIndex < arguments_.length ? credentialIndex : index, - ); + reject(credentialIndex < arguments_.length ? credentialIndex : index); } continue; } @@ -662,16 +667,12 @@ const ProfileMcpArgumentsSchema = z.array(z.string()).superRefine( if (/^bearer$/i.test(argument)) { const credentialIndex = index + 1; if (!isProfileSecretReference(arguments_[credentialIndex])) { - reject( - credentialIndex < arguments_.length ? credentialIndex : index, - ); + reject(credentialIndex < arguments_.length ? credentialIndex : index); } continue; } - const assignment = argument.match( - /^(?:--?|\/)?([^=:\s]+)[=:]\s*(.*)$/, - ); + const assignment = argument.match(/^(?:--?|\/)?([^=:\s]+)[=:]\s*(.*)$/); const assignmentName = assignment?.[1]; const inlineCredential = assignmentName && @@ -706,8 +707,7 @@ const ProfileMcpArgumentsSchema = z.array(z.string()).superRefine( reject(index); } } - }, -); + }); export const ProfileMcpServerConfigSchema = z.union([ z @@ -737,7 +737,10 @@ export const ProfileDeclarationSchema = z .object({ clients: z.array(ProfileClientSchema).min(1), plugins: z.array(ProfilePluginEntrySchema).default([]), - mcpServers: z.record(ProfileMcpServerConfigSchema).optional(), + mcpServers: z + .record(ProfileMcpServerNameSchema, ProfileMcpServerConfigSchema) + .optional(), + mcpProxy: McpProxyConfigSchema.optional(), }) .strict() .superRefine((profile, ctx) => { @@ -755,11 +758,12 @@ export const ProfileDeclarationSchema = z }); const validateSelector = ( - clients: ClientType[] | undefined, + clients: readonly string[] | undefined, path: (string | number)[], + allowWildcard = false, ): void => { if (!clients) return; - const selected = new Set(); + const selected = new Set(); clients.forEach((client, index) => { if (selected.has(client)) { ctx.addIssue({ @@ -767,7 +771,10 @@ export const ProfileDeclarationSchema = z path: [...path, index], message: `Client selector '${client}' is duplicated`, }); - } else if (!declaredClients.has(client)) { + } else if ( + !(allowWildcard && client === '*') && + !declaredClients.has(client as ClientType) + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [...path, index], @@ -786,11 +793,22 @@ export const ProfileDeclarationSchema = z if (profile.mcpServers) { for (const [serverName, server] of Object.entries(profile.mcpServers)) { - validateSelector(server.clients, [ - 'mcpServers', - serverName, - 'clients', - ]); + validateSelector(server.clients, ['mcpServers', serverName, 'clients']); + } + } + + if (profile.mcpProxy) { + validateSelector(profile.mcpProxy.clients, ['mcpProxy', 'clients']); + if (profile.mcpProxy.servers) { + for (const [serverName, server] of Object.entries( + profile.mcpProxy.servers, + )) { + validateSelector( + server.proxy, + ['mcpProxy', 'servers', serverName, 'proxy'], + true, + ); + } } } }); diff --git a/src/utils/workspace-parser.ts b/src/utils/workspace-parser.ts index 058d5f6c..425e6983 100644 --- a/src/utils/workspace-parser.ts +++ b/src/utils/workspace-parser.ts @@ -1,9 +1,10 @@ import { readFile } from 'node:fs/promises'; + import { - ProjectWorkspaceConfigSchema, - UserWorkspaceConfigSchema, type ProjectWorkspaceConfig, + ProjectWorkspaceConfigSchema, type UserWorkspaceConfig, + UserWorkspaceConfigSchema, type WorkspaceConfig, } from '../models/workspace-config.js'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; @@ -13,15 +14,19 @@ const configName = `${CONFIG_DIR}/${WORKSPACE_CONFIG_FILE}`; export type WorkspaceConfigScope = 'project' | 'user'; +export type EditableUserWorkspaceConfig = Omit< + UserWorkspaceConfig, + 'repositories' | 'plugins' | 'clients' +> & + Partial>; + function formatValidationError( path: string, scope: WorkspaceConfigScope, input: unknown, ): ProjectWorkspaceConfig | UserWorkspaceConfig { const schema = - scope === 'user' - ? UserWorkspaceConfigSchema - : ProjectWorkspaceConfigSchema; + scope === 'user' ? UserWorkspaceConfigSchema : ProjectWorkspaceConfigSchema; const result = schema.safeParse(input); if (result.success) return result.data; @@ -35,7 +40,11 @@ export function validateProjectWorkspaceConfig( input: unknown, path: string = configName, ): ProjectWorkspaceConfig { - return formatValidationError(path, 'project', input) as ProjectWorkspaceConfig; + return formatValidationError( + path, + 'project', + input, + ) as ProjectWorkspaceConfig; } export function validateUserWorkspaceConfig( @@ -107,6 +116,28 @@ export async function parseWorkspaceConfigForEdit( return input as WorkspaceConfig; } +async function loadUserWorkspaceConfigForEdit(path: string): Promise<{ + input: EditableUserWorkspaceConfig; + validated: UserWorkspaceConfig; +}> { + const input = await loadConfigFile(path); + const validated = validateUserWorkspaceConfig(input, path); + return { + input: input as EditableUserWorkspaceConfig, + validated, + }; +} + +/** + * Validate a user workspace for mutation while preserving its raw field + * omissions. Callers must validate the whole document again before writing. + */ +export async function parseUserWorkspaceConfigDocumentForEdit( + path: string, +): Promise { + return (await loadUserWorkspaceConfigForEdit(path)).input; +} + /** * Validate a user workspace before mutation without materializing profile * defaults or dropping unrelated top-level fields. Profiles-only workspaces @@ -114,12 +145,10 @@ export async function parseWorkspaceConfigForEdit( */ export async function parseUserWorkspaceConfigForEdit( path: string, -): Promise { - const input = await loadConfigFile(path); - const validated = validateUserWorkspaceConfig(input); - const config = input as Record; - config.repositories ??= validated.repositories; - config.plugins ??= validated.plugins; - config.clients ??= validated.clients; - return config as WorkspaceConfig; +): Promise { + const { input, validated } = await loadUserWorkspaceConfigForEdit(path); + input.repositories ??= validated.repositories; + input.plugins ??= validated.plugins; + input.clients ??= validated.clients; + return input as UserWorkspaceConfig; } diff --git a/tests/e2e/mcp-add-proxy.test.ts b/tests/e2e/mcp-add-proxy.test.ts index 6634b8fd..829dcb7d 100644 --- a/tests/e2e/mcp-add-proxy.test.ts +++ b/tests/e2e/mcp-add-proxy.test.ts @@ -21,17 +21,22 @@ async function runCli( workdir: string, homeDir: string, args: string[], + json = true, ): Promise { const cliEntry = join(import.meta.dir, '..', '..', 'src', 'cli', 'index.ts'); - const proc = Bun.spawn(['bun', 'run', cliEntry, '--json', ...args], { + const proc = Bun.spawn( + ['bun', 'run', cliEntry, ...(json ? ['--json'] : []), ...args], + { cwd: workdir, env: { ...process.env, HOME: homeDir, + ALLAGENTS_TEST_HOME: homeDir, }, stderr: 'pipe', stdout: 'pipe', - }); + }, + ); const [exitCode, stdout, stderr] = await Promise.all([ proc.exited, new Response(proc.stdout).text(), @@ -97,7 +102,6 @@ clients: deepwiki: { type: 'http', url: dummy.mcpUrl }, }); expect(workspace.mcpProxy).toEqual({ - clients: [], servers: { deepwiki: { proxy: ['*'], @@ -145,7 +149,7 @@ clients: expect(rerunPayload.data.mcpResults.codex.added).toBe(0); expect(rerunPayload.data.mcpResults.vscode.added).toBe(0); expect(rerunPayload.data.mcpResults.copilot.added).toBe(0); - }); + }, 15_000); test('scopes proxying to selected clients with --client', async () => { writeFileSync( @@ -180,7 +184,6 @@ clients: }, }); expect(workspace.mcpProxy).toEqual({ - clients: [], servers: { 'secure-api': { proxy: ['claude', 'codex'], @@ -193,6 +196,209 @@ clients: expect(existsSync(join(workspaceDir, '.vscode', 'mcp.json'))).toBe(false); }); + test('accepts repeatable and comma-compatible client selectors', async () => { + writeFileSync( + join(workspaceDir, '.allagents', 'workspace.yaml'), + `repositories: [] +plugins: [] +clients: + - claude + - codex + - copilot +`, + 'utf-8', + ); + + const result = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'add', + 'local', + 'local-mcp', + '--client', + 'claude,codex', + '--client', + 'copilot', + '--client', + 'codex', + ]); + + expect(result.exitCode).toBe(0); + expect(readWorkspaceConfig(workspaceDir).mcpServers).toEqual({ + local: { + type: 'stdio', + command: 'local-mcp', + clients: ['claude', 'codex', 'copilot'], + }, + }); + }); + + + test('rejects empty client segments before mutation', async () => { + writeFileSync( + join(workspaceDir, '.allagents', 'workspace.yaml'), + 'repositories: []\nplugins: []\nclients:\n - codex\n', + 'utf8', + ); + + const result = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'add', + 'local', + 'local-mcp', + '--client', + 'codex,', + ]); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).error).toContain('empty segments'); + expect(readWorkspaceConfig(workspaceDir).mcpServers).toBeUndefined(); + }); + + test('routes ordinary user declarations and output with --scope user', async () => { + writeFileSync( + join(workspaceDir, '.allagents', 'workspace.yaml'), + 'repositories: []\nplugins: []\nclients: []\n', + 'utf-8', + ); + mkdirSync(join(homeDir, '.allagents'), { recursive: true }); + writeFileSync( + join(homeDir, '.allagents', 'workspace.yaml'), + 'repositories: []\nplugins: []\nclients:\n - copilot\n', + 'utf-8', + ); + + const result = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'add', + 'local', + 'local-mcp', + '--scope', + 'user', + ]); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.data.destination).toEqual({ kind: 'user' }); + expect( + readWorkspaceConfig(workspaceDir).mcpServers, + ).toBeUndefined(); + const userConfig = load( + readFileSync(join(homeDir, '.allagents', 'workspace.yaml'), 'utf8'), + ) as Record; + expect(userConfig.mcpServers).toEqual({ + local: { type: 'stdio', command: 'local-mcp' }, + }); + expect( + JSON.parse( + readFileSync(join(homeDir, '.copilot', 'mcp-config.json'), 'utf8'), + ).mcpServers.local, + ).toEqual({ + type: 'stdio', + command: 'local-mcp', + }); + }); + + test('manages a declared profile without implicitly installing it', async () => { + mkdirSync(join(homeDir, '.allagents'), { recursive: true }); + writeFileSync( + join(homeDir, '.allagents', 'workspace.yaml'), + `profiles: + markets: + clients: + - name: codex + - name: copilot +`, + 'utf-8', + ); + + const add = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'add', + 'local', + 'local-mcp', + '--profile', + 'markets', + '--client', + 'codex', + '--client', + 'copilot', + ]); + expect(add.exitCode).toBe(0); + const addPayload = JSON.parse(add.stdout); + expect(addPayload.data.destination).toEqual({ + kind: 'profile', + name: 'markets', + }); + expect(addPayload.data.sync).toEqual({ status: 'not-installed' }); + + const list = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'list', + '--profile', + 'markets', + ]); + expect(list.exitCode).toBe(0); + expect(JSON.parse(list.stdout).data.servers).toEqual({ + local: { + type: 'stdio', + command: 'local-mcp', + clients: ['codex', 'copilot'], + }, + }); + + const get = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'get', + 'local', + '--profile', + 'markets', + ]); + expect(get.exitCode).toBe(0); + expect(JSON.parse(get.stdout).data.destination).toEqual({ + kind: 'profile', + name: 'markets', + }); + + const remove = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'remove', + 'local', + '--profile', + 'markets', + ]); + expect(remove.exitCode).toBe(0); + const userConfig = load( + readFileSync(join(homeDir, '.allagents', 'workspace.yaml'), 'utf8'), + ) as { + profiles: Record>; + }; + expect(userConfig.profiles.markets.mcpServers).toBeUndefined(); + }, 15_000); + + test('rejects combining --scope and --profile before mutation', async () => { + writeFileSync( + join(workspaceDir, '.allagents', 'workspace.yaml'), + 'repositories: []\nplugins: []\nclients: []\n', + 'utf8', + ); + const result = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'add', + 'local', + 'local-mcp', + '--scope', + 'user', + '--profile', + 'markets', + ]); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).error).toContain( + '--scope and --profile cannot be used together', + ); + expect(readWorkspaceConfig(workspaceDir).mcpServers).toBeUndefined(); + }); + test('fails before mutation when a non-interactive HTTP preflight cannot connect', async () => { writeFileSync( join(workspaceDir, '.allagents', 'workspace.yaml'), @@ -216,6 +422,140 @@ clients: expect(readWorkspaceConfig(workspaceDir).mcpServers).toBeUndefined(); }); + test('redacts credential values from list and get JSON output', async () => { + const userConfigPath = join(homeDir, '.allagents', 'workspace.yaml'); + mkdirSync(join(homeDir, '.allagents'), { recursive: true }); + writeFileSync( + userConfigPath, + `repositories: [] +plugins: [] +clients: [] +mcpServers: + secure-http: + url: https://user:password@example.com/mcp?accessToken=camel-query-secret&apiKey=api-query-secret&key=generic-query-secret + headers: + Authorization: Bearer header-secret + secure-stdio: + command: local-mcp + args: + - --token + - arg-secret + - --apiKey=inline-secret + - https://example.com/callback?accessToken=arg-query-secret + - --key + - generic-arg-secret + - key=generic-inline-secret + env: + API_TOKEN: env-secret +`, + 'utf8', + ); + + const list = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'list', + '--scope', + 'user', + ]); + expect(list.exitCode).toBe(0); + expect(list.stdout).not.toContain('header-secret'); + expect(list.stdout).not.toContain('env-secret'); + expect(list.stdout).not.toContain('camel-query-secret'); + expect(list.stdout).not.toContain('api-query-secret'); + expect(list.stdout).not.toContain('arg-secret'); + expect(list.stdout).not.toContain('inline-secret'); + expect(list.stdout).not.toContain('arg-query-secret'); + expect(list.stdout).not.toContain('generic-query-secret'); + expect(list.stdout).not.toContain('generic-arg-secret'); + expect(list.stdout).not.toContain('generic-inline-secret'); + const listPayload = JSON.parse(list.stdout); + expect(listPayload.data.servers['secure-http'].headers.Authorization).toBe( + '[REDACTED]', + ); + expect(listPayload.data.servers['secure-stdio'].env.API_TOKEN).toBe( + '[REDACTED]', + ); + expect(listPayload.data.servers['secure-stdio'].args).toEqual([ + '--token', + '[REDACTED]', + '--apiKey=[REDACTED]', + 'https://example.com/callback?accessToken=[REDACTED]', + '--key', + '[REDACTED]', + 'key=[REDACTED]', + ]); + + const get = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'get', + 'secure-http', + '--scope', + 'user', + ]); + expect(get.exitCode).toBe(0); + expect(get.stdout).not.toContain('header-secret'); + expect(get.stdout).not.toContain('password'); + expect(get.stdout).not.toContain('camel-query-secret'); + expect(get.stdout).not.toContain('api-query-secret'); + expect(get.stdout).not.toContain('generic-query-secret'); + expect(JSON.parse(get.stdout).data.config.headers.Authorization).toBe( + '[REDACTED]', + ); + + const humanList = await runCli( + workspaceDir, + homeDir, + ['mcp', 'list', '--scope', 'user'], + false, + ); + expect(humanList.exitCode).toBe(0); + expect(humanList.stdout).toContain('[REDACTED]'); + expect(humanList.stdout).not.toContain('header-secret'); + expect(humanList.stdout).not.toContain('env-secret'); + expect(humanList.stdout).not.toContain('camel-query-secret'); + expect(humanList.stdout).not.toContain('api-query-secret'); + expect(humanList.stdout).not.toContain('arg-secret'); + expect(humanList.stdout).not.toContain('inline-secret'); + expect(humanList.stdout).not.toContain('arg-query-secret'); + expect(humanList.stdout).not.toContain('generic-query-secret'); + expect(humanList.stdout).not.toContain('generic-arg-secret'); + expect(humanList.stdout).not.toContain('generic-inline-secret'); + }); + + test('rejects invalid profile server names before HTTP preflight or persistence', async () => { + const userConfigPath = join(homeDir, '.allagents', 'workspace.yaml'); + mkdirSync(join(homeDir, '.allagents'), { recursive: true }); + writeFileSync( + userConfigPath, + `repositories: [] +plugins: [] +clients: [] +profiles: + markets: + clients: + - name: codex +`, + 'utf8', + ); + + const result = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'add', + 'invalid/name', + dummy.mcpUrl, + '--profile', + 'markets', + ]); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).error).toContain('Expected 1-100 ASCII'); + expect(dummy.mcpRequestHeaders).toHaveLength(0); + const userConfig = load(readFileSync(userConfigPath, 'utf8')) as { + profiles: Record>; + }; + expect(userConfig.profiles.markets.mcpServers).toBeUndefined(); + }); + test('returns a structured error for malformed workspace config', async () => { writeFileSync( join(workspaceDir, '.allagents', 'workspace.yaml'), diff --git a/tests/e2e/mcp-proxy-oauth.test.ts b/tests/e2e/mcp-proxy-oauth.test.ts index 6126f3ea..baa25b0e 100644 --- a/tests/e2e/mcp-proxy-oauth.test.ts +++ b/tests/e2e/mcp-proxy-oauth.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { connectHttpMcpServer, + getMcpOAuthCacheDir, hashServerUrl, } from '../../src/core/mcp-http-stdio-proxy.ts'; import { @@ -190,6 +191,62 @@ describe('mcp proxy OAuth e2e', () => { expect(dummy.authorizeCallCount).toBe(2); }, 15000); + test('isolates OAuth reuse and reset between profiles and ordinary scope', async () => { + dummy = await startDummyMcpOAuthServer(); + const authorize = async ({ authorizationUrl }: { authorizationUrl: URL }) => { + const response = await fetch(authorizationUrl, { redirect: 'manual' }); + const location = response.headers.get('location'); + expect(location).toBeTruthy(); + return new URL(location!, authorizationUrl).toString(); + }; + + await connectHttpMcpServer(dummy.mcpUrl, { + profile: 'markets', + callbackUrlReader: authorize, + }); + await connectHttpMcpServer(dummy.mcpUrl, { + profile: 'markets', + callbackUrlReader: authorize, + }); + expect(dummy.authorizeCallCount).toBe(1); + + await connectHttpMcpServer(dummy.mcpUrl, { + profile: 'research', + callbackUrlReader: authorize, + }); + await connectHttpMcpServer(dummy.mcpUrl, { + callbackUrlReader: authorize, + }); + expect(dummy.authorizeCallCount).toBe(3); + expect( + readFileSync( + join(getMcpOAuthCacheDir(dummy.mcpUrl, 'markets'), 'tokens.json'), + 'utf8', + ), + ).toContain('access_token'); + expect( + readFileSync( + join(getMcpOAuthCacheDir(dummy.mcpUrl, 'research'), 'tokens.json'), + 'utf8', + ), + ).toContain('access_token'); + expect( + readFileSync(join(getMcpOAuthCacheDir(dummy.mcpUrl), 'tokens.json'), 'utf8'), + ).toContain('access_token'); + + await connectHttpMcpServer(dummy.mcpUrl, { + profile: 'markets', + callbackUrlReader: authorize, + resetCredentials: true, + }); + expect(dummy.authorizeCallCount).toBe(4); + await connectHttpMcpServer(dummy.mcpUrl, { + profile: 'research', + callbackUrlReader: authorize, + }); + expect(dummy.authorizeCallCount).toBe(4); + }, 20000); + test('fails without prompting when authorization is disabled', async () => { dummy = await startDummyMcpOAuthServer(); @@ -201,6 +258,29 @@ describe('mcp proxy OAuth e2e', () => { expect(dummy.authorizeCallCount).toBe(0); }, 15000); + test('resolves header environment references only at connection time', async () => { + dummy = await startDummyMcpOAuthServer({ requireAuth: false }); + const originalToken = process.env.TRADINGVIEW_TOKEN; + process.env.TRADINGVIEW_TOKEN = 'runtime-secret'; + try { + await connectHttpMcpServer(dummy.mcpUrl, { + headers: { Authorization: '${TRADINGVIEW_TOKEN}' }, + allowAuthorization: false, + }); + expect( + dummy.mcpRequestHeaders.some( + (headers) => headers.authorization === 'runtime-secret', + ), + ).toBe(true); + } finally { + if (originalToken === undefined) { + delete process.env.TRADINGVIEW_TOKEN; + } else { + process.env.TRADINGVIEW_TOKEN = originalToken; + } + } + }, 15000); + test('reuses the cached token on a second connection without re-authorizing', async () => { dummy = await startDummyMcpOAuthServer(); diff --git a/tests/unit/cli/agent-help.test.ts b/tests/unit/cli/agent-help.test.ts index 192c8473..a1fa347b 100644 --- a/tests/unit/cli/agent-help.test.ts +++ b/tests/unit/cli/agent-help.test.ts @@ -183,6 +183,16 @@ describe('findMetaByCommand', () => { const reauthMeta = findMetaByCommand('mcp reauth tradingview'); expect(addMeta?.command).toBe('mcp add'); expect(reauthMeta?.command).toBe('mcp reauth'); + expect(addMeta?.interaction).toBe('conditional'); + expect(addMeta?.outputSchema).toBeDefined(); + expect(reauthMeta?.interaction).toBe('required'); + expect(findMetaByCommand('mcp list')?.outputSchema).toMatchObject({ + total: 'number', + }); + expect(findMetaByCommand('mcp get')?.outputSchema).toMatchObject({ + name: 'string', + }); + expect(findMetaByCommand('mcp update')?.outputSchema).toBeDefined(); }); test('resolves deprecated "workspace status" alias to status meta', () => { diff --git a/tests/unit/core/claude-mcp.test.ts b/tests/unit/core/claude-mcp.test.ts index 99d1ebed..d16e06cd 100644 --- a/tests/unit/core/claude-mcp.test.ts +++ b/tests/unit/core/claude-mcp.test.ts @@ -310,7 +310,83 @@ describe('syncClaudeMcpServersViaCli (user-scoped via CLI)', () => { expect(removeCall!.args).toContain('user'); }); - test('warns when claude CLI is not available', async () => { + test('retains ownership when removing an orphan fails', async () => { + const { fn } = mockExec({ + 'claude --version': { success: true, output: '1.0.0' }, + 'claude mcp get old-server': { + success: true, + output: 'old-server: ...', + }, + 'claude mcp remove': { + success: false, + output: '', + error: 'temporary failure', + }, + }); + + const result = await syncClaudeMcpServersViaCli([], { + trackedServers: ['old-server'], + _mockExecute: fn, + }); + + expect(result.authoritative).toBe(false); + expect(result.trackedServers).toEqual(['old-server']); + expect(result.removed).toBe(0); + }); + + + test('retains ownership when orphan inspection fails transiently', async () => { + const { fn } = mockExec({ + 'claude --version': { success: true, output: '1.0.0' }, + 'claude mcp get old-server': { + success: false, + output: '', + error: 'permission denied', + }, + }); + + const result = await syncClaudeMcpServersViaCli([], { + trackedServers: ['old-server'], + _mockExecute: fn, + }); + + expect(result.authoritative).toBe(false); + expect(result.trackedServers).toEqual(['old-server']); + expect(result.warnings[0]).toContain('Failed to inspect'); + }); + + test('only treats the exact Claude server-missing response as absence', async () => { + const misleading = mockExec({ + 'claude --version': { success: true, output: '1.0.0' }, + 'claude mcp get old-server': { + success: false, + output: '', + error: 'config file not found', + }, + }); + const incomplete = await syncClaudeMcpServersViaCli([], { + trackedServers: ['old-server'], + _mockExecute: misleading.fn, + }); + expect(incomplete.authoritative).toBe(false); + expect(incomplete.trackedServers).toEqual(['old-server']); + + const missing = mockExec({ + 'claude --version': { success: true, output: '1.0.0' }, + 'claude mcp get old-server': { + success: false, + output: '', + error: 'No MCP server found with name: old-server', + }, + }); + const authoritative = await syncClaudeMcpServersViaCli([], { + trackedServers: ['old-server'], + _mockExecute: missing.fn, + }); + expect(authoritative.authoritative).toBe(true); + expect(authoritative.trackedServers).toEqual([]); + }); + test('retains ownership when the claude CLI is not available', async () => { writeFileSync( join(pluginDir, '.mcp.json'), JSON.stringify({ mcpServers: { deepwiki: { type: 'http', url: 'https://mcp.deepwiki.com/mcp' } } }), @@ -321,10 +397,13 @@ describe('syncClaudeMcpServersViaCli (user-scoped via CLI)', () => { }); const result = await syncClaudeMcpServersViaCli([makePlugin(pluginDir)], { + trackedServers: ['owned-server'], _mockExecute: fn, }); expect(result.added).toBe(0); + expect(result.authoritative).toBe(false); + expect(result.trackedServers).toEqual(['owned-server']); expect(result.warnings.length).toBeGreaterThan(0); expect(result.warnings[0]).toContain('Claude CLI not available'); }); diff --git a/tests/unit/core/codex-mcp.test.ts b/tests/unit/core/codex-mcp.test.ts index b3383382..5883ec39 100644 --- a/tests/unit/core/codex-mcp.test.ts +++ b/tests/unit/core/codex-mcp.test.ts @@ -169,15 +169,39 @@ describe('syncCodexMcpServers', () => { }; const result = await syncCodexMcpServers([makePlugin(pluginDir)], { + trackedServers: ['owned-server'], _mockExecute: mockExecute, }); expect(result.added).toBe(0); expect(result.removed).toBe(0); + expect(result.authoritative).toBe(false); + expect(result.trackedServers).toEqual(['owned-server']); expect(result.warnings.length).toBeGreaterThan(0); expect(result.warnings.some((w) => w.toLowerCase().includes('codex'))).toBe(true); }); + test('retains ownership when removing an orphan fails', async () => { + const mockExecute = (_binary: string, args: string[]): NativeCommandResult => { + if (args.includes('list')) { + return { + success: true, + output: JSON.stringify([{ name: 'old-server' }]), + }; + } + return { success: false, output: '', error: 'temporary failure' }; + }; + + const result = await syncCodexMcpServers([], { + trackedServers: ['old-server'], + _mockExecute: mockExecute, + }); + + expect(result.authoritative).toBe(false); + expect(result.trackedServers).toEqual(['old-server']); + expect(result.removed).toBe(0); + }); + test('skips codex CLI call when no plugins have MCP servers and nothing previously tracked', async () => { // Plugin has no .mcp.json let execCalled = false; diff --git a/tests/unit/core/mcp-http-stdio-proxy.test.ts b/tests/unit/core/mcp-http-stdio-proxy.test.ts index d4fa1a62..d7826d11 100644 --- a/tests/unit/core/mcp-http-stdio-proxy.test.ts +++ b/tests/unit/core/mcp-http-stdio-proxy.test.ts @@ -1,10 +1,40 @@ -import { describe, expect, test } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; import { getBrowserOpenCommands, + getMcpOAuthCacheDir, + hashServerUrl, parseOAuthCallbackUrl, validateOAuthCallbackUrl, + resolveMcpHeaderReferences, } from '../../../src/core/mcp-http-stdio-proxy.js'; +describe('resolveMcpHeaderReferences', () => { + test('resolves exact environment references and preserves literal values', () => { + expect( + resolveMcpHeaderReferences( + { + Authorization: '${TRADINGVIEW_TOKEN}', + 'X-Literal': 'public', + }, + { TRADINGVIEW_TOKEN: 'secret-token' }, + ), + ).toEqual({ + Authorization: 'secret-token', + 'X-Literal': 'public', + }); + }); + + test('rejects a missing referenced environment variable', () => { + expect(() => + resolveMcpHeaderReferences( + { Authorization: '${TRADINGVIEW_TOKEN}' }, + {}, + ), + ).toThrow("missing environment variable 'TRADINGVIEW_TOKEN'"); + }); +}); + describe('getBrowserOpenCommands', () => { test('uses explorer on Windows so OAuth URLs are not parsed by cmd', () => { const url = @@ -16,6 +46,50 @@ describe('getBrowserOpenCommands', () => { }); }); +describe('getMcpOAuthCacheDir', () => { + const originalHome = process.env.ALLAGENTS_TEST_HOME; + const home = '/tmp/allagents-mcp-oauth-home'; + const url = 'https://mcp.tradingview.com/mcp'; + + beforeEach(() => { + process.env.ALLAGENTS_TEST_HOME = home; + }); + + afterEach(() => { + if (originalHome === undefined) { + delete process.env.ALLAGENTS_TEST_HOME; + } else { + process.env.ALLAGENTS_TEST_HOME = originalHome; + } + }); + + test('shares ordinary project and user credentials by URL', () => { + expect(getMcpOAuthCacheDir(url)).toBe( + join(home, '.allagents', 'oauth-proxy', hashServerUrl(url)), + ); + }); + + test('isolates credentials inside each profile root', () => { + expect(getMcpOAuthCacheDir(url, 'markets')).toBe( + join( + home, + '.allagents', + 'profiles', + 'markets', + 'oauth-proxy', + hashServerUrl(url), + ), + ); + expect(getMcpOAuthCacheDir(url, 'research')).not.toBe( + getMcpOAuthCacheDir(url, 'markets'), + ); + }); + + test('rejects unsafe profile names before constructing a path', () => { + expect(() => getMcpOAuthCacheDir(url, '../outside')).toThrow(); + }); +}); + describe('parseOAuthCallbackUrl', () => { const redirectUrl = 'http://127.0.0.1:38421/callback'; const state = 'expected-state'; diff --git a/tests/unit/core/mcp-proxy.test.ts b/tests/unit/core/mcp-proxy.test.ts index 7e2a2116..c7ccf09c 100644 --- a/tests/unit/core/mcp-proxy.test.ts +++ b/tests/unit/core/mcp-proxy.test.ts @@ -82,6 +82,43 @@ describe('applyMcpProxy', () => { }); }); + test('includes the profile selector only for profile-owned bridges', () => { + const servers = new Map([ + ['tradingview', { url: 'https://mcp.tradingview.com/mcp' }], + ]); + const config: McpProxyConfig = { + clients: [], + servers: { tradingview: { proxy: ['codex'] } }, + }; + + expect( + applyMcpProxy(servers, 'codex', config, { profile: 'markets' }).get( + 'tradingview', + ), + ).toEqual({ + command: 'npx', + args: [ + '-y', + packageRef, + 'mcp', + 'proxy', + 'https://mcp.tradingview.com/mcp', + '--profile', + 'markets', + ], + }); + expect(applyMcpProxy(servers, 'codex', config).get('tradingview')).toEqual({ + command: 'npx', + args: [ + '-y', + packageRef, + 'mcp', + 'proxy', + 'https://mcp.tradingview.com/mcp', + ], + }); + }); + test('does not rewrite HTTP server for non-proxied client', () => { const servers = new Map([ ['deepwiki', { url: 'https://mcp.deepwiki.com/mcp' }], @@ -153,4 +190,40 @@ describe('applyMcpProxy', () => { ], }); }); + + test('keeps profile secrets as environment bindings in generated bridge args', () => { + const servers = new Map([ + [ + 'secure-api', + { + url: 'https://api.example.com/mcp', + headers: { Authorization: '${TRADINGVIEW_TOKEN}' }, + }, + ], + ]); + const config: McpProxyConfig = { + clients: [], + servers: { 'secure-api': { proxy: ['codex'] } }, + }; + + expect( + applyMcpProxy(servers, 'codex', config, { profile: 'markets' }).get( + 'secure-api', + ), + ).toEqual({ + command: 'npx', + args: [ + '-y', + packageRef, + 'mcp', + 'proxy', + 'https://api.example.com/mcp', + '--profile', + 'markets', + '--header-env', + 'Authorization=TRADINGVIEW_TOKEN', + ], + env: { TRADINGVIEW_TOKEN: '${TRADINGVIEW_TOKEN}' }, + }); + }); }); diff --git a/tests/unit/core/mcp-servers.test.ts b/tests/unit/core/mcp-servers.test.ts index 082d221a..a71c412f 100644 --- a/tests/unit/core/mcp-servers.test.ts +++ b/tests/unit/core/mcp-servers.test.ts @@ -1,17 +1,23 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { load } from 'js-yaml'; import { - addWorkspaceMcpServer, + addMcpServer, buildMcpServerConfigFromFlags, - clearWorkspaceMcpServerProxy, - getWorkspaceMcpServer, - listWorkspaceMcpServers, + getMcpServer, + listMcpServers, parseKeyValuePairs, - removeWorkspaceMcpServer, - setWorkspaceMcpServerProxy, + removeMcpServer, + resolveMcpDestination, + type McpDestination, } from '../../../src/core/mcp-servers.js'; function makeTempWorkspace(): string { @@ -35,220 +41,329 @@ function readWorkspace(dir: string): Record { >; } -describe('addWorkspaceMcpServer', () => { + +describe('destination-aware MCP declarations', () => { let dir: string; + let configPath: string; + beforeEach(() => { dir = makeTempWorkspace(); + configPath = join(dir, '.allagents', 'workspace.yaml'); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); - test('adds an http server', async () => { - const result = await addWorkspaceMcpServer( - 'deepwiki', - { type: 'http', url: 'https://mcp.deepwiki.com/mcp' }, - dir, - ); - expect(result.success).toBe(true); - - const cfg = readWorkspace(dir); - expect(cfg.mcpServers).toEqual({ - deepwiki: { type: 'http', url: 'https://mcp.deepwiki.com/mcp' }, - }); - }); - - test('rejects duplicate without force', async () => { - await addWorkspaceMcpServer('a', { command: 'x' }, dir); - const result = await addWorkspaceMcpServer('a', { command: 'y' }, dir); - expect(result.success).toBe(false); - expect(result.error).toContain('already exists'); + test('rejects simultaneous scope and profile selectors', () => { + expect(() => + resolveMcpDestination({ + cwd: dir, + scope: 'user', + profile: 'research', + }), + ).toThrow('--scope and --profile cannot be used together'); }); - test('replaces duplicate with force', async () => { - await addWorkspaceMcpServer('a', { command: 'x' }, dir); - const result = await addWorkspaceMcpServer('a', { command: 'y' }, dir, true); - expect(result.success).toBe(true); - const cfg = readWorkspace(dir); - expect((cfg.mcpServers as Record).a.command).toBe('y'); - }); - - test('rejects invalid config', async () => { - // Neither command nor url - const result = await addWorkspaceMcpServer( - 'bad', - { type: 'http' } as unknown as Parameters[1], - dir, - ); - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid MCP server config'); - }); - - test('preserves other workspace.yaml fields on add', async () => { - await addWorkspaceMcpServer('a', { command: 'x' }, dir); - const cfg = readWorkspace(dir); - expect(cfg.repositories).toEqual([]); - expect(cfg.plugins).toEqual([]); - expect(cfg.clients).toEqual(['claude']); + test('resolves an unflagged command from HOME to the user destination', () => { + const originalHome = process.env.ALLAGENTS_TEST_HOME; + process.env.ALLAGENTS_TEST_HOME = dir; + try { + expect(resolveMcpDestination({ cwd: dir })).toEqual({ + kind: 'user', + configPath: join(dir, '.allagents', 'workspace.yaml'), + }); + } finally { + if (originalHome === undefined) { + delete process.env.ALLAGENTS_TEST_HOME; + } else { + process.env.ALLAGENTS_TEST_HOME = originalHome; + } + } }); - test('persists dynamic server-scoped proxy intent without widening global proxy clients', async () => { - await addWorkspaceMcpServer( - 'wtgkb', - { type: 'http', url: 'https://knowledge.mcp.wtg.zone' }, - dir, - ); - - const result = await setWorkspaceMcpServerProxy('wtgkb', dir); - expect(result.success).toBe(true); - expect(result.proxyClients).toEqual(['*']); - - const cfg = readWorkspace(dir); - expect(cfg.mcpProxy).toEqual({ - clients: [], - servers: { - wtgkb: { proxy: ['*'] }, - }, - }); + test('rejects explicit project scope from HOME instead of aliasing user state', () => { + const originalHome = process.env.ALLAGENTS_TEST_HOME; + process.env.ALLAGENTS_TEST_HOME = dir; + try { + expect(() => + resolveMcpDestination({ cwd: dir, scope: 'project' }), + ).toThrow('--scope project cannot be used from the home directory'); + } finally { + if (originalHome === undefined) { + delete process.env.ALLAGENTS_TEST_HOME; + } else { + process.env.ALLAGENTS_TEST_HOME = originalHome; + } + } }); - test('preserves existing workspace-wide proxy defaults when adding server-scoped proxy intent', async () => { + test('mutates ordinary user declarations without changing profiles', async () => { writeFileSync( - join(dir, '.allagents', 'workspace.yaml'), + configPath, `repositories: [] plugins: [] clients: - - claude - codex -mcpProxy: - clients: - - codex +profiles: + research: + clients: + - name: copilot `, 'utf-8', ); - - await addWorkspaceMcpServer( - 'wtgkb', - { type: 'http', url: 'https://knowledge.mcp.wtg.zone' }, - dir, + const destination: McpDestination = { + kind: 'user', + configPath, + }; + + const result = await addMcpServer( + destination, + 'remote', + { type: 'http', url: 'https://mcp.example' }, + { proxy: { clients: ['codex'] } }, ); - const result = await setWorkspaceMcpServerProxy('wtgkb', dir, ['claude']); - expect(result.success).toBe(true); - const cfg = readWorkspace(dir); - expect(cfg.mcpProxy).toEqual({ - clients: ['codex'], - servers: { - wtgkb: { proxy: ['claude'] }, + expect(result.success).toBe(true); + expect(await getMcpServer(destination, 'remote')).toEqual({ + type: 'http', + url: 'https://mcp.example', + }); + expect(await listMcpServers(destination)).toEqual({ + remote: { type: 'http', url: 'https://mcp.example' }, + }); + expect(readWorkspace(dir)).toMatchObject({ + profiles: { + research: { + clients: [{ name: 'copilot' }], + }, + }, + mcpProxy: { + servers: { + remote: { proxy: ['codex'] }, + }, }, }); }); - test('clears server-scoped proxy intent without removing workspace-wide defaults', async () => { + test('serializes concurrent declaration updates without losing either server', async () => { + const destination: McpDestination = { + kind: 'project', + workspacePath: dir, + configPath, + }; + + const [first, second] = await Promise.all([ + addMcpServer(destination, 'first', { command: 'first-mcp' }), + addMcpServer(destination, 'second', { command: 'second-mcp' }), + ]); + + expect(first.success).toBe(true); + expect(second.success).toBe(true); + expect(await listMcpServers(destination)).toEqual({ + first: { command: 'first-mcp' }, + second: { command: 'second-mcp' }, + }); + }); + + test('rejects a symbolic-link workspace config without replacing its target', async () => { + const targetPath = join(dir, 'workspace-target.yaml'); + const original = readFileSync(configPath, 'utf8'); + writeFileSync(targetPath, original, 'utf8'); + rmSync(configPath); + symlinkSync(targetPath, configPath); + const destination: McpDestination = { + kind: 'project', + workspacePath: dir, + configPath, + }; + + const result = await addMcpServer(destination, 'blocked', { + command: 'blocked-mcp', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('symbolic-link workspace config'); + expect(readFileSync(targetPath, 'utf8')).toBe(original); + }); + + test('atomically writes profile server and proxy policy', async () => { writeFileSync( - join(dir, '.allagents', 'workspace.yaml'), + configPath, `repositories: [] plugins: [] -clients: - - claude - - codex -mcpProxy: - clients: - - codex +clients: [] +profiles: + markets: + clients: + - name: codex + - name: copilot `, 'utf-8', ); - - await addWorkspaceMcpServer( - 'wtgkb', - { type: 'http', url: 'https://knowledge.mcp.wtg.zone' }, - dir, + const destination: McpDestination = { + kind: 'profile', + name: 'markets', + configPath, + }; + + const result = await addMcpServer( + destination, + 'tradingview', + { + type: 'http', + url: 'https://mcp.tradingview.com/mcp', + clients: ['codex', 'copilot'], + }, + { proxy: { clients: ['codex', 'copilot'] } }, ); - await setWorkspaceMcpServerProxy('wtgkb', dir, ['claude']); - const result = await clearWorkspaceMcpServerProxy('wtgkb', dir); expect(result.success).toBe(true); - - const cfg = readWorkspace(dir); - expect(cfg.mcpProxy).toEqual({ - clients: ['codex'], + expect(readWorkspace(dir)).toMatchObject({ + profiles: { + markets: { + mcpServers: { + tradingview: { + type: 'http', + url: 'https://mcp.tradingview.com/mcp', + clients: ['codex', 'copilot'], + }, + }, + mcpProxy: { + servers: { + tradingview: { proxy: ['codex', 'copilot'] }, + }, + }, + }, + }, }); }); -}); - -describe('removeWorkspaceMcpServer', () => { - let dir: string; - beforeEach(() => { - dir = makeTempWorkspace(); - }); - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - }); - test('removes an existing server', async () => { - await addWorkspaceMcpServer('a', { command: 'x' }, dir); - const result = await removeWorkspaceMcpServer('a', dir); - expect(result.success).toBe(true); - const cfg = readWorkspace(dir); - expect(cfg.mcpServers).toBeUndefined(); - }); + test('rejects invalid profile selectors without partially writing', async () => { + writeFileSync( + configPath, + `profiles: + markets: + clients: + - name: codex +`, + 'utf-8', + ); + const before = readFileSync(configPath, 'utf-8'); + const destination: McpDestination = { + kind: 'profile', + name: 'markets', + configPath, + }; + + const result = await addMcpServer( + destination, + 'remote', + { + type: 'http', + url: 'https://mcp.example', + clients: ['copilot'], + }, + { proxy: { clients: ['copilot'] } }, + ); - test('fails when server does not exist', async () => { - const result = await removeWorkspaceMcpServer('nonexistent', dir); expect(result.success).toBe(false); - expect(result.error).toContain('not found'); + expect(result.error).toContain('not declared by this profile'); + expect(readFileSync(configPath, 'utf-8')).toBe(before); }); - test('removes server-scoped proxy intent when removing a server', async () => { - await addWorkspaceMcpServer( - 'wtgkb', - { type: 'http', url: 'https://knowledge.mcp.wtg.zone' }, - dir, + test('rejects an invalid profile server name before writing', async () => { + writeFileSync( + configPath, + `profiles: + markets: + clients: + - name: codex +`, + 'utf-8', + ); + const before = readFileSync(configPath, 'utf-8'); + const result = await addMcpServer( + { kind: 'profile', name: 'markets', configPath }, + 'invalid/name', + { command: 'local-mcp' }, + { proxy: false }, ); - await setWorkspaceMcpServerProxy('wtgkb', dir, ['claude']); - - const result = await removeWorkspaceMcpServer('wtgkb', dir); - expect(result.success).toBe(true); - - const cfg = readWorkspace(dir); - expect(cfg.mcpProxy).toBeUndefined(); - }); -}); -describe('getWorkspaceMcpServer / listWorkspaceMcpServers', () => { - let dir: string; - beforeEach(() => { - dir = makeTempWorkspace(); - }); - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); + expect(result.success).toBe(false); + expect(result.error).toContain('Expected 1-100 ASCII'); + expect(readFileSync(configPath, 'utf-8')).toBe(before); }); - test('get returns null for missing server', async () => { - const cfg = await getWorkspaceMcpServer('missing', dir); - expect(cfg).toBeNull(); - }); + test('rejects an undeclared profile destination', async () => { + const result = await addMcpServer( + { kind: 'profile', name: 'missing', configPath }, + 'remote', + { command: 'local-mcp' }, + { proxy: false }, + ); - test('get returns server config', async () => { - await addWorkspaceMcpServer('a', { command: 'x' }, dir); - const cfg = await getWorkspaceMcpServer('a', dir); - expect(cfg).toEqual({ command: 'x' } as unknown as typeof cfg); + expect(result.success).toBe(false); + expect(result.error).toContain("Profile 'missing' is not declared"); }); - test('list returns all servers', async () => { - await addWorkspaceMcpServer('a', { command: 'x' }, dir); - await addWorkspaceMcpServer( - 'b', - { type: 'http', url: 'https://b.test' }, - dir, + test('removes only the selected profile declaration and proxy policy', async () => { + writeFileSync( + configPath, + `profiles: + markets: + clients: + - name: codex + mcpServers: + remote: + url: https://mcp.example + mcpProxy: + servers: + remote: + proxy: + - codex + research: + clients: + - name: copilot + mcpServers: + keep: + command: keep-mcp +`, + 'utf-8', ); - const servers = await listWorkspaceMcpServers(dir); - expect(Object.keys(servers).sort()).toEqual(['a', 'b']); - }); + const destination: McpDestination = { + kind: 'profile', + name: 'markets', + configPath, + }; + + const result = await removeMcpServer(destination, 'remote'); - test('list returns empty object when none defined', async () => { - const servers = await listWorkspaceMcpServers(dir); - expect(servers).toEqual({}); + expect(result.success).toBe(true); + expect(readWorkspace(dir)).toMatchObject({ + profiles: { + markets: { + clients: [{ name: 'codex' }], + }, + research: { + mcpServers: { + keep: { command: 'keep-mcp' }, + }, + }, + }, + }); + expect( + ( + (readWorkspace(dir).profiles as Record>) + .markets + ).mcpServers, + ).toBeUndefined(); + expect( + ( + (readWorkspace(dir).profiles as Record>) + .markets + ).mcpProxy, + ).toBeUndefined(); }); }); diff --git a/tests/unit/core/mcp-sync-user.test.ts b/tests/unit/core/mcp-sync-user.test.ts new file mode 100644 index 00000000..6abce8f0 --- /dev/null +++ b/tests/unit/core/mcp-sync-user.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { dump, load } from 'js-yaml'; +import { WORKSPACE_CONFIG_FILE } from '../../../src/constants.js'; +import { getCopilotMcpConfigPath } from '../../../src/core/copilot-mcp.js'; +import { syncUserMcpOnly } from '../../../src/core/mcp-sync.js'; +import { getSyncStatePath } from '../../../src/core/sync-state.js'; +import { getVscodeMcpConfigPath } from '../../../src/core/vscode-mcp.js'; +import { stubHomeDir } from '../../helpers/env.js'; + +describe('syncUserMcpOnly', () => { + let home: string; + let restoreHome: () => void; + let configPath: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'allagents-mcp-user-sync-')); + restoreHome = stubHomeDir(home); + configPath = join(home, '.allagents', WORKSPACE_CONFIG_FILE); + }); + + afterEach(async () => { + restoreHome(); + await rm(home, { recursive: true, force: true }); + }); + + async function writeUserConfig(config: Record): Promise { + await mkdir(dirname(configPath), { recursive: true }); + await writeFile(configPath, dump(config, { lineWidth: -1 }), 'utf8'); + } + + test('reconciles only ordinary user MCP destinations and preserves profiles', async () => { + const config = { + repositories: [], + plugins: [], + clients: ['vscode', 'copilot'], + mcpServers: { + tradingview: { + type: 'http', + url: 'https://mcp.tradingview.com/mcp', + }, + }, + profiles: { + markets: { + clients: [{ name: 'codex' }], + mcpServers: { + private: { command: 'private-mcp' }, + }, + }, + }, + }; + await writeUserConfig(config); + const statePath = getSyncStatePath(home); + await writeFile( + statePath, + `${JSON.stringify({ + version: 1, + lastSync: '2026-01-01T00:00:00.000Z', + files: { cursor: ['keep.md'] }, + mcpServers: { claude: ['keep'] }, + skillsIndex: ['keep-skill'], + unknownFutureField: { keep: true }, + })}\n`, + 'utf8', + ); + + const result = await syncUserMcpOnly({ offline: true }); + + expect(result.success).toBe(true); + expect(JSON.parse(await readFile(getVscodeMcpConfigPath(), 'utf8'))).toEqual({ + servers: { + tradingview: { + type: 'http', + url: 'https://mcp.tradingview.com/mcp', + }, + }, + }); + expect(JSON.parse(await readFile(getCopilotMcpConfigPath(), 'utf8'))).toEqual({ + mcpServers: { + tradingview: { + type: 'http', + url: 'https://mcp.tradingview.com/mcp', + }, + }, + }); + expect(existsSync(join(home, '.github', 'mcp.json'))).toBe(false); + + const writtenConfig = load(await readFile(configPath, 'utf8')) as typeof config; + expect(writtenConfig.profiles).toEqual(config.profiles); + const state = JSON.parse(await readFile(statePath, 'utf8')) as Record< + string, + unknown + >; + expect(state.files).toEqual({ cursor: ['keep.md'] }); + expect(state.skillsIndex).toEqual(['keep-skill']); + expect(state.unknownFutureField).toEqual({ keep: true }); + expect(state.mcpServers).toEqual({ + claude: ['keep'], + vscode: ['tradingview'], + copilot: ['tradingview'], + }); + }); + + test('returns an empty success when no user config exists', async () => { + const result = await syncUserMcpOnly({ offline: true }); + + expect(result).toEqual({ success: true, mcpResults: {}, warnings: [] }); + }); + + test('keeps dry-run free of destination and state writes', async () => { + await writeUserConfig({ + repositories: [], + plugins: [], + clients: ['copilot'], + mcpServers: { + local: { command: 'local-mcp' }, + }, + }); + + const result = await syncUserMcpOnly({ offline: true, dryRun: true }); + + expect(result.success).toBe(true); + expect(result.mcpResults.copilot?.added).toBe(1); + expect(existsSync(getCopilotMcpConfigPath())).toBe(false); + expect(existsSync(getSyncStatePath(home))).toBe(false); + }); +}); diff --git a/tests/unit/core/profile/codex.test.ts b/tests/unit/core/profile/codex.test.ts index bc624722..d4513043 100644 --- a/tests/unit/core/profile/codex.test.ts +++ b/tests/unit/core/profile/codex.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { join } from 'node:path'; import { CodexProfileAdapter } from '../../../../src/core/profile/adapters/codex.js'; +import packageJson from '../../../../package.json'; describe('Codex profile adapter', () => { it('isolates CODEX_HOME, selects an exact named config, and preserves cwd', () => { @@ -130,6 +131,21 @@ describe('Codex profile adapter', () => { args: ['server.js'], env: { LOCAL_TOKEN: '${LOCAL_TOKEN}' }, }, + bridge: { + command: 'npx', + args: [ + '-y', + 'allagents@1.15.0', + 'mcp', + 'proxy', + 'https://mcp.example.test', + '--profile', + 'review', + '--header-env', + 'Authorization=REMOTE_TOKEN', + ], + env: { REMOTE_TOKEN: '${REMOTE_TOKEN}' }, + }, remote: { url: 'https://mcp.example.test', headers: { @@ -149,6 +165,10 @@ describe('Codex profile adapter', () => { 'personality = "pragmatic"\n' + 'sandbox_mode = "workspace-write"\n' + 'web_search = "cached"\n\n' + + '[mcp_servers.bridge]\n' + + `args = ["-y", "allagents@${packageJson.version}", "mcp", "proxy", "https://mcp.example.test", "--profile", "review", "--header-env", "Authorization=REMOTE_TOKEN"]\n` + + 'command = "npx"\n' + + 'env_vars = ["REMOTE_TOKEN"]\n\n' + '[mcp_servers.local]\n' + 'args = ["server.js"]\n' + 'command = "node"\n' + diff --git a/tests/unit/core/profile/manager.test.ts b/tests/unit/core/profile/manager.test.ts index 636a53d6..186eaa61 100644 --- a/tests/unit/core/profile/manager.test.ts +++ b/tests/unit/core/profile/manager.test.ts @@ -1,9 +1,19 @@ import { afterEach, describe, expect, it, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; -import { mkdtemp, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { + mkdtemp, + mkdir, + readFile, + realpath, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { dump } from 'js-yaml'; +import packageJson from '../../../../package.json'; import { applyProfilePlan, getProfileStatus, @@ -31,6 +41,7 @@ import type { NativeResource, NativeResourceObservation, } from '../../../../src/core/native/types.js'; +import type { ClientType } from '../../../../src/models/workspace-config.js'; const roots: string[] = []; @@ -185,7 +196,7 @@ class MemoryProfileAdapter implements ProfileAdapter { runtimeChecks = 0; constructor( - readonly client: 'pi' | 'omp', + readonly client: ClientType, private readonly home: string, ) { this.nativeClient = new MemoryNativeClient(client); @@ -640,6 +651,98 @@ describe('profile lifecycle manager', () => { expect(await readFile(join(test.home, '.allagents', 'profiles', 'work', 'clients', 'pi', 'agent', 'mcp.json'), 'utf8')).toContain('docs-mcp'); }); + it('applies profile proxy policy after client selection', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + markets: { + clients: [{ name: 'codex' }, { name: 'omp' }], + plugins: [], + mcpServers: { + tradingview: { + url: 'https://mcp.tradingview.com/mcp', + headers: { Authorization: '${TRADINGVIEW_TOKEN}' }, + clients: ['codex'], + }, + excluded: { + url: 'https://mcp.example/excluded', + clients: ['omp'], + }, + }, + mcpProxy: { + servers: { + tradingview: { proxy: ['codex'] }, + excluded: { proxy: ['codex'] }, + }, + }, + }, + }); + const codex = new MemoryProfileAdapter('codex', test.home); + const omp = new MemoryProfileAdapter('omp', test.home); + const deps = dependencies(codex, omp); + + const plan = await planProfileOperation( + 'markets', + 'install', + test.options, + deps, + ); + const mcpStep = plan.steps.find( + (step) => step.kind === 'mcp' && step.client === 'codex', + ); + expect(mcpStep?.detail?.mcpServers).toEqual([ + { + name: 'tradingview', + transport: 'stdio', + command: { + command: 'npx', + args: [ + '-y', + `allagents@${packageJson.version}`, + 'mcp', + 'proxy', + 'https://mcp.tradingview.com/mcp', + '--profile', + 'markets', + '--header-env', + 'Authorization=TRADINGVIEW_TOKEN', + ], + }, + requestedSecrets: ['TRADINGVIEW_TOKEN'], + }, + ]); + + expect((await applyProfilePlan(plan, test.options, deps)).success).toBe(true); + const mcpPath = join( + test.home, + '.allagents', + 'profiles', + 'markets', + 'clients', + 'codex', + 'agent', + 'mcp.json', + ); + expect(JSON.parse(await readFile(mcpPath, 'utf8'))).toEqual({ + mcpServers: { + tradingview: { + command: 'npx', + args: [ + '-y', + `allagents@${packageJson.version}`, + 'mcp', + 'proxy', + 'https://mcp.tradingview.com/mcp', + '--profile', + 'markets', + '--header-env', + 'Authorization=TRADINGVIEW_TOKEN', + ], + env: { TRADINGVIEW_TOKEN: '${TRADINGVIEW_TOKEN}' }, + }, + }, + }); + }); + it('references a usable preexisting Pi MCP adapter without taking cleanup ownership', async () => { const test = await fixture(); await writeWorkspace(test.userConfigPath, { @@ -1089,6 +1192,116 @@ describe('profile lifecycle manager', () => { await expect(stat(clientRoot)).rejects.toThrow(); }); + it('removes profile-owned OAuth state while preserving unrelated residue', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [] }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const deps = dependencies(pi); + const install = await planProfileOperation( + 'work', + 'install', + test.options, + deps, + ); + expect((await applyProfilePlan(install, test.options, deps)).success).toBe( + true, + ); + const profileRoot = join(test.home, '.allagents', 'profiles', 'work'); + const oauthRoot = join(profileRoot, 'oauth-proxy'); + const residue = join(profileRoot, 'notes.txt'); + await mkdir(join(oauthRoot, 'server'), { recursive: true }); + await writeFile(join(oauthRoot, 'server', 'tokens.json'), '{}', 'utf8'); + await writeFile(residue, 'keep', 'utf8'); + + const removal = await planProfileOperation( + 'work', + 'remove', + test.options, + deps, + ); + const result = await applyProfilePlan(removal, test.options, deps); + + expect(result.status).toBe('removed'); + await expect(stat(oauthRoot)).rejects.toThrow(); + expect(await readFile(residue, 'utf8')).toBe('keep'); + await expect(stat(join(profileRoot, 'state.json'))).rejects.toThrow(); + }); + + it('removes OAuth state for a declared profile that was never installed', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [] }, + }); + const deps = dependencies(new MemoryProfileAdapter('pi', test.home)); + const profileRoot = join(test.home, '.allagents', 'profiles', 'work'); + const oauthRoot = join(profileRoot, 'oauth-proxy'); + await mkdir(join(oauthRoot, 'server'), { recursive: true }); + await writeFile(join(oauthRoot, 'server', 'tokens.json'), '{}', 'utf8'); + + const removal = await planProfileOperation( + 'work', + 'remove', + test.options, + deps, + ); + const result = await applyProfilePlan(removal, test.options, deps); + + expect(result.status).toBe('removed'); + await expect(stat(oauthRoot)).rejects.toThrow(); + await expect(stat(join(profileRoot, 'state.json'))).rejects.toThrow(); + }); + + it('rejects a symlinked OAuth root without deleting its target or removal state', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [] }, + }); + const deps = dependencies(new MemoryProfileAdapter('pi', test.home)); + const install = await planProfileOperation( + 'work', + 'install', + test.options, + deps, + ); + expect((await applyProfilePlan(install, test.options, deps)).success).toBe( + true, + ); + const profileRoot = join(test.home, '.allagents', 'profiles', 'work'); + const oauthRoot = join(profileRoot, 'oauth-proxy'); + const externalRoot = join(test.home, 'external-oauth'); + const externalToken = join(externalRoot, 'tokens.json'); + await mkdir(externalRoot, { recursive: true }); + await writeFile(externalToken, 'keep', 'utf8'); + await symlink(externalRoot, oauthRoot, 'dir'); + + const removal = await planProfileOperation( + 'work', + 'remove', + test.options, + deps, + ); + const result = await applyProfilePlan(removal, test.options, deps); + + expect(result.status).toBe('partial'); + expect(result.success).toBe(false); + expect(result.error).toContain('symbolic link'); + expect(await readFile(externalToken, 'utf8')).toBe('keep'); + expect((await stat(join(profileRoot, 'state.json'))).isFile()).toBe(true); + + await rm(oauthRoot, { force: true }); + const retry = await planProfileOperation( + 'work', + 'remove', + test.options, + deps, + ); + expect((await applyProfilePlan(retry, test.options, deps)).status).toBe( + 'removed', + ); + }); + it('inspects replaced managed roots for state-only clients', async () => { const test = await fixture(); await writeWorkspace(test.userConfigPath, { diff --git a/tests/unit/models/workspace-config-mcp-proxy.test.ts b/tests/unit/models/workspace-config-mcp-proxy.test.ts index fdf66959..776a1d8b 100644 --- a/tests/unit/models/workspace-config-mcp-proxy.test.ts +++ b/tests/unit/models/workspace-config-mcp-proxy.test.ts @@ -48,11 +48,17 @@ describe('mcpProxy workspace config', () => { expect(result.success).toBe(true); }); - test('rejects mcpProxy without clients', () => { + test('defaults clients to empty for server-only proxy policy', () => { const result = WorkspaceConfigSchema.safeParse({ ...baseConfig, mcpProxy: { servers: { 'my-api': { proxy: ['codex'] } } }, }); - expect(result.success).toBe(false); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.mcpProxy).toEqual({ + clients: [], + servers: { 'my-api': { proxy: ['codex'] } }, + }); + } }); }); diff --git a/tests/unit/models/workspace-config-profiles.test.ts b/tests/unit/models/workspace-config-profiles.test.ts index 6b0cf454..53cf98c4 100644 --- a/tests/unit/models/workspace-config-profiles.test.ts +++ b/tests/unit/models/workspace-config-profiles.test.ts @@ -425,6 +425,48 @@ describe('profile workspace declarations', () => { } }); + it('accepts destination-local profile proxy policy', () => { + const result = UserWorkspaceConfigSchema.parse( + userConfigWithProfile({ + clients: [{ name: 'codex' }, { name: 'copilot' }], + mcpServers: { + remote: { url: 'https://mcp.example', clients: ['codex', 'copilot'] }, + }, + mcpProxy: { + servers: { + remote: { proxy: ['codex', 'copilot'] }, + shared: { proxy: ['*'] }, + }, + }, + }), + ); + + expect(result.profiles?.research?.mcpProxy).toEqual({ + clients: [], + servers: { + remote: { proxy: ['codex', 'copilot'] }, + shared: { proxy: ['*'] }, + }, + }); + }); + + it('requires profile proxy selectors to be unique declared clients', () => { + for (const mcpProxy of [ + { clients: ['omp'] }, + { clients: ['codex', 'codex'] }, + { servers: { remote: { proxy: ['omp'] } } }, + { servers: { remote: { proxy: ['codex', 'codex'] } } }, + ]) { + const result = UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ + clients: [{ name: 'codex' }], + mcpProxy, + }), + ); + expect(result.success).toBe(false); + } + }); + it('requires exact portable secret references in profile MCP credentials', () => { for (const server of [ { command: 'local-mcp', env: { TOKEN: 'plaintext' } }, From ac1838dc47c9a31751a998ee0ae3c67a9f843045 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 19 Sep 2026 14:07:14 +1000 Subject: [PATCH 06/16] fix(ci): unlink profile OAuth symlink on Windows --- tests/unit/core/profile/manager.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/core/profile/manager.test.ts b/tests/unit/core/profile/manager.test.ts index 186eaa61..cf6a2541 100644 --- a/tests/unit/core/profile/manager.test.ts +++ b/tests/unit/core/profile/manager.test.ts @@ -8,6 +8,7 @@ import { rm, stat, symlink, + unlink, writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -1290,7 +1291,7 @@ describe('profile lifecycle manager', () => { expect(await readFile(externalToken, 'utf8')).toBe('keep'); expect((await stat(join(profileRoot, 'state.json'))).isFile()).toBe(true); - await rm(oauthRoot, { force: true }); + await unlink(oauthRoot); const retry = await planProfileOperation( 'work', 'remove', From ac83c3845759d488d8055bceb9075a41492df430 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 19 Sep 2026 14:28:39 +1000 Subject: [PATCH 07/16] chore(mcp): remove completed implementation plan --- .../plans/2026-09-19-scoped-mcp-profiles.md | 228 ------------------ 1 file changed, 228 deletions(-) delete mode 100644 .claude/plans/2026-09-19-scoped-mcp-profiles.md diff --git a/.claude/plans/2026-09-19-scoped-mcp-profiles.md b/.claude/plans/2026-09-19-scoped-mcp-profiles.md deleted file mode 100644 index 8c88b7ac..00000000 --- a/.claude/plans/2026-09-19-scoped-mcp-profiles.md +++ /dev/null @@ -1,228 +0,0 @@ -# Scoped MCP Destinations and Profile OAuth - -## Goal - -Make MCP declarations, synchronization, proxying, and OAuth state operate consistently across three explicit destinations: - -- project: default or `--scope project` -- ordinary user config: `--scope user` -- named profile: `--profile ` - -Profiles are first-class destinations, not client-name aliases. Project and user declarations continue to share ordinary OAuth credentials by URL; each profile owns an isolated credential cache under its profile root. - -## Product contract - -- `--scope` and `--profile` are mutually exclusive on every public MCP command. -- Omitting both selects the current project, except from the home directory where the project path aliases the ordinary user config and resolves to user scope. Explicit `--scope project` is rejected at that alias. -- A profile selector is singular and must name a declared profile. Profile MCP server names use `[A-Za-z0-9_.-]{1,100}`. -- `mcp add --client` is repeatable and remains comma-compatible. Values are trimmed, validated, deduplicated in first-seen order, and explicit empty segments are rejected. -- `mcp add` validates first, then persists the server declaration and server-local proxy intent in one atomically replaced document before synchronization. -- `mcp list` and `mcp get` read only inline declarations for the selected destination; they do not merge plugin-provided servers and redact header, environment, URL credential, and sensitive query values. -- `mcp reauth --profile ` removes and recreates only that profile's credentials. -- `mcp update --scope user` performs MCP-only reconciliation, not a full user workspace sync. -- Profile MCP reconciliation stays inside the profile plan/apply ownership model because Codex and OpenCode combine settings and MCP in one managed artifact. -- A declared but uninstalled profile may be edited, listed, or inspected without being implicitly installed. Synchronization is skipped with an explicit result. Installed profiles reconcile through the normal profile planner. -- Profile deletion removes the fixed profile-owned OAuth subtree before state deletion. Partial cleanup fails closed and remains retryable. -- Profile HTTP headers use exact `${ENV_VAR}` references. Generated bridges preserve only the environment binding and resolve its value at connection time. -- Local credential deletion does not revoke an authorization grant at the remote provider. - -## Configuration model - -Project and ordinary user destinations use top-level `mcpServers` and `mcpProxy`. - -Profiles use destination-local fields: - -```yaml -profiles: - markets: - clients: - - name: codex - - name: copilot - mcpServers: - tradingview: - type: http - url: https://mcp.tradingview.com/mcp - clients: [codex, copilot] - mcpProxy: - servers: - tradingview: - proxy: [codex, copilot] -``` - -`profiles..mcpProxy` reuses the ordinary proxy schema. Proxy client selectors must be `*` or a client declared by that profile. The global `mcpProxy.clients` list becomes optional with an empty default so server-local routing does not require `clients: []` noise. - -## Ownership and paths - -Generated client files remain destination-native: - -| Destination | Codex | Copilot | -| --- | --- | --- | -| project | `.codex/config.toml` | `.github/mcp.json` | -| user | `~/.codex/config.toml` | `~/.copilot/mcp-config.json` | -| profile | `~/.allagents/profiles//clients/codex/home/.config.toml` | `~/.allagents/profiles//clients/copilot/home/mcp-config.json` | - -OAuth cache ownership: - -- project/user: `~/.allagents/oauth-proxy//` -- profile: `~/.allagents/profiles//oauth-proxy//` - -Profile bridge commands include the hidden selector: - -```text -npx -y allagents@ mcp proxy --profile -``` - -Ordinary project/user bridge commands remain byte-for-byte unchanged and omit `--profile`. - -## Architecture - -Introduce one validated destination discriminant resolved at the CLI boundary: - -```ts -type McpDestination = - | { kind: 'project'; workspacePath: string; configPath: string } - | { kind: 'user'; configPath: string } - | { kind: 'profile'; name: ProfileName; configPath: string }; -``` - -Declaration access and mutation hide storage differences. A mutation acquires a destination-file lock, loads once with the scope-correct parser, selects the top-level or profile-local container, validates the destination-specific server and server-name schemas, updates the server and server-local proxy policy in one in-memory document, validates the entire document, and atomically replaces the destination file once. - -Synchronization stays separate: - -- project delegates to existing `syncMcpOnly`; -- user delegates to a new `syncUserMcpOnly`, extracted from the full user sync's existing adapter logic; -- installed profile delegates to profile plan/apply update; declared-only profile returns an explicit skipped reconciliation. - -Profile planning filters and normalizes a client's MCP declarations before applying its profile-local proxy policy. The effective map is then passed to both settings and MCP serializers so combined artifacts remain correct. - -## Implementation units - -### U1 — Destination schemas and atomic declarations - -**Files** -- `src/models/workspace-config.ts` -- `src/utils/workspace-parser.ts` -- `src/core/mcp-servers.ts` -- profile/schema/declaration tests - -**Change** -- Add optional/default-empty global proxy clients and profile-local `mcpProxy` validation. -- Add `McpDestination` resolution with scope/profile exclusion and profile-name validation. -- Generalize get/list/add/remove into destination-aware, atomic operations. -- Preserve unrelated user/profile YAML and remove obsolete project-only mutation APIs after callers migrate. - -**Proof** -- Tests for user/profile access, atomic server-plus-proxy writes, concurrent update serialization, symbolic-link rejection, invalid profile selectors, profile-not-found, preservation, and proxy pruning. - -### U2 — Profile-aware bridge and OAuth ownership - -**Files** -- `src/core/mcp-proxy.ts` -- `src/core/mcp-http-stdio-proxy.ts` -- `src/cli/commands/mcp.ts` hidden proxy path -- proxy/OAuth tests - -**Change** -- Add a validated optional profile scope to generated bridges and runtime OAuth resolution. -- Preserve profile header secrets as `--header-env
=` bindings and resolve them only at the connection boundary. -- Keep ordinary cache behavior unchanged and never persist resolved profile secret values. - -**Proof** -- Exact argv tests, ordinary/profile path tests, reset isolation, traversal rejection, OAuth E2E coverage. - -### U3 — Profile materialization and lifecycle cleanup - -**Files** -- `src/core/profile/plan.ts` -- `src/core/profile/manager.ts` -- `src/core/profile/files.ts` only if a shared safe-removal helper is needed -- profile planner/manager/adapter tests - -**Change** -- Filter each client’s servers before proxy transformation. -- Serialize effective proxied maps through existing adapters and report truthful disclosures. -- Remove only `/oauth-proxy` during profile teardown, including declared-only profiles, before state deletion; reject symlink/non-directory roots and retain state on failure. - -**Proof** -- Exact Codex/Copilot materialization, cross-profile isolation, removal cleanup, unrelated-file preservation, hostile symlink failure. - -### U4 — Ordinary user MCP-only synchronization - -**Files** -- `src/core/mcp-sync.ts` -- `src/core/sync.ts` -- sync/state tests - -**Change** -- Extract one reusable user MCP adapter orchestrator from full user sync. -- Add `syncUserMcpOnly` without plugin artifact/native/profile side effects. -- Preserve name-based ownership, unrelated sync state, and unattempted or failed client ownership. - -**Proof** -- User Codex/Copilot destinations, selector filtering, preservation of untracked entries and unrelated state, profiles ignored by ordinary sync, missing/invalid config behavior, dry-run behavior. - -### U5 — Public command routing - -**Files** -- `src/cli/commands/mcp.ts` -- `src/cli/metadata/mcp.ts` -- CLI/E2E tests - -**Change** -- Add shared destination flags to add/remove/list/get/reauth/update. -- Route declaration access, authentication, mutation, and reconciliation through one resolved destination. -- Parse repeatable plus CSV-compatible `--client` values strictly. -- Add stable destination information to JSON results and destination-aware human output. - -**Proof** -- Default project compatibility; explicit project/user/profile flows; scope/profile conflict; repeatable/mixed client forms; unknown/empty selector rejection before auth or mutation; profile reauth isolation. - -### U6 — Documentation and generated schema - -**Files** -- `README.md` -- `docs/src/content/docs/docs/guides/mcp-proxy.mdx` -- `docs/src/content/docs/docs/reference/cli.mdx` -- `docs/src/content/docs/docs/reference/configuration.mdx` -- generated workspace schemas - -**Change** -- Document destination semantics, repeatable clients, profile-local proxy configuration, exact file/cache paths, generated `--profile`, and local reset versus remote revocation. -- Remove stale `allagents update --scope` documentation. -- Regenerate the user workspace JSON schema. - -**Proof** -- Schema generator and docs build succeed; examples match executable command help. - -## Verification - -Focused red/green checks are run per unit. Final verification: - -```bash -bun run build -bun run typecheck -bun run lint -bun run docs:build -bun test -bun run test:e2e -``` - -Manual isolated-home E2E: - -1. Project add/update produces `.codex/config.toml` and `.github/mcp.json`. -2. User add/update produces `~/.codex/config.toml` and `~/.copilot/mcp-config.json` without running a full workspace sync. -3. Installed profile add/update produces the profile Codex/Copilot files with generated `--profile ` bridge arguments. -4. Two profiles targeting the same URL resolve different OAuth cache directories; project and user resolve the shared ordinary directory. -5. `mcp reauth --profile` removes only the selected profile credentials. -6. Profile removal deletes its OAuth subtree while preserving unrelated residue safely. -7. Real Codex and Copilot clients can invoke a TradingView MCP tool through the materialized bridge where installed credentials permit it. - -## Risks controlled - -- No profile-qualified global client IDs; policy remains destination-local. -- No direct profile file writes outside the ownership-aware planner. -- No full user sync from an MCP-only command. -- No profile-root recursive deletion; only the fixed OAuth subtree is lifecycle-owned. -- No selector widening from empty repeatable arguments. -- No cache-path construction from an unvalidated profile name. -- No separate declaration/proxy writes that can leave partial configuration. From 0388f618fc556240c785e72d035705f0de8ada6e Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 19 Sep 2026 15:59:26 +1000 Subject: [PATCH 08/16] feat(cli): compose structured help with --json --- CHANGELOG.md | 7 + docs/src/content/docs/docs/reference/cli.mdx | 16 ++ src/cli/agent-help.ts | 178 -------------- src/cli/index.ts | 41 ++-- src/cli/json-output.ts | 73 ++++-- src/cli/structured-help.ts | 223 ++++++++++++++++++ tests/e2e/mcp-proxy-command.test.ts | 166 ++++++++++++- tests/unit/cli/profile-command.test.ts | 10 +- ...t-help.test.ts => structured-help.test.ts} | 50 ++-- 9 files changed, 507 insertions(+), 257 deletions(-) delete mode 100644 src/cli/agent-help.ts create mode 100644 src/cli/structured-help.ts rename tests/unit/cli/{agent-help.test.ts => structured-help.test.ts} (81%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c3117c..a1f518cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ **Migration**: Remove `--proxy` from `mcp add` calls. Replace `allagents mcp auth ` with `allagents mcp reauth `. +- **Structured CLI help**: Replaced the agent-specific `--agent-help` flag with + composable `--help --json` output at the root, command-group, and individual + command levels. + + **Migration**: Replace `allagents --agent-help ` with + `allagents --help --json`. + - **Plugin Git ref terminology**: Renamed workspace plugin `pin` to `ref`, CLI `--pin` to `--ref`, and sync-state `pinnedRef` to `requestedRef`. Inline `owner/repo@ref` sources are unchanged. diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index 01ad1d94..531e00c3 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -3,6 +3,22 @@ title: CLI Commands description: Complete reference for AllAgents CLI commands. --- +## Structured Help + +Add `--json` to any help invocation to return machine-readable command +metadata instead of terminal-formatted help: + +```bash +allagents --help --json +allagents mcp --help --json +allagents mcp add --help --json +``` + +Structured help includes usage guidance, options, examples, output schemas, and +interaction requirements when available. `--help --json` and +`--json --help` are equivalent when they follow the command path. +Field selection with `--json=` is not supported for help output. + ## Top-Level Commands ```bash diff --git a/src/cli/agent-help.ts b/src/cli/agent-help.ts deleted file mode 100644 index 938ac548..00000000 --- a/src/cli/agent-help.ts +++ /dev/null @@ -1,178 +0,0 @@ -import type { AgentCommandMeta } from './help.js'; -import { normalizeSkillHelpArgs } from './skill-arg-normalizer.js'; -import { - mcpAddMeta, - mcpGetMeta, - mcpListMeta, - mcpReauthMeta, - mcpRemoveMeta, - mcpUpdateMeta, -} from './metadata/mcp.js'; - -import { - skillsAddMeta, - skillsListMeta, - skillsRemoveMeta, - skillsSearchMeta, - skillsUpdateMeta, -} from './metadata/plugin-skills.js'; -import { - marketplaceAddMeta, - marketplaceBrowseMeta, - marketplaceListMeta, - marketplaceRemoveMeta, - marketplaceUpdateMeta, - pluginInstallMeta, - pluginListMeta, - pluginUninstallMeta, - pluginUpdateMeta, - pluginValidateMeta, -} from './metadata/plugin.js'; -import { updateMeta } from './metadata/self.js'; -import { - profileInstallMeta, - profileListMeta, - profileRemoveMeta, - profileStatusMeta, -} from './metadata/profile.js'; -import { - initMeta, - setupMeta, - statusMeta, - syncMeta, -} from './metadata/workspace.js'; - -const allCommands: AgentCommandMeta[] = [ - initMeta, - setupMeta, - syncMeta, - statusMeta, - mcpAddMeta, - mcpReauthMeta, - mcpRemoveMeta, - mcpListMeta, - mcpGetMeta, - mcpUpdateMeta, - pluginInstallMeta, - pluginUninstallMeta, - pluginUpdateMeta, - marketplaceListMeta, - marketplaceAddMeta, - marketplaceRemoveMeta, - marketplaceUpdateMeta, - marketplaceBrowseMeta, - pluginListMeta, - pluginValidateMeta, - skillsListMeta, - skillsAddMeta, - skillsRemoveMeta, - skillsSearchMeta, - skillsUpdateMeta, - updateMeta, - profileInstallMeta, - profileListMeta, - profileStatusMeta, - profileRemoveMeta, -]; - -/** - * Strip --agent-help from args so cmd-ts doesn't see it. - */ -export function extractAgentHelpFlag(args: string[]): { args: string[]; agentHelp: boolean } { - const idx = args.indexOf('--agent-help'); - if (idx === -1) return { args, agentHelp: false }; - return { args: [...args.slice(0, idx), ...args.slice(idx + 1)], agentHelp: true }; -} - -function formatForAgent(meta: AgentCommandMeta) { - const result: Record = { - command: meta.command, - description: meta.description, - when_to_use: meta.whenToUse, - }; - if (meta.positionals && meta.positionals.length > 0) { - result.positionals = meta.positionals; - } - if (meta.options && meta.options.length > 0) { - result.options = meta.options; - } - result.examples = meta.examples; - if (meta.outputSchema) { - result.output_schema = meta.outputSchema; - } - if (meta.interaction) { - result.interaction = meta.interaction; - } - if (meta.jsonFields && meta.jsonFields.length > 0) { - result.json_fields = [...meta.jsonFields]; - } - return result; -} - -/** Maps deprecated command paths to their current canonical equivalents. */ -const commandAliases: Record = { - 'workspace status': 'status', -}; - -function resolveAlias(commandPath: string): string { - return commandAliases[commandPath] ?? commandPath; -} - -/** - * Look up metadata by a runtime command path (e.g. "skill update foo"). - * Resolves deprecated aliases (e.g. "workspace status" -> "status"). - * Used by index.ts to validate `--json=` against the per-command - * allowlist before dispatching. A longest-prefix match allows command metadata - * to resolve when positional arguments follow the command tokens. - */ -export function findMetaByCommand( - commandPath: string, -): AgentCommandMeta | undefined { - if (!commandPath) return undefined; - const resolved = resolveAlias(commandPath); - const exact = allCommands.find((command) => command.command === resolved); - if (exact) return exact; - - return allCommands - .filter((command) => resolved.startsWith(`${command.command} `)) - .sort((a, b) => b.command.length - a.command.length)[0]; -} - -export function printAgentHelp(args: string[], version: string): void { - // Determine which command is being asked about by looking at remaining args. - const positional = args.filter(a => !a.startsWith('-')); - const normalized = normalizeSkillHelpArgs(positional); - const commandPath = normalized.join(' '); - - if (!commandPath) { - // Full tree - const tree = { - name: 'allagents', - version, - description: 'CLI tool for managing multi-repo AI agent workspaces with plugin synchronization', - commands: allCommands.map(formatForAgent), - }; - console.log(JSON.stringify(tree, null, 2)); - } else { - const resolved = resolveAlias(commandPath); - // Find exact matching command - const match = allCommands.find(c => c.command === resolved); - if (match) { - console.log(JSON.stringify(formatForAgent(match), null, 2)); - } else { - // Try prefix match for subcommand groups - const matches = allCommands.filter(c => c.command.startsWith(`${resolved} `)); - if (matches.length > 0) { - const group = { - name: resolved, - commands: matches.map(formatForAgent), - }; - console.log(JSON.stringify(group, null, 2)); - } else { - console.error(`Unknown command: ${commandPath}`); - process.exit(1); - } - } - } - process.exit(0); -} diff --git a/src/cli/index.ts b/src/cli/index.ts index 80aa3687..d29516dd 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,33 +1,37 @@ #!/usr/bin/env node -import { run } from 'cmd-ts'; -import { conciseSubcommands } from './help.js'; -import { workspaceCmd, syncCmd, initCmd, statusCmd } from './commands/workspace.js'; -import { pluginCmd } from './commands/plugin.js'; +import { run, setDefaultHelpFormatter } from 'cmd-ts'; +import packageJson from '../../package.json'; import { mcpCmd } from './commands/mcp.js'; -import { selfCmd } from './commands/self.js'; +import { pluginCmd } from './commands/plugin.js'; import { skillsCmd } from './commands/plugin-skills.js'; import { profileCmd } from './commands/profile.js'; +import { selfCmd } from './commands/self.js'; +import { + initCmd, + statusCmd, + syncCmd, + workspaceCmd, +} from './commands/workspace.js'; +import { conciseSubcommands } from './help.js'; import { - extractJsonFlag, extractJqFlag, + extractJsonFlag, setJsonMode, validateJsonFields, } from './json-output.js'; +import { normalizeSkillArgs } from './skill-arg-normalizer.js'; import { - extractAgentHelpFlag, + createStructuredHelpFormatter, findMetaByCommand, - printAgentHelp, -} from './agent-help.js'; +} from './structured-help.js'; import { getUpdateNotice } from './update-check.js'; -import { normalizeSkillArgs, normalizeSkillHelpArgs } from './skill-arg-normalizer.js'; -import packageJson from '../../package.json'; const app = conciseSubcommands({ name: 'allagents', description: 'CLI tool for managing multi-repo AI agent workspaces with plugin synchronization\n\n' + - 'For AI agents: use --agent-help for machine-readable help, or --json for structured output', + 'Use --help --json for machine-readable command metadata, or --json for structured command output', version: packageJson.version, cmds: { init: initCmd, @@ -45,8 +49,7 @@ const app = conciseSubcommands({ const rawArgs = process.argv.slice(2); const { args: argsNoJson, json, jsonFields } = extractJsonFlag(rawArgs); const { args: argsNoJq, jqExpr } = extractJqFlag(argsNoJson); -const { args: argsAfterAgentHelp, agentHelp } = extractAgentHelpFlag(argsNoJq); -const finalArgs = normalizeSkillArgs(argsAfterAgentHelp); +const finalArgs = normalizeSkillArgs(argsNoJq); const commandPath = finalArgs.filter((arg) => !arg.startsWith('-')).join(' '); const commandMeta = findMetaByCommand(commandPath); @@ -68,17 +71,19 @@ setJsonMode(json, { ...(jqExpr && { jqExpr }), }); +if (json) { + setDefaultHelpFormatter(createStructuredHelpFormatter(packageJson.version)); +} + // Kick off the update check for ordinary non-JSON invocations unless the // resolved command metadata marks the command as strictly read-only. const isWizard = finalArgs.length === 0 && process.stdout.isTTY && !json; -if (!agentHelp && !json && !isWizard && !commandMeta?.skipUpdateCheck) { +if (!json && !isWizard && !commandMeta?.skipUpdateCheck) { const notice = await getUpdateNotice(packageJson.version); if (notice) process.stderr.write(`${notice}\n\n`); } -if (agentHelp) { - printAgentHelp(normalizeSkillHelpArgs(argsAfterAgentHelp), packageJson.version); -} else if (isWizard) { +if (isWizard) { // Interactive wizard when no args and running in a terminal const { runWizard } = await import('./tui/wizard.js'); await runWizard(); diff --git a/src/cli/json-output.ts b/src/cli/json-output.ts index 1c814a7a..65910cf2 100644 --- a/src/cli/json-output.ts +++ b/src/cli/json-output.ts @@ -9,7 +9,10 @@ export function isJsonMode(): boolean { return jsonMode; } -export function setJsonMode(value: boolean, options?: { fields?: string[]; jqExpr?: string }): void { +export function setJsonMode( + value: boolean, + options?: { fields?: string[]; jqExpr?: string }, +): void { jsonMode = value; jsonFields = options?.fields ?? null; jqExpr = options?.jqExpr ?? null; @@ -32,14 +35,21 @@ export interface JsonEnvelope { * narrows each item to the requested fields. Otherwise the filter is applied * to the top-level `data` object directly. */ -function applyFieldFilter(envelope: JsonEnvelope, fields: string[]): JsonEnvelope { +function applyFieldFilter( + envelope: JsonEnvelope, + fields: string[], +): JsonEnvelope { if (!envelope.data || typeof envelope.data !== 'object') return envelope; const data = envelope.data as Record; const keys = Object.keys(data); // Single top-level array of objects → filter each item. const arrayKey = keys.find( - (k) => Array.isArray(data[k]) && (data[k] as unknown[]).every((it) => it !== null && typeof it === 'object' && !Array.isArray(it)), + (k) => + Array.isArray(data[k]) && + (data[k] as unknown[]).every( + (it) => it !== null && typeof it === 'object' && !Array.isArray(it), + ), ); if (arrayKey && keys.length >= 1) { const items = data[arrayKey] as Array>; @@ -51,7 +61,10 @@ function applyFieldFilter(envelope: JsonEnvelope, fields: string[]): JsonEnvelop return { ...envelope, data: projectFields(data, fields) }; } -function projectFields(obj: Record, fields: string[]): Record { +function projectFields( + obj: Record, + fields: string[], +): Record { const out: Record = {}; for (const f of fields) { if (f in obj) out[f] = obj[f]; @@ -60,30 +73,38 @@ function projectFields(obj: Record, fields: string[]): Record 0) { - final = applyFieldFilter(envelope, jsonFields); - } - if (jqExpr) { - console.log(runJq(final, jqExpr)); - return; - } - console.log(JSON.stringify(final, null, 2)); + const final = + jsonFields && jsonFields.length > 0 + ? applyFieldFilter(envelope, jsonFields) + : envelope; + jsonValueOutput(final); } /** @@ -93,9 +114,11 @@ export function jsonOutput(envelope: JsonEnvelope): void { * `json` — boolean, true if the flag was present in either form. * `jsonFields` — comma-split field list when `--json=` was supplied. */ -export function extractJsonFlag( - args: string[], -): { args: string[]; json: boolean; jsonFields?: string[] } { +export function extractJsonFlag(args: string[]): { + args: string[]; + json: boolean; + jsonFields?: string[]; +} { const out: string[] = []; let json = false; let fields: string[] | undefined; @@ -109,7 +132,10 @@ export function extractJsonFlag( json = true; const value = a.slice('--json='.length); if (value.length > 0) { - fields = value.split(',').map((s) => s.trim()).filter(Boolean); + fields = value + .split(',') + .map((s) => s.trim()) + .filter(Boolean); } continue; } @@ -125,7 +151,10 @@ export function extractJsonFlag( * `--jq` without `--json` is rejected by the caller; this function only does * lexical extraction so the args list passed to cmd-ts no longer contains it. */ -export function extractJqFlag(args: string[]): { args: string[]; jqExpr?: string } { +export function extractJqFlag(args: string[]): { + args: string[]; + jqExpr?: string; +} { const idx = args.indexOf('--jq'); if (idx === -1) return { args }; const expr = args[idx + 1]; diff --git a/src/cli/structured-help.ts b/src/cli/structured-help.ts new file mode 100644 index 00000000..630774f3 --- /dev/null +++ b/src/cli/structured-help.ts @@ -0,0 +1,223 @@ +import type { HelpFormatter } from 'cmd-ts'; +import type { AgentCommandMeta } from './help.js'; +import { formatJsonValue, getJsonFields } from './json-output.js'; +import { + mcpAddMeta, + mcpGetMeta, + mcpListMeta, + mcpReauthMeta, + mcpRemoveMeta, + mcpUpdateMeta, +} from './metadata/mcp.js'; +import { + marketplaceAddMeta, + marketplaceBrowseMeta, + marketplaceListMeta, + marketplaceRemoveMeta, + marketplaceUpdateMeta, + pluginInstallMeta, + pluginListMeta, + pluginUninstallMeta, + pluginUpdateMeta, + pluginValidateMeta, +} from './metadata/plugin.js'; +import { + skillsAddMeta, + skillsListMeta, + skillsRemoveMeta, + skillsSearchMeta, + skillsUpdateMeta, +} from './metadata/plugin-skills.js'; +import { + profileInstallMeta, + profileListMeta, + profileRemoveMeta, + profileStatusMeta, +} from './metadata/profile.js'; +import { updateMeta } from './metadata/self.js'; +import { + initMeta, + pruneMeta, + setupMeta, + statusMeta, + syncMeta, +} from './metadata/workspace.js'; +import { + repoAddMeta, + repoListMeta, + repoRemoveMeta, +} from './metadata/workspace-repo.js'; + +interface RegisteredCommand { + command: string; + meta: AgentCommandMeta; +} + +/** + * Public command paths mirror the cmd-ts tree. Aliases reference the same + * metadata objects as their canonical commands so the two help surfaces cannot + * drift. + */ +const registeredCommands: RegisteredCommand[] = [ + { command: 'init', meta: initMeta }, + { command: 'update', meta: syncMeta }, + { command: 'status', meta: statusMeta }, + { command: 'workspace init', meta: initMeta }, + { command: 'workspace setup', meta: setupMeta }, + { command: 'workspace sync', meta: syncMeta }, + { command: 'workspace status', meta: statusMeta }, + { command: 'workspace prune', meta: pruneMeta }, + { command: 'workspace repo add', meta: repoAddMeta }, + { command: 'workspace repo remove', meta: repoRemoveMeta }, + { command: 'workspace repo list', meta: repoListMeta }, + { command: 'mcp add', meta: mcpAddMeta }, + { command: 'mcp reauth', meta: mcpReauthMeta }, + { command: 'mcp remove', meta: mcpRemoveMeta }, + { command: 'mcp list', meta: mcpListMeta }, + { command: 'mcp get', meta: mcpGetMeta }, + { command: 'mcp update', meta: mcpUpdateMeta }, + { command: 'plugin install', meta: pluginInstallMeta }, + { command: 'plugin uninstall', meta: pluginUninstallMeta }, + { command: 'plugin update', meta: pluginUpdateMeta }, + { command: 'plugin marketplace list', meta: marketplaceListMeta }, + { command: 'plugin marketplace add', meta: marketplaceAddMeta }, + { command: 'plugin marketplace remove', meta: marketplaceRemoveMeta }, + { command: 'plugin marketplace update', meta: marketplaceUpdateMeta }, + { command: 'plugin marketplace browse', meta: marketplaceBrowseMeta }, + { command: 'plugin list', meta: pluginListMeta }, + { command: 'plugin validate', meta: pluginValidateMeta }, + { command: 'plugin skills list', meta: skillsListMeta }, + { command: 'plugin skills add', meta: skillsAddMeta }, + { command: 'plugin skills remove', meta: skillsRemoveMeta }, + { command: 'plugin skills search', meta: skillsSearchMeta }, + { command: 'plugin skills update', meta: skillsUpdateMeta }, + { command: 'skill list', meta: skillsListMeta }, + { command: 'skill add', meta: skillsAddMeta }, + { command: 'skill remove', meta: skillsRemoveMeta }, + { command: 'skill search', meta: skillsSearchMeta }, + { command: 'skill update', meta: skillsUpdateMeta }, + { command: 'self update', meta: updateMeta }, + { command: 'profile install', meta: profileInstallMeta }, + { command: 'profile list', meta: profileListMeta }, + { command: 'profile status', meta: profileStatusMeta }, + { command: 'profile remove', meta: profileRemoveMeta }, +]; + +function formatStructuredHelp( + meta: AgentCommandMeta, + command = meta.command, +): Record { + const result: Record = { + command, + description: meta.description, + when_to_use: meta.whenToUse, + }; + if (meta.positionals && meta.positionals.length > 0) { + result.positionals = meta.positionals; + } + if (meta.options && meta.options.length > 0) { + result.options = meta.options; + } + result.examples = meta.examples; + if (meta.outputSchema) { + result.output_schema = meta.outputSchema; + } + if (meta.interaction) { + result.interaction = meta.interaction; + } + if (meta.jsonFields && meta.jsonFields.length > 0) { + result.json_fields = [...meta.jsonFields]; + } + return result; +} + +function findRegisteredCommand( + commandPath: string, +): RegisteredCommand | undefined { + let longest: RegisteredCommand | undefined; + for (const command of registeredCommands) { + const matches = + commandPath === command.command || + commandPath.startsWith(`${command.command} `); + if ( + matches && + (!longest || command.command.length > longest.command.length) + ) { + longest = command; + } + } + return longest; +} + +/** + * Look up metadata by a runtime command path (e.g. "skill update foo"). + * Public aliases and trailing positionals/options resolve through the same + * longest-prefix registry used by structured help. + */ +export function findMetaByCommand( + commandPath: string, +): AgentCommandMeta | undefined { + return findRegisteredCommand(commandPath)?.meta; +} + +function commandPathFromHelp(path: string[]): string { + return (path[0] === 'allagents' ? path.slice(1) : path).join(' '); +} + +function buildStructuredHelp( + commandPath: string, + version: string, +): Record { + if (!commandPath) { + return { + name: 'allagents', + version, + description: + 'CLI tool for managing multi-repo AI agent workspaces with plugin synchronization', + commands: registeredCommands.map(({ command, meta }) => + formatStructuredHelp(meta, command), + ), + }; + } + + const match = registeredCommands.find( + (command) => command.command === commandPath, + ); + if (match) { + return formatStructuredHelp(match.meta, match.command); + } + + const matches = registeredCommands.filter((command) => + command.command.startsWith(`${commandPath} `), + ); + if (matches.length > 0) { + return { + name: commandPath, + commands: matches.map(({ command, meta }) => + formatStructuredHelp(meta, command), + ), + }; + } + + process.stderr.write(`Unknown command: ${commandPath}\n`); + process.exit(1); +} + +function formatHelp(path: string[], version: string): string { + if (getJsonFields()) { + process.stderr.write( + 'Error: --json= is not supported with --help; use --json.\n', + ); + process.exit(2); + } + return formatJsonValue( + buildStructuredHelp(commandPathFromHelp(path), version), + ); +} + +export function createStructuredHelpFormatter(version: string): HelpFormatter { + return { + formatCommand: ({ path }) => formatHelp(path, version), + formatSubcommands: ({ path }) => formatHelp(path, version), + }; +} diff --git a/tests/e2e/mcp-proxy-command.test.ts b/tests/e2e/mcp-proxy-command.test.ts index 9742a846..3fdc379b 100644 --- a/tests/e2e/mcp-proxy-command.test.ts +++ b/tests/e2e/mcp-proxy-command.test.ts @@ -36,9 +36,9 @@ describe('mcp public command help', () => { expect(result.stdout).not.toContain('Expose a remote HTTP MCP server locally over stdio'); }); - test('exposes add and reauth through machine-readable agent help', () => { - const addResult = runCli(['--agent-help', 'mcp', 'add']); - const reauthResult = runCli(['--agent-help', 'mcp', 'reauth']); + test('exposes add and reauth through structured JSON help', () => { + const addResult = runCli(['mcp', 'add', '--help', '--json']); + const reauthResult = runCli(['--json', 'mcp', 'reauth', '-h']); expect(addResult.exitCode).toBe(0); expect(JSON.parse(addResult.stdout).command).toBe('mcp add'); @@ -46,6 +46,166 @@ describe('mcp public command help', () => { expect(JSON.parse(reauthResult.stdout).command).toBe('mcp reauth'); }); + test('keeps exact bare command help human-readable', () => { + const result = runCli(['mcp', 'add', '--help']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Add an MCP server'); + expect(result.stdout).toContain('--arg'); + expect(result.stdout).not.toContain('"when_to_use"'); + }); + + test('resolves structured help from the longest command prefix', () => { + const positionalResult = runCli([ + 'skill', + 'search', + 'terraform', + '--help', + '--json', + ]); + const optionResult = runCli([ + 'mcp', + 'list', + '--scope', + 'user', + '--help', + '--json', + ]); + + expect(positionalResult.exitCode).toBe(0); + expect(JSON.parse(positionalResult.stdout)).toMatchObject({ + command: 'skill search', + positionals: [{ name: 'query', required: true }], + output_schema: { total: 'number' }, + }); + expect(optionResult.exitCode).toBe(0); + expect(JSON.parse(optionResult.stdout).command).toBe('mcp list'); + }); + + test('applies jq to the existing bare structured-help value', () => { + const result = runCli(['--help', '--json', '--jq', '.name']); + + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe('"allagents"'); + }); + + test('covers workspace aliases and workspace-only command metadata', () => { + const groupResult = runCli(['workspace', '--help', '--json']); + const repoResult = runCli([ + 'workspace', + 'repo', + 'add', + '../project', + '--help', + '--json', + ]); + + expect(groupResult.exitCode).toBe(0); + const group = JSON.parse(groupResult.stdout) as { + commands: Array<{ command: string }>; + }; + expect(group.commands.map(({ command }) => command)).toEqual([ + 'workspace init', + 'workspace setup', + 'workspace sync', + 'workspace status', + 'workspace prune', + 'workspace repo add', + 'workspace repo remove', + 'workspace repo list', + ]); + expect(repoResult.exitCode).toBe(0); + expect(JSON.parse(repoResult.stdout)).toMatchObject({ + command: 'workspace repo add', + positionals: [{ name: 'path', required: true }], + output_schema: { repo: 'string | null' }, + }); + }); + + test('covers canonical and compatibility skill command paths', () => { + const pluginGroupResult = runCli([ + 'plugin', + 'skills', + '--help', + '--json', + ]); + const pluginLeafResult = runCli([ + 'plugin', + 'skills', + 'list', + '--help', + '--json', + ]); + const pluralAliasResult = runCli(['skills', 'list', '--help', '--json']); + + expect(pluginGroupResult.exitCode).toBe(0); + const pluginGroup = JSON.parse(pluginGroupResult.stdout) as { + commands: Array<{ command: string }>; + }; + expect(pluginGroup.commands.map(({ command }) => command)).toEqual([ + 'plugin skills list', + 'plugin skills add', + 'plugin skills remove', + 'plugin skills search', + 'plugin skills update', + ]); + expect(pluginLeafResult.exitCode).toBe(0); + expect(JSON.parse(pluginLeafResult.stdout).command).toBe( + 'plugin skills list', + ); + expect(pluralAliasResult.exitCode).toBe(0); + expect(JSON.parse(pluralAliasResult.stdout).command).toBe('skill list'); + }); + + test('does not treat a registered string option value as structured help', () => { + const result = runCli(['mcp', 'add', '--arg', '--help', '--json']); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout).not.toContain('"when_to_use"'); + }); + + test('does not treat positional help after -- as structured help', () => { + const result = runCli(['--json', 'mcp', 'add', '--', '--help']); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout).not.toContain('"when_to_use"'); + }); + + test('rejects the removed legacy structured-help flag', () => { + const result = runCli(['--agent-help']); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout).not.toContain('"commands"'); + }); + + test('exposes the full command tree through structured JSON help', () => { + const result = runCli(['--help', '--json']); + + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout) as { + name: string; + commands: Array<{ command: string }>; + }; + expect(parsed.name).toBe('allagents'); + expect( + parsed.commands.some((command) => command.command === 'mcp add'), + ).toBe(true); + expect( + parsed.commands.some( + (command) => command.command === 'plugin skills list', + ), + ).toBe(true); + }); + + test('rejects field selection for structured JSON help', () => { + const result = runCli(['mcp', 'add', '--help', '--json=command']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + '--json= is not supported with --help; use --json', + ); + }); + test('rejects proxy-stdio after the rename', () => { const result = runCli(['mcp', 'proxy-stdio']); diff --git a/tests/unit/cli/profile-command.test.ts b/tests/unit/cli/profile-command.test.ts index ce6ff407..8dda2b0a 100644 --- a/tests/unit/cli/profile-command.test.ts +++ b/tests/unit/cli/profile-command.test.ts @@ -838,7 +838,7 @@ describe('profile formatting', () => { }); describe('profile root registration', () => { - test('is present in root help and agent help', () => { + test('is present in root help and structured JSON help', () => { const rootHelp = Bun.spawnSync( ['bun', 'run', cliEntry, '--help'], { stdout: 'pipe', stderr: 'pipe' }, @@ -846,12 +846,12 @@ describe('profile root registration', () => { expect(rootHelp.exitCode).toBe(0); expect(rootHelp.stdout.toString()).toContain('profile'); - const agentHelp = Bun.spawnSync( - ['bun', 'run', cliEntry, '--agent-help', 'profile'], + const structuredHelp = Bun.spawnSync( + ['bun', 'run', cliEntry, 'profile', '--help', '--json'], { stdout: 'pipe', stderr: 'pipe' }, ); - expect(agentHelp.exitCode).toBe(0); - const parsed = JSON.parse(agentHelp.stdout.toString()) as { + expect(structuredHelp.exitCode).toBe(0); + const parsed = JSON.parse(structuredHelp.stdout.toString()) as { commands: Array<{ command: string }>; }; expect(parsed.commands.map((entry) => entry.command)).toEqual([ diff --git a/tests/unit/cli/agent-help.test.ts b/tests/unit/cli/structured-help.test.ts similarity index 81% rename from tests/unit/cli/agent-help.test.ts rename to tests/unit/cli/structured-help.test.ts index a1fa347b..5184d742 100644 --- a/tests/unit/cli/agent-help.test.ts +++ b/tests/unit/cli/structured-help.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test'; -import { extractAgentHelpFlag, findMetaByCommand } from '../../../src/cli/agent-help.js'; +import { findMetaByCommand } from '../../../src/cli/structured-help.js'; import { initMeta, setupMeta, @@ -51,32 +51,6 @@ const allCommands: AgentCommandMeta[] = [ updateMeta, ]; -describe('extractAgentHelpFlag', () => { - test('returns agentHelp false when flag is absent', () => { - const result = extractAgentHelpFlag(['workspace', 'sync']); - expect(result.agentHelp).toBe(false); - expect(result.args).toEqual(['workspace', 'sync']); - }); - - test('strips --agent-help from end of args', () => { - const result = extractAgentHelpFlag(['workspace', 'sync', '--agent-help']); - expect(result.agentHelp).toBe(true); - expect(result.args).toEqual(['workspace', 'sync']); - }); - - test('strips --agent-help from beginning of args', () => { - const result = extractAgentHelpFlag(['--agent-help', 'workspace', 'sync']); - expect(result.agentHelp).toBe(true); - expect(result.args).toEqual(['workspace', 'sync']); - }); - - test('strips --agent-help from middle of args', () => { - const result = extractAgentHelpFlag(['workspace', '--agent-help', 'sync']); - expect(result.agentHelp).toBe(true); - expect(result.args).toEqual(['workspace', 'sync']); - }); -}); - describe('agent command metadata', () => { test('contains exactly 20 commands', () => { expect(allCommands.length).toBe(20); @@ -195,10 +169,12 @@ describe('findMetaByCommand', () => { expect(findMetaByCommand('mcp update')?.outputSchema).toBeDefined(); }); - test('resolves deprecated "workspace status" alias to status meta', () => { - const meta = findMetaByCommand('workspace status'); - expect(meta).toBeDefined(); - expect(meta!.command).toBe('status'); + test('resolves public workspace aliases to their shared metadata', () => { + expect(findMetaByCommand('workspace init ./project')?.command).toBe('init'); + expect(findMetaByCommand('workspace sync --profile work')?.command).toBe( + 'update', + ); + expect(findMetaByCommand('workspace status')?.command).toBe('status'); }); test('resolves command metadata when rest-positionals follow the command', () => { @@ -206,6 +182,18 @@ describe('findMetaByCommand', () => { expect(meta?.command).toBe('skill update'); }); + test('resolves workspace-only commands exposed by the human command tree', () => { + expect(findMetaByCommand('workspace prune')?.command).toBe( + 'workspace prune', + ); + expect(findMetaByCommand('workspace repo add ../project')?.command).toBe( + 'workspace repo add', + ); + expect(findMetaByCommand('workspace repo list')?.outputSchema).toMatchObject( + { total: 'number' }, + ); + }); + test('returns undefined for unknown command', () => { expect(findMetaByCommand('workspace frobnicate')).toBeUndefined(); }); From 04ca63f51d887088225f1c901f8d1c07bddfd0b2 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 19 Sep 2026 17:50:52 +1000 Subject: [PATCH 09/16] feat(mcp): add interactive server management --- CHANGELOG.md | 7 + docs/src/content/docs/docs/reference/cli.mdx | 7 + src/cli/commands/mcp.ts | 316 +++------- src/cli/tui/__tests__/mcp.test.ts | 428 ++++++++++++++ src/cli/tui/__tests__/wizard.test.ts | 7 +- src/cli/tui/actions/mcp.ts | 569 +++++++++++++++++++ src/cli/tui/wizard.ts | 24 +- src/core/mcp-http-stdio-proxy.ts | 79 ++- src/core/mcp-management.ts | 228 ++++++++ tests/e2e/mcp-proxy-oauth.test.ts | 60 ++ 10 files changed, 1465 insertions(+), 260 deletions(-) create mode 100644 src/cli/tui/__tests__/mcp.test.ts create mode 100644 src/cli/tui/actions/mcp.ts create mode 100644 src/core/mcp-management.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d04db9e..26337b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ - Project-scoped Copilot MCP servers are now written to `.github/mcp.json`, which Copilot CLI discovers, instead of the unsupported `.copilot/mcp-config.json` project path. +- Interactive OAuth guidance for `mcp add` and `mcp reauth` now uses normal + terminal output instead of the error channel. Callback URLs are entered + through an abortable masked prompt, and failed reauthentication restores the + previous working credentials. ### Added @@ -42,6 +46,9 @@ - Generated HTTP MCP bridges now invoke the current pinned AllAgents version through cached `npx`, so managed MCP connections do not require a global AllAgents installation. +- Full MCP server management in the interactive TUI, including destination + selection, add, reauthenticate, update, and remove flows for project, user, + and named-profile declarations. - Pi and OMP as file-sync clients at project and user scope, including native runtime skill paths and agent instructions. diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index 1a7078c5..b5181373 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -465,6 +465,13 @@ resolves to user scope and explicit `--scope project` is rejected. Use `--profile ` selects `profiles.` in that same file. `--scope` and `--profile` are mutually exclusive. +Run `allagents` without arguments and choose **MCP Servers** to manage the same +project, user, and named-profile destinations interactively. The TUI supports +listing and inspecting declarations, adding HTTP or stdio servers, +reauthenticating HTTP servers, reconciling generated client configuration, and +removing declarations. Credential values remain hidden from summaries and +selection screens. + Codex and Copilot materialize the selected destination at these exact paths: | Destination | Codex | Copilot CLI | diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts index 18459015..1dc2fbfe 100644 --- a/src/cli/commands/mcp.ts +++ b/src/cli/commands/mcp.ts @@ -10,39 +10,31 @@ import { string, } from 'cmd-ts'; import { dump } from 'js-yaml'; -import { getHomeDir } from '../../constants.js'; import { - type ConnectHttpMcpServerOptions, - connectHttpMcpServer, runHttpMcpStdioProxy, validateOAuthCallbackUrl, } from '../../core/mcp-http-stdio-proxy.js'; import { - addMcpServer, + addManagedMcpServer, + listManagedMcpServers, + type McpAuthorizationInteraction, + type McpDestinationSync, + reauthenticateManagedMcpServer, + removeManagedMcpServer, + updateManagedMcpServers, +} from '../../core/mcp-management.js'; +import { buildMcpServerConfigFromFlags, getMcpServer, - listMcpServers, type McpDestination, parseKeyValuePairs, - removeMcpServer, resolveMcpDestination, } from '../../core/mcp-servers.js'; -import { - type SyncMcpOnlyResult, - syncMcpOnly, - syncUserMcpOnly, -} from '../../core/mcp-sync.js'; -import { - type ProfileApplyResult, - updateInstalledProfiles, -} from '../../core/profile/index.js'; import { type ClientType, ClientTypeSchema, type McpServerConfig, - ProfileDeclarationSchema, } from '../../models/workspace-config.js'; -import { parseUserWorkspaceConfig } from '../../utils/workspace-parser.js'; import { buildProfileData, formatProfileResult } from '../format-profile.js'; import { formatMcpResult } from '../format-sync.js'; import { buildDescription, conciseSubcommands } from '../help.js'; @@ -274,49 +266,41 @@ function buildConfigFromAddFlags( return built.config; } -async function connectConfiguredHttpServer( +function createAuthorizationInteraction(): McpAuthorizationInteraction { + return { + output: console.log, + readCallback: async ({ redirectUrl, state, signal }) => { + const callbackUrl = await password({ + message: 'Paste the OAuth callback URL if using another browser', + signal, + validate: (value) => { + if (!value) { + return 'OAuth callback URL is required'; + } + try { + validateOAuthCallbackUrl(value, redirectUrl, state); + return undefined; + } catch (error) { + return error instanceof Error + ? error.message + : 'Invalid OAuth callback URL'; + } + }, + }); + if (isCancel(callbackUrl)) { + throw new Error('OAuth authorization cancelled'); + } + return callbackUrl; + }, + }; +} + +async function runManagedMcpOperation( commandName: string, - serverUrl: string, - headers: Record | undefined, - mode: { - resetCredentials: boolean; - allowAuthorization: boolean; - profile?: string; - }, -): Promise { + operation: () => Promise, +): Promise { try { - const options: ConnectHttpMcpServerOptions = { - headers: headers ?? {}, - resetCredentials: mode.resetCredentials, - allowAuthorization: mode.allowAuthorization, - ...(mode.profile ? { profile: mode.profile } : {}), - }; - if (mode.allowAuthorization) { - options.callbackUrlReader = async ({ redirectUrl, state, signal }) => { - const callbackUrl = await password({ - message: 'Paste the OAuth callback URL if using another browser', - signal, - validate: (value) => { - if (!value) { - return 'OAuth callback URL is required'; - } - try { - validateOAuthCallbackUrl(value, redirectUrl, state); - return undefined; - } catch (error) { - return error instanceof Error - ? error.message - : 'Invalid OAuth callback URL'; - } - }, - }); - if (isCancel(callbackUrl)) { - throw new Error('OAuth authorization cancelled'); - } - return callbackUrl; - }; - } - await connectHttpMcpServer(serverUrl, options); + return await operation(); } catch (error) { exitWithError( commandName, @@ -340,105 +324,17 @@ async function getConfiguredMcpServer( } } -async function validateProfileAddCandidate( - commandName: string, - destination: McpDestination, - name: string, - config: McpServerConfig, -): Promise { - if (destination.kind !== 'profile') return; - - try { - const workspace = await parseUserWorkspaceConfig(destination.configPath); - const profile = workspace.profiles?.[destination.name]; - if (!profile) { - exitWithError( - commandName, - `Profile '${destination.name}' is not declared`, - ); - } - const validation = ProfileDeclarationSchema.safeParse({ - ...profile, - mcpServers: { - ...profile.mcpServers, - [name]: config, - }, - }); - if (!validation.success) { - const issues = validation.error.issues.map( - (issue) => ` - ${issue.path.join('.')}: ${issue.message}`, - ); - exitWithError( - commandName, - `Invalid MCP server config:\n${issues.join('\n')}`, - ); - } - } catch (error) { - exitWithError( - commandName, - error instanceof Error ? error.message : String(error), - ); - } -} - -type DestinationSync = - | { kind: 'mcp'; result: SyncMcpOnlyResult } - | { kind: 'profile'; result: ProfileApplyResult | null }; - -async function reconcileDestination( - commandName: string, - destination: McpDestination, - offline: boolean, -): Promise { - if (destination.kind !== 'profile') { - const result = - destination.kind === 'project' - ? await syncMcpOnly(destination.workspacePath, { offline }) - : await syncUserMcpOnly({ offline }); - if (!result.success) { - exitWithError(commandName, result.error ?? 'MCP sync failed'); - } - return { kind: 'mcp', result }; - } - let results: readonly ProfileApplyResult[]; - try { - results = await updateInstalledProfiles([destination.name], { - offline, - homeDir: getHomeDir(), - userConfigPath: destination.configPath, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message === `Profile '${destination.name}' is not installed`) { - return { kind: 'profile', result: null }; - } - exitWithError(commandName, message); - } - const result = results[0]; - if (!result) { - exitWithError( - commandName, - `Profile '${destination.name}' was not reconciled`, - ); - } - if (!result.success) { - exitWithError( - commandName, - result.error ?? `Profile '${destination.name}' update failed`, - ); - } - return { kind: 'profile', result }; -} - function profileSyncData( - sync: Extract, + sync: Extract, ): { status: 'not-installed' } | Record { return sync.result ? buildProfileData(sync.result) : { status: 'not-installed' }; } -function printMcpSyncResult(result: SyncMcpOnlyResult): void { +function printMcpSyncResult( + result: Extract['result'], +): void { for (const [scope, scopeResult] of Object.entries(result.mcpResults)) { if (!scopeResult) continue; const lines = formatMcpResult(scopeResult, scope); @@ -454,7 +350,7 @@ function printMcpSyncResult(result: SyncMcpOnlyResult): void { function printProfileSyncResult( destination: Extract, - sync: Extract, + sync: Extract, ): void { if (!sync.result) { console.log( @@ -467,16 +363,15 @@ function printProfileSyncResult( } /** - * Reconcile only the selected destination after a declaration mutation. + * Render the completed reconciliation for a declaration mutation. */ -async function runPostMutationSync( +function renderPostMutationSync( commandName: string, destination: McpDestination, + sync: McpDestinationSync, successMessage: string, jsonExtra: Record, -): Promise { - const sync = await reconcileDestination(commandName, destination, true); - +): void { if (isJsonMode()) { jsonOutput({ success: true, @@ -603,48 +498,28 @@ const mcpAddCmd = command({ header, client, ); - const existing = await getConfiguredMcpServer('mcp add', destination, name); - if (existing && !force) { - exitWithError( - 'mcp add', - `MCP server '${name}' already exists in ${destinationDisplay(destination)}. Use --force to replace it.`, - ); - } - await validateProfileAddCandidate('mcp add', destination, name, config); - - if ('url' in config) { - const allowAuthorization = !isJsonMode() && Boolean(process.stdin.isTTY); - await connectConfiguredHttpServer('mcp add', config.url, config.headers, { - resetCredentials: false, - allowAuthorization, - ...(destination.kind === 'profile' - ? { profile: destination.name } - : {}), - }); - } - - const addResult = await addMcpServer(destination, name, config, { - force, - proxy: - 'url' in config - ? { - ...(config.clients === undefined - ? {} - : { clients: config.clients }), - } - : false, - }); - if (!addResult.success) { - exitWithError('mcp add', addResult.error ?? 'Unknown error'); - } + const authorization = + !isJsonMode() && process.stdin.isTTY + ? createAuthorizationInteraction() + : undefined; + const result = await runManagedMcpOperation('mcp add', () => + addManagedMcpServer({ + destination, + name, + config, + force, + ...(authorization ? { authorization } : {}), + }), + ); - await runPostMutationSync( + renderPostMutationSync( 'mcp add', destination, + result.sync, `\u2713 Added MCP server '${terminalSafe(name)}' to ${destinationDisplay(destination)}`, { name, - config: redactMcpServerConfig(addResult.config ?? config), + config: redactMcpServerConfig(result.config), }, ); }, @@ -666,13 +541,13 @@ const mcpRemoveCmd = command({ scope, profile, }); - const removeResult = await removeMcpServer(destination, name); - if (!removeResult.success) { - exitWithError('mcp remove', removeResult.error ?? 'Unknown error'); - } - await runPostMutationSync( + const sync = await runManagedMcpOperation('mcp remove', () => + removeManagedMcpServer(destination, name), + ); + renderPostMutationSync( 'mcp remove', destination, + sync, `\u2713 Removed MCP server '${terminalSafe(name)}' from ${destinationDisplay(destination)}`, { name }, ); @@ -695,23 +570,6 @@ const mcpReauthCmd = command({ scope, profile, }); - const config = await getConfiguredMcpServer( - 'mcp reauth', - destination, - name, - ); - if (!config) { - exitWithError( - 'mcp reauth', - `MCP server '${name}' is not defined in ${destinationDisplay(destination)}`, - ); - } - if (!('url' in config)) { - exitWithError( - 'mcp reauth', - `MCP server '${name}' uses stdio and cannot be reauthenticated`, - ); - } if (isJsonMode() || !process.stdin.isTTY) { exitWithError( 'mcp reauth', @@ -719,17 +577,12 @@ const mcpReauthCmd = command({ ); } - await connectConfiguredHttpServer( - 'mcp reauth', - config.url, - config.headers, - { - resetCredentials: true, - allowAuthorization: true, - ...(destination.kind === 'profile' - ? { profile: destination.name } - : {}), - }, + await runManagedMcpOperation('mcp reauth', () => + reauthenticateManagedMcpServer( + destination, + name, + createAuthorizationInteraction(), + ), ); console.log( `\u2713 Reauthenticated MCP server '${terminalSafe(name)}' in ${destinationDisplay(destination)}`, @@ -812,12 +665,9 @@ const mcpListCmd = command({ scope, profile, }); - let servers: Record; - try { - servers = await listMcpServers(destination); - } catch (e) { - exitWithError('mcp list', e instanceof Error ? e.message : String(e)); - } + const servers = await runManagedMcpOperation('mcp list', () => + listManagedMcpServers(destination), + ); const names = Object.keys(servers); const redactedServers = Object.fromEntries( Object.entries(servers).map(([name, config]) => [ @@ -925,7 +775,9 @@ const mcpUpdateCmd = command({ scope, profile, }); - const sync = await reconcileDestination('mcp update', destination, offline); + const sync = await runManagedMcpOperation('mcp update', () => + updateManagedMcpServers(destination, { offline }), + ); if (isJsonMode()) { jsonOutput({ diff --git a/src/cli/tui/__tests__/mcp.test.ts b/src/cli/tui/__tests__/mcp.test.ts new file mode 100644 index 00000000..76f31dbe --- /dev/null +++ b/src/cli/tui/__tests__/mcp.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, it } from 'bun:test'; +import type { + AddManagedMcpServerRequest, + McpDestinationSync, +} from '../../../core/mcp-management.js'; +import type { McpDestination } from '../../../core/mcp-servers.js'; +import type { + McpServerConfig, + UserWorkspaceConfig, +} from '../../../models/workspace-config.js'; +import { + type McpManagementApi, + type McpTuiDependencies, + type McpTuiPrompts, + runMcpServers, +} from '../actions/mcp.js'; +import type { TuiCache } from '../cache.js'; +import type { TuiContext } from '../context.js'; + +const CANCEL = Symbol('cancel'); + +interface SelectRequest { + message: string; + options: Array<{ label: string; value: string; hint?: string }>; +} + +class ScriptedPrompts implements McpTuiPrompts { + readonly selectRequests: SelectRequest[] = []; + readonly notes: Array<{ message: string; title?: string }> = []; + readonly passwordRequests: Array<{ message: string; signal: AbortSignal }> = + []; + readonly selects: Array; + readonly texts: Array; + readonly multiselects: Array; + readonly confirms: Array; + + constructor(script: { + selects: Array; + texts?: Array; + multiselects?: Array; + confirms?: Array; + }) { + this.selects = [...script.selects]; + this.texts = [...(script.texts ?? [])]; + this.multiselects = [...(script.multiselects ?? [])]; + this.confirms = [...(script.confirms ?? [])]; + } + + async select(request: { + message: string; + options: Array<{ label: string; value: T; hint?: string }>; + }): Promise { + this.selectRequests.push(request as SelectRequest); + const value = this.selects.shift(); + if (value === undefined) { + throw new Error(`Missing select response for ${request.message}`); + } + return value as T | symbol; + } + + async text(): Promise { + const value = this.texts.shift(); + if (value === undefined) throw new Error('Missing text response'); + return value; + } + + async password(request: { + message: string; + signal: AbortSignal; + }): Promise { + this.passwordRequests.push(request); + return this.text(); + } + + async multiselect(): Promise { + const value = this.multiselects.shift(); + if (value === undefined) throw new Error('Missing multiselect response'); + return value as T[] | symbol; + } + + async confirm(): Promise { + const value = this.confirms.shift(); + if (value === undefined) throw new Error('Missing confirm response'); + return value; + } + + isCancel(value: unknown): value is symbol { + return value === CANCEL; + } + + note(message: string, title?: string): void { + this.notes.push({ message, ...(title && { title }) }); + } +} + +function context(hasWorkspace = true): TuiContext { + return { + hasWorkspace, + workspacePath: hasWorkspace ? '/workspace' : null, + projectPluginCount: 0, + userPluginCount: 0, + needsSync: false, + hasUserConfig: true, + marketplaceCount: 0, + }; +} + +function destination(options: { + cwd?: string; + scope?: string; + profile?: string; +}): McpDestination { + if (options.profile) { + return { + kind: 'profile', + name: options.profile, + configPath: '/home/user/.allagents/workspace.yaml', + }; + } + if (options.scope === 'project') { + return { + kind: 'project', + workspacePath: options.cwd ?? '/workspace', + configPath: `${options.cwd ?? '/workspace'}/.allagents/workspace.yaml`, + }; + } + return { + kind: 'user', + configPath: '/home/user/.allagents/workspace.yaml', + }; +} + +const completedSync: McpDestinationSync = { kind: 'profile', result: null }; + +function dependencies(options: { + prompts: ScriptedPrompts; + servers?: Record; + userConfig?: UserWorkspaceConfig | null; + onAdd?: (request: AddManagedMcpServerRequest) => Promise | void; + onReauthenticate?: (destination: McpDestination, name: string) => void; + onRemove?: (destination: McpDestination, name: string) => void; + onUpdate?: (destination: McpDestination) => void; +}): McpTuiDependencies { + const management: McpManagementApi = { + async addManagedMcpServer(request) { + await options.onAdd?.(request); + return { config: request.config, sync: completedSync }; + }, + async listManagedMcpServers() { + return options.servers ?? {}; + }, + async reauthenticateManagedMcpServer(selected, name) { + options.onReauthenticate?.(selected, name); + }, + async removeManagedMcpServer(selected, name) { + options.onRemove?.(selected, name); + return completedSync; + }, + async updateManagedMcpServers(selected) { + options.onUpdate?.(selected); + return completedSync; + }, + }; + return { + prompts: options.prompts, + management, + getUserConfig: async () => options.userConfig ?? null, + resolveDestination: destination, + }; +} + +function cacheCounter(): { cache: TuiCache; count: () => number } { + let invalidations = 0; + return { + cache: { + invalidate() { + invalidations += 1; + }, + } as unknown as TuiCache, + count: () => invalidations, + }; +} + +describe('runMcpServers', () => { + it('selects a declared profile and adds an HTTP server through management', async () => { + const callbackUrl = + 'http://127.0.0.1:3117/callback?code=complete&state=state'; + const callbackSignal = new AbortController().signal; + const prompts = new ScriptedPrompts({ + selects: ['profile:work', '__add__', 'http', '__back__'], + texts: [ + 'remote', + 'https://mcp.example.test/path', + `Authorization=\${MCP_TOKEN}`, + '', + callbackUrl, + ], + multiselects: [['claude']], + confirms: [true], + }); + let added: AddManagedMcpServerRequest | undefined; + let pastedCallback: string | undefined; + const { cache, count } = cacheCounter(); + const userConfig = { + repositories: [], + plugins: [], + clients: [], + profiles: { + work: { + clients: [{ name: 'claude', install: 'file', settings: {} }], + plugins: [], + }, + }, + } as UserWorkspaceConfig; + + await runMcpServers( + context(), + cache, + dependencies({ + prompts, + userConfig, + async onAdd(request) { + added = request; + request.authorization?.output( + 'Open the browser to authorize this server.', + ); + pastedCallback = await request.authorization?.readCallback({ + authorizationUrl: new URL('https://login.example.test/authorize'), + redirectUrl: 'http://127.0.0.1:3117/callback', + state: 'state', + signal: callbackSignal, + }); + }, + }), + ); + + expect(added?.destination).toMatchObject({ kind: 'profile', name: 'work' }); + expect(added?.config).toEqual({ + type: 'http', + url: 'https://mcp.example.test/path', + headers: { Authorization: `\${MCP_TOKEN}` }, + clients: ['claude'], + }); + expect(pastedCallback).toBe(callbackUrl); + expect(prompts.passwordRequests).toEqual([ + { + message: 'Paste the OAuth callback URL', + signal: callbackSignal, + }, + ]); + expect(prompts.notes).toContainEqual({ + message: 'Open the browser to authorize this server.', + title: 'Authorization', + }); + expect(count()).toBe(1); + expect( + prompts.selectRequests[0]?.options.map((option) => option.value), + ).toEqual(['project', 'user', 'profile:work', '__back__']); + }); + + it('offers reauthentication only for HTTP servers and calls management for HTTP', async () => { + const prompts = new ScriptedPrompts({ + selects: [ + 'user', + 'server:local', + 'back', + 'server:remote', + 'reauthenticate', + 'back', + '__back__', + ], + }); + const reauthenticated: Array<{ + destination: McpDestination; + name: string; + }> = []; + const { cache, count } = cacheCounter(); + + await runMcpServers( + context(false), + cache, + dependencies({ + prompts, + servers: { + local: { type: 'stdio', command: 'npx', args: ['secret-token'] }, + remote: { type: 'http', url: 'https://example.test/mcp' }, + }, + onReauthenticate(selected, name) { + reauthenticated.push({ destination: selected, name }); + }, + }), + ); + + const localActions = prompts.selectRequests.find( + (request) => request.message === 'MCP server: local', + ); + const remoteActions = prompts.selectRequests.find( + (request) => request.message === 'MCP server: remote', + ); + expect(localActions?.options.map((option) => option.value)).not.toContain( + 'reauthenticate', + ); + expect(remoteActions?.options.map((option) => option.value)).toContain( + 'reauthenticate', + ); + expect(reauthenticated).toEqual([ + { + destination: expect.objectContaining({ kind: 'user' }), + name: 'remote', + }, + ]); + expect(count()).toBe(1); + }); + + it('confirms removal, updates through management, and invalidates after each mutation', async () => { + const prompts = new ScriptedPrompts({ + selects: ['project', 'server:local', 'remove', '__update__', '__back__'], + confirms: [true], + }); + const removals: string[] = []; + const updates: McpDestination[] = []; + const { cache, count } = cacheCounter(); + + await runMcpServers( + context(), + cache, + dependencies({ + prompts, + servers: { local: { type: 'stdio', command: 'node' } }, + onRemove(selected, name) { + expect(selected.kind).toBe('project'); + removals.push(name); + }, + onUpdate(selected) { + updates.push(selected); + }, + }), + ); + + expect(removals).toEqual(['local']); + expect(updates).toHaveLength(1); + expect(updates[0]?.kind).toBe('project'); + expect(count()).toBe(2); + }); + + it('does not mutate when add or removal prompts are cancelled', async () => { + let addCalls = 0; + const addPrompts = new ScriptedPrompts({ + selects: ['user', '__add__', '__back__'], + texts: [CANCEL], + }); + await runMcpServers( + context(false), + undefined, + dependencies({ + prompts: addPrompts, + onAdd() { + addCalls += 1; + }, + }), + ); + + let removeCalls = 0; + const removePrompts = new ScriptedPrompts({ + selects: ['user', 'server:local', 'remove', 'back', '__back__'], + confirms: [false], + }); + await runMcpServers( + context(false), + undefined, + dependencies({ + prompts: removePrompts, + servers: { local: { type: 'stdio', command: 'node' } }, + onRemove() { + removeCalls += 1; + }, + }), + ); + + expect(addCalls).toBe(0); + expect(removeCalls).toBe(0); + }); + + it('shows safe metadata without header, environment, argument, or URL credential values', async () => { + const prompts = new ScriptedPrompts({ + selects: [ + 'user', + 'server:secure-http', + 'back', + 'server:secure-stdio', + 'back', + '__back__', + ], + }); + await runMcpServers( + context(false), + undefined, + dependencies({ + prompts, + servers: { + 'secure-http': { + type: 'http', + url: 'https://user:password@example.test/private?token=url-secret', + headers: { Authorization: 'header-secret' }, + }, + 'secure-stdio': { + type: 'stdio', + command: '/private/secret-command', + args: ['--token', 'argument-secret'], + env: { API_TOKEN: 'environment-secret' }, + }, + }, + }), + ); + + const rendered = prompts.notes.map((note) => note.message).join('\n'); + expect(rendered).toContain('Origin: https://example.test'); + expect(rendered).toContain('Headers: Authorization'); + expect(rendered).toContain('Arguments: 2 configured'); + expect(rendered).toContain('Environment: API_TOKEN'); + expect(rendered).not.toContain('password'); + expect(rendered).not.toContain('url-secret'); + expect(rendered).not.toContain('header-secret'); + expect(rendered).not.toContain('secret-command'); + expect(rendered).not.toContain('argument-secret'); + expect(rendered).not.toContain('environment-secret'); + }); +}); diff --git a/src/cli/tui/__tests__/wizard.test.ts b/src/cli/tui/__tests__/wizard.test.ts index 8e66017a..fbd01e3d 100644 --- a/src/cli/tui/__tests__/wizard.test.ts +++ b/src/cli/tui/__tests__/wizard.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect } from 'bun:test'; -import { buildMenuOptions, type MenuAction } from '../wizard.js'; +import { describe, expect, it } from 'bun:test'; import type { TuiContext } from '../context.js'; +import { buildMenuOptions, type MenuAction } from '../wizard.js'; /** Helper to create a TuiContext with sensible defaults. */ function makeContext(overrides: Partial = {}): TuiContext { @@ -30,12 +30,13 @@ describe('buildMenuOptions', () => { ]; for (const ctx of states) { - it(`includes workspace, plugins, skills, clients, marketplace (hasWorkspace=${ctx.hasWorkspace}, needsSync=${ctx.needsSync})`, () => { + it(`includes workspace, plugins, skills, clients, mcp, marketplace (hasWorkspace=${ctx.hasWorkspace}, needsSync=${ctx.needsSync})`, () => { const values = actionValues(ctx); expect(values).toContain('workspace'); expect(values).toContain('plugins'); expect(values).toContain('skills'); expect(values).toContain('clients'); + expect(values).toContain('mcp'); expect(values).toContain('marketplace'); }); } diff --git a/src/cli/tui/actions/mcp.ts b/src/cli/tui/actions/mcp.ts new file mode 100644 index 00000000..4cce7c56 --- /dev/null +++ b/src/cli/tui/actions/mcp.ts @@ -0,0 +1,569 @@ +import * as p from '@clack/prompts'; +import { + addManagedMcpServer, + listManagedMcpServers, + type McpAuthorizationInteraction, + reauthenticateManagedMcpServer, + removeManagedMcpServer, + updateManagedMcpServers, +} from '../../../core/mcp-management.js'; +import { + type McpDestination, + resolveMcpDestination, +} from '../../../core/mcp-servers.js'; +import { getUserWorkspaceConfig } from '../../../core/user-workspace.js'; +import { + type ClientType, + ClientTypeSchema, + type McpServerConfig, + type UserWorkspaceConfig, +} from '../../../models/workspace-config.js'; +import { terminalSafe } from '../../terminal-output.js'; +import type { TuiCache } from '../cache.js'; +import type { TuiContext } from '../context.js'; + +interface PromptOption { + label: string; + value: T; + hint?: string; +} + +export interface McpTuiPrompts { + select(request: { + message: string; + options: Array>; + }): Promise; + text(request: { + message: string; + placeholder?: string; + }): Promise; + password(request: { + message: string; + signal: AbortSignal; + }): Promise; + multiselect(request: { + message: string; + options: Array>; + required: false; + }): Promise; + confirm(request: { message: string }): Promise; + isCancel(value: unknown): value is symbol; + note(message: string, title?: string): void; +} + +export interface McpManagementApi { + addManagedMcpServer: typeof addManagedMcpServer; + listManagedMcpServers: typeof listManagedMcpServers; + reauthenticateManagedMcpServer: typeof reauthenticateManagedMcpServer; + removeManagedMcpServer: typeof removeManagedMcpServer; + updateManagedMcpServers: typeof updateManagedMcpServers; +} + +export interface McpTuiDependencies { + prompts: McpTuiPrompts; + management: McpManagementApi; + getUserConfig(): Promise; + resolveDestination(options: { + cwd?: string; + scope?: string; + profile?: string; + }): McpDestination; +} + +const clackPrompts: McpTuiPrompts = { + async select(request: { + message: string; + options: Array>; + }): Promise { + return p.select({ + ...request, + options: request.options as unknown as p.Option[], + }); + }, + async text(request): Promise { + return p.text(request); + }, + async password(request): Promise { + return p.password(request); + }, + async multiselect(request: { + message: string; + options: Array>; + required: false; + }): Promise { + return p.multiselect({ + ...request, + options: request.options as unknown as p.Option[], + }); + }, + async confirm(request): Promise { + return p.confirm(request); + }, + isCancel(value): value is symbol { + return p.isCancel(value); + }, + note(message, title): void { + p.note(message, title); + }, +}; + +const defaultDependencies: McpTuiDependencies = { + prompts: clackPrompts, + management: { + addManagedMcpServer, + listManagedMcpServers, + reauthenticateManagedMcpServer, + removeManagedMcpServer, + updateManagedMcpServers, + }, + getUserConfig: getUserWorkspaceConfig, + resolveDestination: resolveMcpDestination, +}; + +interface DestinationChoice { + key: string; + label: string; + hint: string; + destination: McpDestination; + clients: readonly ClientType[]; +} + +function errorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + const redacted = message.replace(/https?:\/\/[^\s'"<>]+/gi, (value) => { + try { + return new URL(value).origin; + } catch { + return '[redacted URL]'; + } + }); + return terminalSafe(redacted); +} + +async function buildDestinationChoices( + context: TuiContext, + dependencies: McpTuiDependencies, +): Promise { + const choices: DestinationChoice[] = []; + if (context.hasWorkspace && context.workspacePath) { + choices.push({ + key: 'project', + label: 'Project', + hint: 'current workspace', + destination: dependencies.resolveDestination({ + cwd: context.workspacePath, + scope: 'project', + }), + clients: ClientTypeSchema.options, + }); + } + + choices.push({ + key: 'user', + label: 'User', + hint: 'global user configuration', + destination: dependencies.resolveDestination({ scope: 'user' }), + clients: ClientTypeSchema.options, + }); + + const userConfig = await dependencies.getUserConfig(); + for (const name of Object.keys(userConfig?.profiles ?? {}).sort()) { + const profile = userConfig?.profiles?.[name]; + if (!profile) continue; + choices.push({ + key: `profile:${name}`, + label: `Profile: ${terminalSafe(name)}`, + hint: 'declared user profile', + destination: dependencies.resolveDestination({ profile: name }), + clients: profile.clients.map((client) => client.name), + }); + } + return choices; +} + +async function selectDestination( + context: TuiContext, + dependencies: McpTuiDependencies, +): Promise { + const choices = await buildDestinationChoices(context, dependencies); + const selected = await dependencies.prompts.select({ + message: 'MCP server destination', + options: [ + ...choices.map((choice) => ({ + label: choice.label, + value: choice.key, + hint: choice.hint, + })), + { label: 'Back', value: '__back__' }, + ], + }); + if (dependencies.prompts.isCancel(selected) || selected === '__back__') { + return null; + } + return choices.find((choice) => choice.key === selected) ?? null; +} + +function destinationLabel(destination: McpDestination): string { + if (destination.kind === 'profile') { + return `profile ${terminalSafe(destination.name)}`; + } + return destination.kind; +} + +async function promptRequired( + prompts: McpTuiPrompts, + message: string, + placeholder?: string, + validate?: (value: string) => string | null, +): Promise { + while (true) { + const value = await prompts.text({ + message, + ...(placeholder && { placeholder }), + }); + if (prompts.isCancel(value)) return null; + const normalized = value.trim(); + const validationError = normalized + ? validate?.(normalized) + : 'A value is required.'; + if (!validationError) return normalized; + prompts.note(validationError, 'Invalid value'); + } +} + +async function promptArguments( + prompts: McpTuiPrompts, +): Promise { + const arguments_: string[] = []; + while (true) { + const value = await prompts.text({ + message: 'Argument (leave blank when finished)', + }); + if (prompts.isCancel(value)) return null; + if (value === '') return arguments_; + arguments_.push(value); + } +} + +async function promptKeyValues( + prompts: McpTuiPrompts, + label: string, +): Promise | null> { + const entries: Record = {}; + while (true) { + const value = await prompts.text({ + message: `${label} (KEY=VALUE; leave blank when finished)`, + }); + if (prompts.isCancel(value)) return null; + if (value.trim() === '') return entries; + const separator = value.indexOf('='); + const key = separator < 0 ? '' : value.slice(0, separator).trim(); + if (!key) { + prompts.note( + 'Enter a non-empty key followed by = and a value.', + 'Invalid entry', + ); + continue; + } + entries[key] = value.slice(separator + 1); + } +} + +async function promptClients( + prompts: McpTuiPrompts, + available: readonly ClientType[], +): Promise { + const selected = await prompts.multiselect({ + message: 'Limit to clients (leave empty for all supported clients)', + options: available.map((client) => ({ label: client, value: client })), + required: false, + }); + return prompts.isCancel(selected) ? null : selected; +} + +function authorizationInteraction( + prompts: McpTuiPrompts, +): McpAuthorizationInteraction { + return { + output(message): void { + prompts.note(terminalSafe(message), 'Authorization'); + }, + async readCallback(request): Promise { + const value = await prompts.password({ + message: 'Paste the OAuth callback URL', + signal: request.signal, + }); + if (prompts.isCancel(value)) { + throw new Error('OAuth authorization cancelled'); + } + const callbackUrl = value.trim(); + if (!callbackUrl) { + throw new Error('OAuth callback URL is required'); + } + return callbackUrl; + }, + }; +} + +async function addServer( + choice: DestinationChoice, + existing: Record, + cache: TuiCache | undefined, + dependencies: McpTuiDependencies, +): Promise { + const { prompts, management } = dependencies; + const name = await promptRequired(prompts, 'Server name'); + if (name === null) return; + + const force = Object.hasOwn(existing, name); + + const transport = await prompts.select({ + message: 'Transport', + options: [ + { label: 'HTTP', value: 'http' }, + { label: 'stdio', value: 'stdio' }, + ], + }); + if (prompts.isCancel(transport)) return; + + let config: McpServerConfig; + if (transport === 'http') { + const url = await promptRequired( + prompts, + 'Server URL', + 'https://example.com/mcp', + (value) => + /^https?:\/\//i.test(value) + ? null + : 'Enter an http:// or https:// URL.', + ); + if (url === null) return; + const headers = await promptKeyValues(prompts, 'Header'); + if (headers === null) return; + const clients = await promptClients(prompts, choice.clients); + if (clients === null) return; + config = { + type: 'http', + url, + ...(Object.keys(headers).length > 0 && { headers }), + ...(clients.length > 0 && { clients }), + }; + } else { + const command = await promptRequired(prompts, 'Command', 'npx'); + if (command === null) return; + const args = await promptArguments(prompts); + if (args === null) return; + const env = await promptKeyValues(prompts, 'Environment variable'); + if (env === null) return; + const clients = await promptClients(prompts, choice.clients); + if (clients === null) return; + config = { + type: 'stdio', + command, + ...(args.length > 0 && { args }), + ...(Object.keys(env).length > 0 && { env }), + ...(clients.length > 0 && { clients }), + }; + } + + prompts.note(safeServerMetadata(name, config), 'Review MCP Server'); + const confirmed = await prompts.confirm({ + message: force + ? `Replace MCP server "${terminalSafe(name)}"?` + : `Add MCP server "${terminalSafe(name)}"?`, + }); + if (prompts.isCancel(confirmed) || !confirmed) return; + + try { + await management.addManagedMcpServer({ + destination: choice.destination, + name, + config, + force, + ...('url' in config && { + authorization: authorizationInteraction(prompts), + }), + }); + cache?.invalidate(); + prompts.note( + `Added ${terminalSafe(name)} to ${destinationLabel(choice.destination)}.`, + 'MCP Servers', + ); + } catch (error) { + prompts.note(errorMessage(error), 'Error'); + } +} + +function safeServerMetadata(name: string, config: McpServerConfig): string { + const lines = [`Name: ${terminalSafe(name)}`]; + if ('url' in config) { + let origin = 'configured endpoint'; + try { + origin = new URL(config.url).origin; + } catch { + // The management layer owns config validation. Avoid echoing malformed input. + } + lines.push('Transport: HTTP', `Origin: ${terminalSafe(origin)}`); + const headerNames = Object.keys(config.headers ?? {}); + lines.push( + `Headers: ${ + headerNames.length > 0 + ? headerNames.map(terminalSafe).join(', ') + : 'none' + }`, + ); + } else { + lines.push('Transport: stdio', 'Command: configured'); + lines.push(`Arguments: ${config.args?.length ?? 0} configured`); + const environmentNames = Object.keys(config.env ?? {}); + lines.push( + `Environment: ${ + environmentNames.length > 0 + ? environmentNames.map(terminalSafe).join(', ') + : 'none' + }`, + ); + } + lines.push( + `Clients: ${ + config.clients?.length + ? config.clients.map(terminalSafe).join(', ') + : 'all supported' + }`, + ); + return lines.join('\n'); +} + +function serverActions(config: McpServerConfig): Array> { + return [ + ...('url' in config + ? [{ label: 'Reauthenticate', value: 'reauthenticate' }] + : []), + { label: 'Remove', value: 'remove' }, + { label: 'Back', value: 'back' }, + ]; +} + +async function serverDetail( + choice: DestinationChoice, + name: string, + config: McpServerConfig, + cache: TuiCache | undefined, + dependencies: McpTuiDependencies, +): Promise { + const { prompts, management } = dependencies; + while (true) { + prompts.note(safeServerMetadata(name, config), 'MCP Server'); + const action = await prompts.select({ + message: `MCP server: ${terminalSafe(name)}`, + options: serverActions(config), + }); + if (prompts.isCancel(action) || action === 'back') return; + + if (action === 'reauthenticate' && 'url' in config) { + try { + await management.reauthenticateManagedMcpServer( + choice.destination, + name, + authorizationInteraction(prompts), + ); + cache?.invalidate(); + prompts.note(`Reauthenticated ${terminalSafe(name)}.`, 'MCP Servers'); + } catch (error) { + prompts.note(errorMessage(error), 'Error'); + } + continue; + } + + if (action === 'remove') { + const confirmed = await prompts.confirm({ + message: `Remove MCP server "${terminalSafe(name)}"?`, + }); + if (prompts.isCancel(confirmed) || !confirmed) continue; + try { + await management.removeManagedMcpServer(choice.destination, name); + cache?.invalidate(); + prompts.note(`Removed ${terminalSafe(name)}.`, 'MCP Servers'); + return; + } catch (error) { + prompts.note(errorMessage(error), 'Error'); + } + } + } +} + +async function manageDestination( + choice: DestinationChoice, + cache: TuiCache | undefined, + dependencies: McpTuiDependencies, +): Promise<'back' | 'change'> { + const { prompts, management } = dependencies; + while (true) { + let servers: Record; + try { + servers = await management.listManagedMcpServers(choice.destination); + } catch (error) { + prompts.note(errorMessage(error), 'Error'); + return 'change'; + } + + const names = Object.keys(servers).sort(); + const selected = await prompts.select({ + message: `MCP Servers [${destinationLabel(choice.destination)}]`, + options: [ + { label: '+ Add server', value: '__add__' }, + { label: 'Update / reconcile', value: '__update__' }, + ...names.map((name) => ({ + label: terminalSafe(name), + value: `server:${name}`, + hint: 'url' in (servers[name] as McpServerConfig) ? 'HTTP' : 'stdio', + })), + { label: 'Change destination', value: '__change__' }, + { label: 'Back', value: '__back__' }, + ], + }); + + if (prompts.isCancel(selected) || selected === '__back__') return 'back'; + if (selected === '__change__') return 'change'; + if (selected === '__add__') { + await addServer(choice, servers, cache, dependencies); + continue; + } + if (selected === '__update__') { + try { + await management.updateManagedMcpServers(choice.destination); + cache?.invalidate(); + prompts.note('MCP servers reconciled.', 'MCP Servers'); + } catch (error) { + prompts.note(errorMessage(error), 'Error'); + } + continue; + } + + if (selected.startsWith('server:')) { + const name = selected.slice('server:'.length); + const config = servers[name]; + if (config) { + await serverDetail(choice, name, config, cache, dependencies); + } + } + } +} + +/** Manage project, user, and declared-profile MCP servers without shelling out to the CLI. */ +export async function runMcpServers( + context: TuiContext, + cache?: TuiCache, + dependencies: McpTuiDependencies = defaultDependencies, +): Promise { + try { + while (true) { + const choice = await selectDestination(context, dependencies); + if (!choice) return; + if ((await manageDestination(choice, cache, dependencies)) === 'back') + return; + } + } catch (error) { + dependencies.prompts.note(errorMessage(error), 'Error'); + } +} diff --git a/src/cli/tui/wizard.ts b/src/cli/tui/wizard.ts index bca8ccc7..48a01f88 100644 --- a/src/cli/tui/wizard.ts +++ b/src/cli/tui/wizard.ts @@ -1,7 +1,7 @@ -import * as p from '@clack/prompts'; +import { relative } from 'node:path'; import { settings } from '@clack/core'; +import * as p from '@clack/prompts'; import chalk from 'chalk'; -import { relative } from 'node:path'; import packageJson from '../../../package.json'; import { TuiCache } from './cache.js'; import { getTuiContext, type TuiContext } from './context.js'; @@ -11,12 +11,14 @@ const { select } = p; // Disable Escape key as cancel trigger to prevent terminal freezes. // Ctrl+C (\x03) still works for cancellation. settings.aliases.delete('escape'); -import { runSync } from './actions/sync.js'; -import { runStatus } from './actions/status.js'; -import { runBrowseMarketplaces, runPlugins } from './actions/plugins.js'; + +import { getUpdateNotice } from '../update-check.js'; import { runManageClients } from './actions/clients.js'; +import { runMcpServers } from './actions/mcp.js'; +import { runBrowseMarketplaces, runPlugins } from './actions/plugins.js'; import { runSkills } from './actions/skills.js'; -import { getUpdateNotice } from '../update-check.js'; +import { runStatus } from './actions/status.js'; +import { runSync } from './actions/sync.js'; export type MenuAction = | 'workspace' @@ -24,15 +26,17 @@ export type MenuAction = | 'plugins' | 'skills' | 'clients' + | 'mcp' | 'marketplace' | 'exit'; /** * Build context-aware menu options based on workspace state. - * Plugins, Skills, Clients, and Marketplaces are always visible. + * Plugins, Skills, Clients, MCP Servers, and Marketplaces are always visible. */ export function buildMenuOptions(context: TuiContext) { - const options: Array<{ label: string; value: MenuAction; hint?: string }> = []; + const options: Array<{ label: string; value: MenuAction; hint?: string }> = + []; if (context.needsSync) { options.push({ label: 'Sync plugins', value: 'sync', hint: 'sync needed' }); @@ -42,6 +46,7 @@ export function buildMenuOptions(context: TuiContext) { options.push({ label: 'Plugins', value: 'plugins' }); options.push({ label: 'Skills', value: 'skills' }); options.push({ label: 'Clients', value: 'clients' }); + options.push({ label: 'MCP Servers', value: 'mcp' }); options.push({ label: 'Marketplaces', value: 'marketplace' }); options.push({ label: 'Exit', value: 'exit' }); @@ -143,6 +148,9 @@ export async function runWizard(): Promise { case 'clients': await runManageClients(context, cache); break; + case 'mcp': + await runMcpServers(context, cache); + break; case 'marketplace': await runBrowseMarketplaces(context, cache); break; diff --git a/src/core/mcp-http-stdio-proxy.ts b/src/core/mcp-http-stdio-proxy.ts index e6d8866f..22dc9d47 100644 --- a/src/core/mcp-http-stdio-proxy.ts +++ b/src/core/mcp-http-stdio-proxy.ts @@ -1,7 +1,14 @@ import { spawn } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { constants as fsConstants } from 'node:fs'; -import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { + access, + mkdir, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises'; import { createServer, type IncomingMessage, @@ -322,6 +329,7 @@ function tryOpenBrowser(url: string): Promise { interface OAuthProviderOptions { callbackUrlReader?: OAuthCallbackUrlReader; + authorizationOutput?: (message: string) => void; allowAuthorization?: boolean; profile?: string; } @@ -339,6 +347,7 @@ class FileOAuthClientProvider implements OAuthClientProvider { private pendingAuth: Promise | undefined = undefined; private authorizationUnavailable = false; private readonly callbackUrlReader: OAuthCallbackUrlReader | undefined; + private readonly authorizationOutput: (message: string) => void; private readonly allowAuthorization: boolean; private readonly stateValue = randomUUID(); @@ -354,6 +363,7 @@ class FileOAuthClientProvider implements OAuthClientProvider { this.discoveryPath = join(cacheDir, 'discovery.json'); this.redirectUriValue = `http://127.0.0.1:${port}/callback`; this.callbackUrlReader = options.callbackUrlReader; + this.authorizationOutput = options.authorizationOutput ?? console.error; this.allowAuthorization = options.allowAuthorization ?? true; } @@ -555,9 +565,11 @@ class FileOAuthClientProvider implements OAuthClientProvider { }, AUTH_TIMEOUT_MS); server.on('error', (error) => settle('reject', error)); server.listen(this.port, '127.0.0.1', () => { - console.error('Opening browser for authorization...'); - console.error(`${AUTH_URL_LOG_PREFIX}${authorizationUrl.toString()}`); - console.error( + this.authorizationOutput('Opening browser for authorization...'); + this.authorizationOutput( + `${AUTH_URL_LOG_PREFIX}${authorizationUrl.toString()}`, + ); + this.authorizationOutput( this.callbackUrlReader ? 'Using a remote browser? Paste its callback URL in this terminal.' : 'Using a remote browser? Run `allagents mcp reauth ` in this workspace, then reconnect.', @@ -565,7 +577,7 @@ class FileOAuthClientProvider implements OAuthClientProvider { // Test-only escape hatch: e2e tests fetch the URL themselves against a local // dummy IdP, and skipping the real OS browser-open avoids ever launching one. if (process.env.ALLAGENTS_MCP_OAUTH_NO_BROWSER === '1') { - console.error( + this.authorizationOutput( 'Skipping automatic browser open (ALLAGENTS_MCP_OAUTH_NO_BROWSER=1).', ); } else { @@ -675,6 +687,7 @@ async function connectRemoteTransport( export interface ConnectHttpMcpServerOptions { headers?: Record; callbackUrlReader?: OAuthCallbackUrlReader; + authorizationOutput?: (message: string) => void; resetCredentials?: boolean; allowAuthorization?: boolean; profile?: string; @@ -684,20 +697,52 @@ export async function connectHttpMcpServer( serverUrl: string, options: ConnectHttpMcpServerOptions = {}, ): Promise { - if (options.resetCredentials) { - await rm(getMcpOAuthCacheDir(serverUrl, options.profile), { - recursive: true, - force: true, - }); + const cacheDir = getMcpOAuthCacheDir(serverUrl, options.profile); + const backupDir = options.resetCredentials + ? `${cacheDir}.reauth-backup-${randomUUID()}` + : undefined; + let hasBackup = false; + + if (backupDir) { + try { + await rename(cacheDir, backupDir); + hasBackup = true; + } catch (error) { + if ( + !(error instanceof Error && 'code' in error && error.code === 'ENOENT') + ) { + throw error; + } + } } - const { client, transport } = await connectRemoteTransport( - serverUrl, - options, - ); + try { - await transport.terminateSession(); - } finally { - await client.close(); + const { client, transport } = await connectRemoteTransport( + serverUrl, + options, + ); + try { + await transport.terminateSession(); + } finally { + await client.close(); + } + } catch (error) { + if (backupDir) { + try { + await rm(cacheDir, { recursive: true, force: true }); + if (hasBackup) await rename(backupDir, cacheDir); + } catch (restoreError) { + throw new AggregateError( + [error, restoreError], + 'MCP reauthentication failed and the previous credentials could not be restored', + ); + } + } + throw error; + } + + if (hasBackup && backupDir) { + await rm(backupDir, { recursive: true, force: true }); } } diff --git a/src/core/mcp-management.ts b/src/core/mcp-management.ts new file mode 100644 index 00000000..f55c8c5d --- /dev/null +++ b/src/core/mcp-management.ts @@ -0,0 +1,228 @@ +import { getHomeDir } from '../constants.js'; +import { + type McpServerConfig, + ProfileDeclarationSchema, +} from '../models/workspace-config.js'; +import { parseUserWorkspaceConfig } from '../utils/workspace-parser.js'; +import { + connectHttpMcpServer, + type OAuthCallbackUrlReader, +} from './mcp-http-stdio-proxy.js'; +import { + addMcpServer, + getMcpServer, + listMcpServers, + type McpDestination, + removeMcpServer, +} from './mcp-servers.js'; +import { + type SyncMcpOnlyResult, + syncMcpOnly, + syncUserMcpOnly, +} from './mcp-sync.js'; +import { + type ProfileApplyResult, + updateInstalledProfiles, +} from './profile/index.js'; + +export interface McpAuthorizationInteraction { + output(message: string): void; + readCallback: OAuthCallbackUrlReader; +} + +export type McpDestinationSync = + | { kind: 'mcp'; result: SyncMcpOnlyResult } + | { kind: 'profile'; result: ProfileApplyResult | null }; + +export interface AddManagedMcpServerRequest { + destination: McpDestination; + name: string; + config: McpServerConfig; + force?: boolean; + authorization?: McpAuthorizationInteraction; +} + +export interface UpdateManagedMcpServersOptions { + offline?: boolean; +} + +function destinationDisplay(destination: McpDestination): string { + switch (destination.kind) { + case 'project': + return 'workspace.yaml'; + case 'user': + return 'the user workspace'; + case 'profile': + return `profile '${destination.name}'`; + } +} + +async function validateProfileAddCandidate( + destination: McpDestination, + name: string, + config: McpServerConfig, +): Promise { + if (destination.kind !== 'profile') return; + + const workspace = await parseUserWorkspaceConfig(destination.configPath); + const profile = workspace.profiles?.[destination.name]; + if (!profile) { + throw new Error(`Profile '${destination.name}' is not declared`); + } + const validation = ProfileDeclarationSchema.safeParse({ + ...profile, + mcpServers: { + ...profile.mcpServers, + [name]: config, + }, + }); + if (!validation.success) { + const issues = validation.error.issues.map( + (issue) => ` - ${issue.path.join('.')}: ${issue.message}`, + ); + throw new Error(`Invalid MCP server config:\n${issues.join('\n')}`); + } +} + +async function connectConfiguredHttpServer( + destination: McpDestination, + config: Extract, + authorization: McpAuthorizationInteraction | undefined, + resetCredentials: boolean, +): Promise { + await connectHttpMcpServer(config.url, { + headers: config.headers ?? {}, + resetCredentials, + allowAuthorization: authorization !== undefined, + ...(destination.kind === 'profile' ? { profile: destination.name } : {}), + ...(authorization + ? { + authorizationOutput: authorization.output, + callbackUrlReader: authorization.readCallback, + } + : {}), + }); +} + +export async function updateManagedMcpServers( + destination: McpDestination, + options: UpdateManagedMcpServersOptions = {}, +): Promise { + const offline = options.offline ?? false; + if (destination.kind === 'project') { + const result = await syncMcpOnly(destination.workspacePath, { offline }); + if (!result.success) { + throw new Error(result.error ?? 'MCP sync failed'); + } + return { kind: 'mcp', result }; + } + if (destination.kind === 'user') { + const result = await syncUserMcpOnly({ offline }); + if (!result.success) { + throw new Error(result.error ?? 'MCP sync failed'); + } + return { kind: 'mcp', result }; + } + + let results: readonly ProfileApplyResult[]; + try { + results = await updateInstalledProfiles([destination.name], { + offline, + homeDir: getHomeDir(), + userConfigPath: destination.configPath, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message === `Profile '${destination.name}' is not installed`) { + return { kind: 'profile', result: null }; + } + throw new Error(message); + } + + const result = results[0]; + if (!result) { + throw new Error(`Profile '${destination.name}' was not reconciled`); + } + if (!result.success) { + throw new Error( + result.error ?? `Profile '${destination.name}' update failed`, + ); + } + return { kind: 'profile', result }; +} + +export async function addManagedMcpServer( + request: AddManagedMcpServerRequest, +): Promise<{ config: McpServerConfig; sync: McpDestinationSync }> { + const { destination, name, config, force = false, authorization } = request; + const existing = await getMcpServer(destination, name); + if (existing && !force) { + throw new Error( + `MCP server '${name}' already exists in ${destinationDisplay(destination)}. Use --force to replace it.`, + ); + } + await validateProfileAddCandidate(destination, name, config); + + if ('url' in config) { + await connectConfiguredHttpServer( + destination, + config, + authorization, + false, + ); + } + + const addResult = await addMcpServer(destination, name, config, { + force, + proxy: + 'url' in config + ? { + ...(config.clients === undefined + ? {} + : { clients: config.clients }), + } + : false, + }); + if (!addResult.success) { + throw new Error(addResult.error ?? 'Unknown error'); + } + + const sync = await updateManagedMcpServers(destination, { offline: true }); + return { config: addResult.config ?? config, sync }; +} + +export async function removeManagedMcpServer( + destination: McpDestination, + name: string, +): Promise { + const removeResult = await removeMcpServer(destination, name); + if (!removeResult.success) { + throw new Error(removeResult.error ?? 'Unknown error'); + } + return updateManagedMcpServers(destination, { offline: true }); +} + +export async function reauthenticateManagedMcpServer( + destination: McpDestination, + name: string, + authorization: McpAuthorizationInteraction, +): Promise { + const config = await getMcpServer(destination, name); + if (!config) { + throw new Error( + `MCP server '${name}' is not defined in ${destinationDisplay(destination)}`, + ); + } + if (!('url' in config)) { + throw new Error( + `MCP server '${name}' uses stdio and cannot be reauthenticated`, + ); + } + await connectConfiguredHttpServer(destination, config, authorization, true); +} + +export async function listManagedMcpServers( + destination: McpDestination, +): Promise> { + return listMcpServers(destination); +} diff --git a/tests/e2e/mcp-proxy-oauth.test.ts b/tests/e2e/mcp-proxy-oauth.test.ts index baa25b0e..1830351a 100644 --- a/tests/e2e/mcp-proxy-oauth.test.ts +++ b/tests/e2e/mcp-proxy-oauth.test.ts @@ -170,6 +170,31 @@ describe('mcp proxy OAuth e2e', () => { expect(dummy.authorizeCallCount).toBe(1); }, 15000); + test('routes authorization guidance through the configured output', async () => { + dummy = await startDummyMcpOAuthServer(); + const output: string[] = []; + + await connectHttpMcpServer(dummy.mcpUrl, { + authorizationOutput: (message) => output.push(message), + callbackUrlReader: async ({ authorizationUrl }) => { + const response = await fetch(authorizationUrl, { + redirect: 'manual', + }); + const location = response.headers.get('location'); + expect(location).toBeTruthy(); + return new URL(location!, authorizationUrl).toString(); + }, + }); + + expect(output[0]).toBe('Opening browser for authorization...'); + expect(output[1]).toStartWith( + 'If the browser does not open, visit: http', + ); + expect(output[2]).toBe( + 'Using a remote browser? Paste its callback URL in this terminal.', + ); + }, 15000); + test('forces a fresh OAuth flow when credentials are reset', async () => { dummy = await startDummyMcpOAuthServer(); const authorize = async ({ authorizationUrl }: { authorizationUrl: URL }) => { @@ -191,6 +216,41 @@ describe('mcp proxy OAuth e2e', () => { expect(dummy.authorizeCallCount).toBe(2); }, 15000); + test('restores previous credentials when a reset authorization fails', async () => { + dummy = await startDummyMcpOAuthServer(); + const authorize = async ({ authorizationUrl }: { authorizationUrl: URL }) => { + const response = await fetch(authorizationUrl, { redirect: 'manual' }); + const location = response.headers.get('location'); + expect(location).toBeTruthy(); + return new URL(location!, authorizationUrl).toString(); + }; + + await connectHttpMcpServer(dummy.mcpUrl, { + authorizationOutput: () => {}, + callbackUrlReader: authorize, + }); + const tokensPath = join(getMcpOAuthCacheDir(dummy.mcpUrl), 'tokens.json'); + const previousTokens = readFileSync(tokensPath, 'utf8'); + + await expect( + connectHttpMcpServer(dummy.mcpUrl, { + authorizationOutput: () => {}, + callbackUrlReader: async () => { + throw new Error('Authorization cancelled'); + }, + resetCredentials: true, + }), + ).rejects.toThrow('Authorization cancelled'); + + expect(readFileSync(tokensPath, 'utf8')).toBe(previousTokens); + const authorizationCount = dummy.authorizeCallCount; + await connectHttpMcpServer(dummy.mcpUrl, { + allowAuthorization: false, + authorizationOutput: () => {}, + }); + expect(dummy.authorizeCallCount).toBe(authorizationCount); + }, 15000); + test('isolates OAuth reuse and reset between profiles and ordinary scope', async () => { dummy = await startDummyMcpOAuthServer(); const authorize = async ({ authorizationUrl }: { authorizationUrl: URL }) => { From a099f91837b29bca5955fdc8c94ba4bed245d3d3 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 19 Sep 2026 19:37:01 +1000 Subject: [PATCH 10/16] fix(tui): clarify MCP server management --- CHANGELOG.md | 5 +- docs/src/content/docs/docs/reference/cli.mdx | 13 +- src/cli/tui/__tests__/mcp.test.ts | 130 +++++++++++++++++-- src/cli/tui/__tests__/wizard.test.ts | 15 ++- src/cli/tui/actions/mcp.ts | 62 ++++++--- src/cli/tui/actions/skills.ts | 4 +- src/cli/tui/actions/sync.ts | 24 ++-- src/cli/tui/wizard.ts | 8 +- src/core/mcp-management.ts | 20 ++- 9 files changed, 212 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26337b5e..d10c8ccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,8 +47,9 @@ through cached `npx`, so managed MCP connections do not require a global AllAgents installation. - Full MCP server management in the interactive TUI, including destination - selection, add, reauthenticate, update, and remove flows for project, user, - and named-profile declarations. + selection, listing, inspection, add, reauthenticate, and remove flows for + project, user, and named-profile declarations. Client configuration updates + automatically after mutations, with a contextual retry when an update fails. - Pi and OMP as file-sync clients at project and user scope, including native runtime skill paths and agent instructions. diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index b5181373..1ec174df 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -466,11 +466,11 @@ resolves to user scope and explicit `--scope project` is rejected. Use `--scope` and `--profile` are mutually exclusive. Run `allagents` without arguments and choose **MCP Servers** to manage the same -project, user, and named-profile destinations interactively. The TUI supports -listing and inspecting declarations, adding HTTP or stdio servers, -reauthenticating HTTP servers, reconciling generated client configuration, and -removing declarations. Credential values remain hidden from summaries and -selection screens. +project, user, and named-profile destinations interactively. Each destination +lists its servers directly, with add, inspect, reauthenticate, and remove +actions. Client configuration updates automatically after mutations; if an +update fails after the declaration changes, the TUI offers a contextual retry. +Credential values remain hidden from summaries and selection screens. Codex and Copilot materialize the selected destination at these exact paths: @@ -482,7 +482,8 @@ Codex and Copilot materialize the selected destination at these exact paths: ### mcp add -Add a new MCP server to the selected destination and immediately reconcile it. +Add a new MCP server to the selected destination and immediately update its +client configuration. For HTTP servers, AllAgents connects first, completes OAuth when required, and routes selected clients through its built-in MCP client. diff --git a/src/cli/tui/__tests__/mcp.test.ts b/src/cli/tui/__tests__/mcp.test.ts index 76f31dbe..3309ba54 100644 --- a/src/cli/tui/__tests__/mcp.test.ts +++ b/src/cli/tui/__tests__/mcp.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'bun:test'; -import type { - AddManagedMcpServerRequest, - McpDestinationSync, +import { + type AddManagedMcpServerRequest, + type McpDestinationSync, + McpUpdateError, } from '../../../core/mcp-management.js'; import type { McpDestination } from '../../../core/mcp-servers.js'; import type { @@ -139,6 +140,8 @@ function dependencies(options: { onAdd?: (request: AddManagedMcpServerRequest) => Promise | void; onReauthenticate?: (destination: McpDestination, name: string) => void; onRemove?: (destination: McpDestination, name: string) => void; + removeError?: Error; + updateErrors?: Error[]; onUpdate?: (destination: McpDestination) => void; }): McpTuiDependencies { const management: McpManagementApi = { @@ -154,10 +157,13 @@ function dependencies(options: { }, async removeManagedMcpServer(selected, name) { options.onRemove?.(selected, name); + if (options.removeError) throw options.removeError; return completedSync; }, async updateManagedMcpServers(selected) { options.onUpdate?.(selected); + const error = options.updateErrors?.shift(); + if (error) throw error; return completedSync; }, }; @@ -187,7 +193,7 @@ describe('runMcpServers', () => { 'http://127.0.0.1:3117/callback?code=complete&state=state'; const callbackSignal = new AbortController().signal; const prompts = new ScriptedPrompts({ - selects: ['profile:work', '__add__', 'http', '__back__'], + selects: ['profile:work', '__add__', 'http', '__back__', '__back__'], texts: [ 'remote', 'https://mcp.example.test/path', @@ -258,6 +264,60 @@ describe('runMcpServers', () => { ).toEqual(['project', 'user', 'profile:work', '__back__']); }); + it('lists servers first and uses Back to return to the destination chooser', async () => { + const prompts = new ScriptedPrompts({ + selects: ['project', '__back__', 'user', '__back__', '__back__'], + }); + + await runMcpServers( + context(), + undefined, + dependencies({ + prompts, + servers: { + zeta: { type: 'stdio', command: 'zeta' }, + alpha: { type: 'http', url: 'https://example.test/mcp' }, + }, + }), + ); + + const destinationRequests = prompts.selectRequests.filter( + (request) => request.message === 'MCP server destination', + ); + expect(destinationRequests).toHaveLength(3); + + const serverRequest = prompts.selectRequests.find( + (request) => request.message === 'Project MCP Servers', + ); + expect(serverRequest?.options).toEqual([ + { label: 'alpha', value: 'server:alpha', hint: 'HTTP' }, + { label: 'zeta', value: 'server:zeta', hint: 'stdio' }, + { label: '+ Add server', value: '__add__' }, + { label: 'Back', value: '__back__' }, + ]); + }); + + it('exits MCP management when the server-list prompt is cancelled', async () => { + const prompts = new ScriptedPrompts({ + selects: ['user', CANCEL], + }); + + await runMcpServers( + context(false), + undefined, + dependencies({ + prompts, + servers: { remote: { type: 'http', url: 'https://example.test/mcp' } }, + }), + ); + + expect(prompts.selectRequests.map((request) => request.message)).toEqual([ + 'MCP server destination', + 'User MCP Servers', + ]); + expect(prompts.notes).toEqual([]); + }); + it('offers reauthentication only for HTTP servers and calls management for HTTP', async () => { const prompts = new ScriptedPrompts({ selects: [ @@ -268,6 +328,7 @@ describe('runMcpServers', () => { 'reauthenticate', 'back', '__back__', + '__back__', ], }); const reauthenticated: Array<{ @@ -312,13 +373,12 @@ describe('runMcpServers', () => { expect(count()).toBe(1); }); - it('confirms removal, updates through management, and invalidates after each mutation', async () => { + it('confirms removal and invalidates after the mutation', async () => { const prompts = new ScriptedPrompts({ - selects: ['project', 'server:local', 'remove', '__update__', '__back__'], + selects: ['project', 'server:local', 'remove', '__back__', '__back__'], confirms: [true], }); const removals: string[] = []; - const updates: McpDestination[] = []; const { cache, count } = cacheCounter(); await runMcpServers( @@ -331,22 +391,58 @@ describe('runMcpServers', () => { expect(selected.kind).toBe('project'); removals.push(name); }, + }), + ); + + expect(removals).toEqual(['local']); + expect(count()).toBe(1); + }); + + it('offers a retry when removal persisted but its client update failed', async () => { + const prompts = new ScriptedPrompts({ + selects: ['project', 'server:local', 'remove', '__back__', '__back__'], + confirms: [true, true, true], + }); + const updates: McpDestination[] = []; + const { cache, count } = cacheCounter(); + + await runMcpServers( + context(), + cache, + dependencies({ + prompts, + servers: { local: { type: 'stdio', command: 'node' } }, + removeError: new McpUpdateError(new Error('Client update failed')), + updateErrors: [new Error('Client update still failing')], onUpdate(selected) { updates.push(selected); }, }), ); - expect(removals).toEqual(['local']); - expect(updates).toHaveLength(1); - expect(updates[0]?.kind).toBe('project'); - expect(count()).toBe(2); + expect(updates).toEqual([ + expect.objectContaining({ kind: 'project' }), + expect.objectContaining({ kind: 'project' }), + ]); + expect(prompts.notes).toContainEqual({ + message: 'Client update failed', + title: 'Update Error', + }); + expect(prompts.notes).toContainEqual({ + message: 'Client update still failing', + title: 'Update Error', + }); + expect(prompts.notes).toContainEqual({ + message: 'Client configuration updated.', + title: 'MCP Servers', + }); + expect(count()).toBe(1); }); it('does not mutate when add or removal prompts are cancelled', async () => { let addCalls = 0; const addPrompts = new ScriptedPrompts({ - selects: ['user', '__add__', '__back__'], + selects: ['user', '__add__', '__back__', '__back__'], texts: [CANCEL], }); await runMcpServers( @@ -362,7 +458,14 @@ describe('runMcpServers', () => { let removeCalls = 0; const removePrompts = new ScriptedPrompts({ - selects: ['user', 'server:local', 'remove', 'back', '__back__'], + selects: [ + 'user', + 'server:local', + 'remove', + 'back', + '__back__', + '__back__', + ], confirms: [false], }); await runMcpServers( @@ -390,6 +493,7 @@ describe('runMcpServers', () => { 'server:secure-stdio', 'back', '__back__', + '__back__', ], }); await runMcpServers( diff --git a/src/cli/tui/__tests__/wizard.test.ts b/src/cli/tui/__tests__/wizard.test.ts index fbd01e3d..e77d55df 100644 --- a/src/cli/tui/__tests__/wizard.test.ts +++ b/src/cli/tui/__tests__/wizard.test.ts @@ -42,27 +42,28 @@ describe('buildMenuOptions', () => { } }); - describe('sync option', () => { - it('should show sync when sync is needed', () => { + describe('update option', () => { + it('should show Update when an update is needed', () => { const context = makeContext({ hasWorkspace: true, needsSync: true }); const values = actionValues(context); expect(values).toContain('sync'); }); - it('should show sync needed hint', () => { + it('should use Update terminology', () => { const context = makeContext({ hasWorkspace: true, needsSync: true }); const options = buildMenuOptions(context); - const syncOption = options.find((o) => o.value === 'sync'); - expect(syncOption?.hint).toBe('sync needed'); + const updateOption = options.find((o) => o.value === 'sync'); + expect(updateOption?.label).toBe('Update'); + expect(updateOption?.hint).toBe('update needed'); }); - it('should NOT show sync when not needed', () => { + it('should NOT show Update when not needed', () => { const context = makeContext({ hasWorkspace: true, needsSync: false }); const values = actionValues(context); expect(values).not.toContain('sync'); }); - it('should NOT show sync without workspace', () => { + it('should NOT show Update without workspace', () => { const context = makeContext({ hasWorkspace: false }); const values = actionValues(context); expect(values).not.toContain('sync'); diff --git a/src/cli/tui/actions/mcp.ts b/src/cli/tui/actions/mcp.ts index 4cce7c56..d97e9a47 100644 --- a/src/cli/tui/actions/mcp.ts +++ b/src/cli/tui/actions/mcp.ts @@ -3,6 +3,7 @@ import { addManagedMcpServer, listManagedMcpServers, type McpAuthorizationInteraction, + McpUpdateError, reauthenticateManagedMcpServer, removeManagedMcpServer, updateManagedMcpServers, @@ -305,6 +306,36 @@ function authorizationInteraction( }; } +async function handleMutationError( + choice: DestinationChoice, + error: unknown, + cache: TuiCache | undefined, + dependencies: McpTuiDependencies, +): Promise { + const { prompts, management } = dependencies; + if (!(error instanceof McpUpdateError)) { + prompts.note(errorMessage(error), 'Error'); + return false; + } + + cache?.invalidate(); + prompts.note(errorMessage(error), 'Update Error'); + while (true) { + const retry = await prompts.confirm({ + message: 'Client configuration update failed. Retry update now?', + }); + if (prompts.isCancel(retry) || !retry) return true; + + try { + await management.updateManagedMcpServers(choice.destination); + prompts.note('Client configuration updated.', 'MCP Servers'); + return true; + } catch (retryError) { + prompts.note(errorMessage(retryError), 'Update Error'); + } + } +} + async function addServer( choice: DestinationChoice, existing: Record, @@ -390,7 +421,7 @@ async function addServer( 'MCP Servers', ); } catch (error) { - prompts.note(errorMessage(error), 'Error'); + await handleMutationError(choice, error, cache, dependencies); } } @@ -486,7 +517,8 @@ async function serverDetail( prompts.note(`Removed ${terminalSafe(name)}.`, 'MCP Servers'); return; } catch (error) { - prompts.note(errorMessage(error), 'Error'); + if (await handleMutationError(choice, error, cache, dependencies)) + return; } } } @@ -496,7 +528,7 @@ async function manageDestination( choice: DestinationChoice, cache: TuiCache | undefined, dependencies: McpTuiDependencies, -): Promise<'back' | 'change'> { +): Promise<'destination' | 'exit'> { const { prompts, management } = dependencies; while (true) { let servers: Record; @@ -504,41 +536,29 @@ async function manageDestination( servers = await management.listManagedMcpServers(choice.destination); } catch (error) { prompts.note(errorMessage(error), 'Error'); - return 'change'; + return 'destination'; } const names = Object.keys(servers).sort(); const selected = await prompts.select({ - message: `MCP Servers [${destinationLabel(choice.destination)}]`, + message: `${choice.label} MCP Servers`, options: [ - { label: '+ Add server', value: '__add__' }, - { label: 'Update / reconcile', value: '__update__' }, ...names.map((name) => ({ label: terminalSafe(name), value: `server:${name}`, hint: 'url' in (servers[name] as McpServerConfig) ? 'HTTP' : 'stdio', })), - { label: 'Change destination', value: '__change__' }, + { label: '+ Add server', value: '__add__' }, { label: 'Back', value: '__back__' }, ], }); - if (prompts.isCancel(selected) || selected === '__back__') return 'back'; - if (selected === '__change__') return 'change'; + if (prompts.isCancel(selected)) return 'exit'; + if (selected === '__back__') return 'destination'; if (selected === '__add__') { await addServer(choice, servers, cache, dependencies); continue; } - if (selected === '__update__') { - try { - await management.updateManagedMcpServers(choice.destination); - cache?.invalidate(); - prompts.note('MCP servers reconciled.', 'MCP Servers'); - } catch (error) { - prompts.note(errorMessage(error), 'Error'); - } - continue; - } if (selected.startsWith('server:')) { const name = selected.slice('server:'.length); @@ -560,7 +580,7 @@ export async function runMcpServers( while (true) { const choice = await selectDestination(context, dependencies); if (!choice) return; - if ((await manageDestination(choice, cache, dependencies)) === 'back') + if ((await manageDestination(choice, cache, dependencies)) === 'exit') return; } } catch (error) { diff --git a/src/cli/tui/actions/skills.ts b/src/cli/tui/actions/skills.ts index f2c665e1..c4667afb 100644 --- a/src/cli/tui/actions/skills.ts +++ b/src/cli/tui/actions/skills.ts @@ -252,14 +252,14 @@ async function runToggleSkills( } // Auto-sync affected scopes - s.message('Syncing...'); + s.message('Updating...'); if (changedProject && context.workspacePath) { await syncWorkspace(context.workspacePath); } if (changedUser) { await syncUserWorkspace(); } - s.stop('Skills updated and synced'); + s.stop('Skills updated'); cache?.invalidate(); const changes: string[] = []; diff --git a/src/cli/tui/actions/sync.ts b/src/cli/tui/actions/sync.ts index 53d3adb1..dd16b269 100644 --- a/src/cli/tui/actions/sync.ts +++ b/src/cli/tui/actions/sync.ts @@ -15,23 +15,23 @@ export async function runSync(context: TuiContext): Promise { // Sync project-level plugins if workspace exists if (context.hasWorkspace && context.workspacePath) { - s.start('Syncing project plugins...'); + s.start('Updating project...'); const result = await syncWorkspace(context.workspacePath); if (result.error) { if (context.userPluginCount > 0) { - s.message('Syncing user plugins...'); + s.message('Updating user configuration...'); } else { - s.stop('Sync failed'); + s.stop('Update failed'); } - p.note(result.error, 'Sync Error'); + p.note(result.error, 'Update Error'); } else { projectLines = formatVerboseSyncLines(result); if (context.userPluginCount > 0) { - s.message('Syncing user plugins...'); + s.message('Updating user configuration...'); } else { - s.stop('Sync complete'); - p.note(projectLines.join('\n'), 'Project Sync'); + s.stop('Update complete'); + p.note(projectLines.join('\n'), 'Project Update'); return; } } @@ -40,21 +40,21 @@ export async function runSync(context: TuiContext): Promise { // Sync user-level plugins if (context.userPluginCount > 0) { if (!context.hasWorkspace || !context.workspacePath) { - s.start('Syncing user plugins...'); + s.start('Updating user configuration...'); } const userResult = await syncUserWorkspace(); - s.stop('Sync complete'); + s.stop('Update complete'); // Show project results first (deferred from above) if (projectLines) { - p.note(projectLines.join('\n'), 'Project Sync'); + p.note(projectLines.join('\n'), 'Project Update'); } if (userResult.error) { - p.note(userResult.error, 'User Sync Error'); + p.note(userResult.error, 'User Update Error'); } else { const lines = formatVerboseSyncLines(userResult); - p.note(lines.join('\n'), 'User Sync'); + p.note(lines.join('\n'), 'User Update'); } } } catch (error) { diff --git a/src/cli/tui/wizard.ts b/src/cli/tui/wizard.ts index 48a01f88..c87a0ac6 100644 --- a/src/cli/tui/wizard.ts +++ b/src/cli/tui/wizard.ts @@ -39,7 +39,7 @@ export function buildMenuOptions(context: TuiContext) { []; if (context.needsSync) { - options.push({ label: 'Sync plugins', value: 'sync', hint: 'sync needed' }); + options.push({ label: 'Update', value: 'sync', hint: 'update needed' }); } options.push({ label: 'Workspace', value: 'workspace' }); @@ -64,7 +64,7 @@ function buildCompactSummary(context: TuiContext): string { parts.push(`${context.userPluginCount} user`); parts.push(`${context.marketplaceCount} marketplaces`); if (context.needsSync) { - parts.push(chalk.yellow('sync needed')); + parts.push(chalk.yellow('update needed')); } return parts.join(', '); } @@ -87,9 +87,9 @@ function buildSummary(context: TuiContext): string { lines.push(`Marketplaces: ${context.marketplaceCount}`); if (context.needsSync) { - lines.push(`Sync: ${chalk.yellow('needed')}`); + lines.push(`Update: ${chalk.yellow('needed')}`); } else if (context.hasWorkspace) { - lines.push(`Sync: ${chalk.green('up to date')}`); + lines.push(`Update: ${chalk.green('up to date')}`); } return lines.join('\n'); diff --git a/src/core/mcp-management.ts b/src/core/mcp-management.ts index f55c8c5d..e79290c8 100644 --- a/src/core/mcp-management.ts +++ b/src/core/mcp-management.ts @@ -46,6 +46,13 @@ export interface UpdateManagedMcpServersOptions { offline?: boolean; } +export class McpUpdateError extends Error { + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }); + this.name = 'McpUpdateError'; + } +} + function destinationDisplay(destination: McpDestination): string { switch (destination.kind) { case 'project': @@ -187,7 +194,12 @@ export async function addManagedMcpServer( throw new Error(addResult.error ?? 'Unknown error'); } - const sync = await updateManagedMcpServers(destination, { offline: true }); + let sync: McpDestinationSync; + try { + sync = await updateManagedMcpServers(destination, { offline: true }); + } catch (error) { + throw new McpUpdateError(error); + } return { config: addResult.config ?? config, sync }; } @@ -199,7 +211,11 @@ export async function removeManagedMcpServer( if (!removeResult.success) { throw new Error(removeResult.error ?? 'Unknown error'); } - return updateManagedMcpServers(destination, { offline: true }); + try { + return await updateManagedMcpServers(destination, { offline: true }); + } catch (error) { + throw new McpUpdateError(error); + } } export async function reauthenticateManagedMcpServer( From de5f1407f515ead4895253c9b77b2a9639d35fdc Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 19 Sep 2026 19:38:11 +1000 Subject: [PATCH 11/16] docs(tui): strengthen dogfood guidance --- AGENTS.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6e47df4d..54c522d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,6 +185,9 @@ bun run build ## Interactive Testing ### agent-tui -- Use `agent-tui` when you need to exercise interactive terminal behavior manually. -- Prefer testing the built CLI inside a temporary workspace so the interaction matches real user conditions. -- When documenting manual verification, record the exact command, the temp workspace setup, and what terminal behavior you confirmed. +- Use `agent-tui` to exercise interactive terminal behavior in the built CLI inside a temporary workspace. +- Dogfood the complete navigation loop, not only the successful mutation. Capture each decision screen before acting and read it as a first-time user. +- Apply a one-screen, one-decision check: a resource list contains resources plus add/back; a resource detail contains actions for that resource; scope or destination changes happen by returning to the chooser. If one menu mixes resource selection, navigation, and maintenance operations, simplify it. +- Use established product vocabulary in every label and status message. Prefer the public operation name, such as **Update**, over internal terms such as sync or reconcile. +- Exercise Back and Ctrl+C from every menu level, plus mutation success, failure, and retry paths. Confirm each transition lands on the screen a user would expect without losing the selected scope. +- When documenting manual verification, record the exact command, temporary workspace setup, screenshots or observed screens, and the transitions confirmed. From d8af1dd350f91812babf7d99d80401ab0ac326b6 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 19 Sep 2026 19:52:02 +1000 Subject: [PATCH 12/16] docs(tui): move dogfood details out of resolver --- AGENTS.md | 8 +--- docs/agent-guides/tui-dogfooding.md | 73 +++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 docs/agent-guides/tui-dogfooding.md diff --git a/AGENTS.md b/AGENTS.md index 54c522d9..81540e59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,10 +184,4 @@ bun run build ## Interactive Testing -### agent-tui -- Use `agent-tui` to exercise interactive terminal behavior in the built CLI inside a temporary workspace. -- Dogfood the complete navigation loop, not only the successful mutation. Capture each decision screen before acting and read it as a first-time user. -- Apply a one-screen, one-decision check: a resource list contains resources plus add/back; a resource detail contains actions for that resource; scope or destination changes happen by returning to the chooser. If one menu mixes resource selection, navigation, and maintenance operations, simplify it. -- Use established product vocabulary in every label and status message. Prefer the public operation name, such as **Update**, over internal terms such as sync or reconcile. -- Exercise Back and Ctrl+C from every menu level, plus mutation success, failure, and retry paths. Confirm each transition lands on the screen a user would expect without losing the selected scope. -- When documenting manual verification, record the exact command, temporary workspace setup, screenshots or observed screens, and the transitions confirmed. +- TUI navigation, prompt, copy, status, or recovery changes: read and follow [`docs/agent-guides/tui-dogfooding.md`](docs/agent-guides/tui-dogfooding.md) before implementation and final verification. diff --git a/docs/agent-guides/tui-dogfooding.md b/docs/agent-guides/tui-dogfooding.md new file mode 100644 index 00000000..0fc667bf --- /dev/null +++ b/docs/agent-guides/tui-dogfooding.md @@ -0,0 +1,73 @@ +# TUI Dogfooding + +Use this guide for any change to interactive CLI navigation, prompts, labels, status messages, or failure recovery. + +## Goal + +Prove that the built TUI is understandable to a first-time user, not only that its underlying mutation succeeds. + +## Setup + +1. Build the CLI. +2. Create isolated temporary project and HOME directories. +3. Seed the smallest realistic configuration that exposes every changed state. +4. Launch the built CLI with `agent-tui`. +5. Capture each changed decision screen before interacting with it. + +Never dogfood against a real user workspace when an isolated fixture can exercise the behavior. + +## Confusion pass + +For every changed screen, state: + +- the object the user is currently managing; +- the single decision the screen asks them to make; +- where Back and Ctrl+C will land; +- whether the selected scope or destination remains visible and intact. + +Apply one screen, one decision: + +- A resource list contains resources plus Add and Back. +- A resource detail contains actions for that resource plus Back. +- Scope or destination changes happen by returning to the chooser. +- Maintenance mechanics stay automatic. Show a retry only when automatic recovery fails. + +If a menu mixes resource selection, navigation, and maintenance operations, simplify it before continuing. + +## Language pass + +Use the established public term for each operation in labels, progress messages, results, errors, and documentation. Internal implementation terms do not belong in user-facing copy. For example, use **Update** rather than sync or reconcile. + +Read the complete screen, not only the changed label. Adjacent hints, summaries, and success or error messages must use the same vocabulary. + +## Interaction pass + +Exercise every changed path that applies: + +1. Enter the flow from the main menu. +2. Move forward through each chooser and detail screen. +3. Use Back from every changed level. +4. Use Ctrl+C from every changed level. +5. Complete a successful mutation and verify both the next screen and filesystem result. +6. Trigger a realistic failure and verify the error leaves a clear recovery path. +7. Exercise retry, repeated retry failure, explicit cancellation, and eventual success when retry behavior changed. + +A transition passes only when it lands on the screen a user would predict without losing or silently changing scope. + +## Durable coverage + +Keep regression tests for navigation state, scope preservation, cancellation, mutation boundaries, and failure recovery. Test exact copy only when the wording is a deliberate product contract; use the manual confusion pass for general prose quality. + +## Completion evidence + +Record in the PR description: + +- the exact built command; +- temporary workspace and HOME setup; +- selections and transitions exercised; +- screenshots or the text of each observed decision screen; +- resulting configuration or filesystem state; +- failure and retry behavior checked; +- cleanup performed. + +Dogfooding is complete only when the full changed journey passes the confusion, language, interaction, and filesystem checks. From d220b3063483de7b7a37edaf9f2230096db4fef0 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 19 Sep 2026 20:40:31 +1000 Subject: [PATCH 13/16] docs(tui): route automation through agent-tui skill --- AGENTS.md | 2 +- docs/agent-guides/tui-dogfooding.md | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 81540e59..6bb25326 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,4 +184,4 @@ bun run build ## Interactive Testing -- TUI navigation, prompt, copy, status, or recovery changes: read and follow [`docs/agent-guides/tui-dogfooding.md`](docs/agent-guides/tui-dogfooding.md) before implementation and final verification. +- TUI navigation, prompt, copy, status, or recovery changes: use the installed `agent-tui` skill for terminal automation, then apply the AllAgents-specific acceptance criteria in [`docs/agent-guides/tui-dogfooding.md`](docs/agent-guides/tui-dogfooding.md) before implementation and final verification. diff --git a/docs/agent-guides/tui-dogfooding.md b/docs/agent-guides/tui-dogfooding.md index 0fc667bf..1e0759d4 100644 --- a/docs/agent-guides/tui-dogfooding.md +++ b/docs/agent-guides/tui-dogfooding.md @@ -1,6 +1,12 @@ # TUI Dogfooding -Use this guide for any change to interactive CLI navigation, prompts, labels, status messages, or failure recovery. +This guide defines AllAgents-specific acceptance criteria for changes to interactive CLI navigation, prompts, labels, status messages, or failure recovery. + +## Tooling boundary + +Use the installed `agent-tui` skill for terminal automation. That skill owns installation checks, command selection, session lifecycle, snapshots, actions, waits, assertions, and cleanup. Follow its current CLI workflow rather than reproducing command recipes here. + +This guide owns the product-specific UX questions, interaction coverage, and evidence required for AllAgents. ## Goal @@ -11,8 +17,7 @@ Prove that the built TUI is understandable to a first-time user, not only that i 1. Build the CLI. 2. Create isolated temporary project and HOME directories. 3. Seed the smallest realistic configuration that exposes every changed state. -4. Launch the built CLI with `agent-tui`. -5. Capture each changed decision screen before interacting with it. +4. Use the `agent-tui` skill to drive the built CLI and capture each changed decision screen before interacting with it. Never dogfood against a real user workspace when an isolated fixture can exercise the behavior. From 7c7835a4c2f31b01eda8f3fe32a03d3907e838cc Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 19 Sep 2026 22:10:36 +1000 Subject: [PATCH 14/16] feat(skill): add AllAgents CLI guidance --- .claude-plugin/marketplace.json | 5 ++ .github/plugin/marketplace.json | 5 ++ CHANGELOG.md | 11 +-- README.md | 7 ++ .../2026-02-03-workspace-sync-both-scopes.md | 2 +- ...eat-skill-update-deletion-handling-plan.md | 2 +- ...09-17-1211-perf-update-no-op-paths-plan.md | 2 +- ...-interactive-install-scope-clients-plan.md | 6 +- plugins/allagents/.claude-plugin/plugin.json | 10 +++ plugins/allagents/skills/allagents/SKILL.md | 82 +++++++++++++++++++ tests/e2e/mcp-proxy-command.test.ts | 7 -- 11 files changed, 119 insertions(+), 20 deletions(-) create mode 100644 plugins/allagents/.claude-plugin/plugin.json create mode 100644 plugins/allagents/skills/allagents/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4877afab..ab415136 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,6 +7,11 @@ "url": "https://allagents.dev" }, "plugins": [ + { + "name": "allagents", + "description": "End-user guidance for managing AllAgents through its CLI", + "source": "./plugins/allagents" + }, { "name": "deepwiki", "description": "AI-generated documentation for GitHub repositories", diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index b70726ea..5b4e6806 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -7,6 +7,11 @@ "url": "https://allagents.dev" }, "plugins": [ + { + "name": "allagents", + "description": "End-user guidance for managing AllAgents through its CLI", + "source": "./plugins/allagents" + }, { "name": "deepwiki", "description": "AI-generated documentation for GitHub repositories", diff --git a/CHANGELOG.md b/CHANGELOG.md index d10c8ccc..65f64b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,13 +12,6 @@ **Migration**: Remove `--proxy` from `mcp add` calls. Replace `allagents mcp auth ` with `allagents mcp reauth `. -- **Structured CLI help**: Replaced the agent-specific `--agent-help` flag with - composable `--help --json` output at the root, command-group, and individual - command levels. - - **Migration**: Replace `allagents --agent-help ` with - `allagents --help --json`. - - **Plugin Git ref terminology**: Renamed workspace plugin `pin` to `ref`, CLI `--pin` to `--ref`, and sync-state `pinnedRef` to `requestedRef`. Inline `owner/repo@ref` sources are unchanged. @@ -38,6 +31,10 @@ ### Added +- Added a first-party AllAgents skill that discovers current command contracts + through `--help --json` and guides workspace, plugin, skill, profile, and MCP + operations without relying on memorized flags. + - Added the official TradingView MCP plugin with OAuth-backed access to market data, analytics, watchlists, alerts, news, and screeners. - Added automatic OAuth login to `allagents mcp add` and named credential diff --git a/README.md b/README.md index 23db0eca..29b9d4e9 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,13 @@ npx allagents update No cloning required — AllAgents fetches the `workspace.yaml` directly from GitHub and sets up everything. +Install the end-user AllAgents skill when you want your coding agent to operate +the CLI using its current machine-readable command contracts: + +```bash +npx allagents plugin install allagents@allagentsdev/allagents --scope user +``` + ## How It Works 1. **Configure** your workspace with repos, plugins, and target clients in `workspace.yaml` diff --git a/docs/plans/2026-02-03-workspace-sync-both-scopes.md b/docs/plans/2026-02-03-workspace-sync-both-scopes.md index 9de15b08..86d44e0d 100644 --- a/docs/plans/2026-02-03-workspace-sync-both-scopes.md +++ b/docs/plans/2026-02-03-workspace-sync-both-scopes.md @@ -446,7 +446,7 @@ In `src/cli/metadata/workspace.ts`, remove the `--scope` example and option: **Step 2: Run help text tests** -Run: `bun test tests/e2e/cli-help.test.ts tests/e2e/cli-enriched-help.test.ts tests/e2e/cli-agent-help.test.ts` +Run: `bun test tests/e2e/cli-help.test.ts tests/e2e/cli-enriched-help.test.ts tests/unit/cli/structured-help.test.ts` Expected: PASS (help tests should not hardcode --scope for sync) **Step 3: Commit** diff --git a/docs/plans/2026-08-10-001-feat-skill-update-deletion-handling-plan.md b/docs/plans/2026-08-10-001-feat-skill-update-deletion-handling-plan.md index 22f91856..a65513c1 100644 --- a/docs/plans/2026-08-10-001-feat-skill-update-deletion-handling-plan.md +++ b/docs/plans/2026-08-10-001-feat-skill-update-deletion-handling-plan.md @@ -222,7 +222,7 @@ flowchart TB - **Goal:** Expose the orchestration as a predictable interactive and scriptable command. - **Requirements:** R1-R3, R6, R8-R12; F1-F3. - **Dependencies:** U1, U2. -- **Files:** `src/cli/commands/plugin-skills.ts`, `src/cli/metadata/plugin-skills.ts`, `src/cli/skill-arg-normalizer.ts`, `tests/unit/cli/skill-update.test.ts`, `tests/unit/cli/agent-help.test.ts`. +- **Files:** `src/cli/commands/plugin-skills.ts`, `src/cli/metadata/plugin-skills.ts`, `src/cli/skill-arg-normalizer.ts`, `tests/unit/cli/skill-update.test.ts`, `tests/unit/cli/structured-help.test.ts`. - **Approach:** Register `update`; validate scope/filter values; use Clack for scope and confirmation prompts; group warnings once per physical refresh unit with every impacted scope/plugin listed; collect all decisions before execution; interpret No as retain/skip and cancel as pre-mutation abort; render one concise summary; produce the same result model and exit contract through JSON without UI noise. - **Patterns to follow:** Existing skill search scope picker, global JSON envelope helpers, and enriched command metadata. - **Test scenarios:** diff --git a/docs/plans/2026-09-17-1211-perf-update-no-op-paths-plan.md b/docs/plans/2026-09-17-1211-perf-update-no-op-paths-plan.md index 7eb76159..1b3ae48c 100644 --- a/docs/plans/2026-09-17-1211-perf-update-no-op-paths-plan.md +++ b/docs/plans/2026-09-17-1211-perf-update-no-op-paths-plan.md @@ -188,7 +188,7 @@ flowchart LR ### System-Wide Impact -- **CLI and agents:** Human output, `--json`, exit codes, and agent-help schemas stay stable. Automation still sees successful no-op checks as existing update outcomes. +- **CLI and agents:** Human output, `--json`, exit codes, and structured-help schemas stay stable. Automation still sees successful no-op checks as existing update outcomes. - **Filesystem state:** Direct plugin and skill no-ops avoid persistent checkout work while retaining existing scope synchronization. Marketplace no-ops retain the registry timestamp write required by compatibility. - **Dependency direction:** CLI/TUI callers create a neutral context and pass it into domain updaters; domain updaters consume context/Git/identity helpers. The leaf identity helper and Git module never import plugin, marketplace, skill, or CLI/TUI modules. - **Scope ownership:** Remote and physical checkout facts can be shared, but each consumer independently derives public output and writes only its owning registry. External-plugin `changed` is the OR of successful marketplace and external-checkout physical changes without changing current public precedence or sync eligibility. diff --git a/docs/plans/2026-09-19-1012-feat-interactive-install-scope-clients-plan.md b/docs/plans/2026-09-19-1012-feat-interactive-install-scope-clients-plan.md index 797fa50e..fe2dc4e8 100644 --- a/docs/plans/2026-09-19-1012-feat-interactive-install-scope-clients-plan.md +++ b/docs/plans/2026-09-19-1012-feat-interactive-install-scope-clients-plan.md @@ -80,7 +80,7 @@ The feature is primarily UX. Existing marketplace resolution, declaration instal - A shared summary-data model rendered by CLI and TUI. - Localized project/user declaration persistence for client overrides while preserving object fields. - Crash-safe publication of the targeted project/user config via the repository’s established same-directory temporary-write-and-rename pattern. -- Existing command metadata, agent help, user docs, changelog, focused regression tests, and built-CLI dogfood. +- Existing command metadata, structured help, user docs, changelog, focused regression tests, and built-CLI dogfood. ### Out of Scope @@ -231,7 +231,7 @@ Only these areas require code-review attention beyond dogfooding: - `src/cli/tui/actions/plugins.ts` - `src/cli/metadata/plugin.ts` - `tests/unit/cli/tui-plugin-install.test.ts` (new) -- `tests/unit/cli/agent-help.test.ts` +- `tests/unit/cli/structured-help.test.ts` - `tests/e2e/plugin-install-options.test.ts` (new) **Changes:** @@ -281,7 +281,7 @@ Only these areas require code-review attention beyond dogfooding: - Document chooser order, first-config default consequence, per-plugin overrides, flags, and non-interactive behavior. - Add a valid `plugins[].clients` example using `source`. -- Update command metadata and generated agent help. +- Update command metadata and structured help. - Remove temporary dogfood workspaces and any obsolete plan artifacts after implementation. ## Verification Contract diff --git a/plugins/allagents/.claude-plugin/plugin.json b/plugins/allagents/.claude-plugin/plugin.json new file mode 100644 index 00000000..2c47f39e --- /dev/null +++ b/plugins/allagents/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "allagents", + "description": "End-user guidance for managing workspaces, plugins, skills, profiles, clients, and MCP servers with the AllAgents CLI.", + "author": { + "name": "AllAgents" + }, + "version": "1.0.0", + "category": "development", + "homepage": "https://github.com/allagentsdev/allagents/tree/main/plugins/allagents" +} diff --git a/plugins/allagents/skills/allagents/SKILL.md b/plugins/allagents/skills/allagents/SKILL.md new file mode 100644 index 00000000..a5e97837 --- /dev/null +++ b/plugins/allagents/skills/allagents/SKILL.md @@ -0,0 +1,82 @@ +--- +name: allagents +description: Manage AllAgents workspaces, plugins, skills, client targets, global profiles, and MCP servers through the allagents CLI. Use when a user asks to initialize, inspect, update, or configure AllAgents; install or remove plugins or skills; manage profiles; or add, inspect, authenticate, update, or remove MCP servers. Resolve current command syntax from machine-readable help instead of relying on remembered flags. +--- + +# AllAgents CLI + +Use an AllAgents CLI runner as the authoritative mutation boundary. Prefer its commands over hand-editing AllAgents declarations or generated client files. + +## Resolve the CLI runner + +Choose one runner at the start of the task and use it for every discovery, execution, and verification command: + +1. If `allagents` is on `PATH`, use `allagents`. +2. Otherwise, if `npx` is available, use `npx --yes allagents`. +3. If neither is available, stop and ask the user to install AllAgents or Node.js with npm. + +Examples below use `allagents`; substitute the complete `npx --yes allagents` prefix when that is the selected runner. Do not mix runners within one operation. + +## Resolve the current command contract + +1. Confirm the installed version with `allagents --version`. +2. Discover top-level commands with `allagents --help --json`. +3. Narrow to the relevant group with `allagents --help --json`. +4. Before execution, inspect the leaf command with `allagents --help --json`. +5. Use the returned positionals, options, examples, interaction requirements, output schema, and JSON field allowlist as the source of truth. + +Do not rely on memorized flags when structured help is available. Do not invent aliases or combine options that the leaf metadata does not advertise. + +## Choose the interface + +- Run `allagents` without arguments in an interactive terminal when the user wants to browse and make choices in the TUI. +- Use direct commands when the requested operation and destination are already known. +- For automation, use `--json`, explicit selectors, and non-interactive confirmation options advertised by the leaf help. +- Use `--json=` only with fields listed by that command. Use `--jq` only with JSON output. +- Treat the process exit code and structured success envelope as authoritative. Verify mutations with the corresponding list, get, or status command. + +## Route the request + +| Intent | Inspect first | +| --- | --- | +| Initialize or reconcile a workspace | `allagents --help --json`, then the selected workspace or update command help | +| Inspect declared and live state | `allagents status --help --json` | +| Install, list, update, or remove plugins | `allagents plugin --help --json` | +| Discover, install, enable, disable, or update skills | `allagents skill --help --json` | +| Add, inspect, authenticate, update, or remove MCP servers | `allagents mcp --help --json` | +| Install, inspect, update, or remove global profiles | `allagents profile --help --json` | +| Update a globally installed CLI | `allagents self update --help --json`; with the npx runner, use `npx --yes allagents@latest` and skip self-update | + +Always continue from group help to the chosen leaf command before executing it. + +## Destination and ownership rules + +AllAgents keeps project, ordinary user, and named-profile state separate. + +- Use the destination explicitly requested by the user. +- When a mutating command supports destination flags, pass the explicit project, user, or profile selector advertised by its help. +- Never guess a named profile. +- Do not treat generated client files as declarations. Change the AllAgents-owned declaration through the CLI, then let AllAgents update client configuration. +- If an update fails after a declaration mutation, report that split state and use the command's documented update or retry path. Do not silently rewrite generated files. + +## MCP workflow + +1. Select exactly one project, user, or named-profile destination. +2. Inspect `allagents mcp --help --json`, then the selected MCP leaf command help. +3. List the destination before destructive or authentication-changing operations. +4. Execute the mutation with an explicit destination when the help supports one. +5. Verify the result with the corresponding MCP list or get command in the same destination. + +Never print credential values. Prefer environment-variable references for secret headers or environment values when the command contract supports them. Preserve OAuth browser and callback interaction when structured help marks it as required. + +## Safety + +- Review declared setup commands before running any workspace setup action. Setup is an explicit trust boundary. +- Use a dry-run option before a material mutation whenever the leaf help advertises one. +- Do not edit or delete user-owned client configuration outside AllAgents ownership records. +- Do not cross project, user, or profile boundaries to make a command succeed. +- Stop on validation, authentication, ownership, or partial-update errors; report the selected destination and the recovery command exposed by structured help. + +## Completion + +Report the command path used, selected destination and clients, structured result, and verification command. For a mutation, completion requires the declared state and the corresponding live or generated client state to agree, or an explicit partial-update error with its recovery path. diff --git a/tests/e2e/mcp-proxy-command.test.ts b/tests/e2e/mcp-proxy-command.test.ts index 3fdc379b..29bec45f 100644 --- a/tests/e2e/mcp-proxy-command.test.ts +++ b/tests/e2e/mcp-proxy-command.test.ts @@ -171,13 +171,6 @@ describe('mcp public command help', () => { expect(result.stdout).not.toContain('"when_to_use"'); }); - test('rejects the removed legacy structured-help flag', () => { - const result = runCli(['--agent-help']); - - expect(result.exitCode).not.toBe(0); - expect(result.stdout).not.toContain('"commands"'); - }); - test('exposes the full command tree through structured JSON help', () => { const result = runCli(['--help', '--json']); From 2b9a3d44728e1b895d56268281daedc3fa0cabdc Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sun, 20 Sep 2026 08:12:12 +1000 Subject: [PATCH 15/16] refactor(skill): keep CLI help authoritative --- plugins/allagents/skills/allagents/SKILL.md | 79 +++++++-------------- 1 file changed, 24 insertions(+), 55 deletions(-) diff --git a/plugins/allagents/skills/allagents/SKILL.md b/plugins/allagents/skills/allagents/SKILL.md index a5e97837..c2eb2fd7 100644 --- a/plugins/allagents/skills/allagents/SKILL.md +++ b/plugins/allagents/skills/allagents/SKILL.md @@ -5,11 +5,11 @@ description: Manage AllAgents workspaces, plugins, skills, client targets, globa # AllAgents CLI -Use an AllAgents CLI runner as the authoritative mutation boundary. Prefer its commands over hand-editing AllAgents declarations or generated client files. +The installed AllAgents CLI owns the current command surface and operational contract. This skill only resolves the runner, loads that contract, and applies it. Do not maintain a second command reference here. ## Resolve the CLI runner -Choose one runner at the start of the task and use it for every discovery, execution, and verification command: +Choose one runner at the start of the task and use it for discovery, execution, and verification: 1. If `allagents` is on `PATH`, use `allagents`. 2. Otherwise, if `npx` is available, use `npx --yes allagents`. @@ -17,66 +17,35 @@ Choose one runner at the start of the task and use it for every discovery, execu Examples below use `allagents`; substitute the complete `npx --yes allagents` prefix when that is the selected runner. Do not mix runners within one operation. -## Resolve the current command contract +## Load the current contract -1. Confirm the installed version with `allagents --version`. -2. Discover top-level commands with `allagents --help --json`. -3. Narrow to the relevant group with `allagents --help --json`. -4. Before execution, inspect the leaf command with `allagents --help --json`. -5. Use the returned positionals, options, examples, interaction requirements, output schema, and JSON field allowlist as the source of truth. +1. Confirm the selected runner with `allagents --version`. +2. Discover the current top-level surface with `allagents --help --json`. +3. Select a command group from that response and load it with `allagents --help --json`. +4. Before execution, load the selected leaf with `allagents --help --json`. +5. Follow the returned `when_to_use`, positionals, options, examples, interaction requirement, output schema, and JSON field allowlist. -Do not rely on memorized flags when structured help is available. Do not invent aliases or combine options that the leaf metadata does not advertise. +The structured response is authoritative. Do not rely on remembered flags, copied examples, aliases, destination behavior, or mutation semantics. If the installed CLI does not advertise an operation, do not invent it. -## Choose the interface +## Execute through the discovered surface -- Run `allagents` without arguments in an interactive terminal when the user wants to browse and make choices in the TUI. -- Use direct commands when the requested operation and destination are already known. -- For automation, use `--json`, explicit selectors, and non-interactive confirmation options advertised by the leaf help. -- Use `--json=` only with fields listed by that command. Use `--jq` only with JSON output. -- Treat the process exit code and structured success envelope as authoritative. Verify mutations with the corresponding list, get, or status command. +- Run `allagents` without arguments in an interactive terminal only when the user wants to browse and choose in the TUI. +- Use the discovered direct command when the operation is already known. +- For automation, use `--json` plus only the explicit selectors and non-interactive options advertised by the leaf help. +- Use `--json=` only with fields in the leaf's JSON allowlist. Use `--jq` only with JSON output. +- Prefer an advertised CLI command over hand-editing an AllAgents declaration or generated client file. +- Preserve the exact scope, destination, profile, clients, and other ownership selectors requested by the user. If the request is insufficient and the CLI requires a choice, ask rather than guessing. +- Never echo credentials. Supply sensitive values only through mechanisms advertised by the current leaf help. -## Route the request +Treat the process exit code and structured result as authoritative. After a mutation, rediscover and run the relevant read-only list, get, or status command against the same selectors. Do not claim success from a config write alone when the CLI reports a partial update or failed client reconciliation. -| Intent | Inspect first | -| --- | --- | -| Initialize or reconcile a workspace | `allagents --help --json`, then the selected workspace or update command help | -| Inspect declared and live state | `allagents status --help --json` | -| Install, list, update, or remove plugins | `allagents plugin --help --json` | -| Discover, install, enable, disable, or update skills | `allagents skill --help --json` | -| Add, inspect, authenticate, update, or remove MCP servers | `allagents mcp --help --json` | -| Install, inspect, update, or remove global profiles | `allagents profile --help --json` | -| Update a globally installed CLI | `allagents self update --help --json`; with the npx runner, use `npx --yes allagents@latest` and skip self-update | +## Recover from errors -Always continue from group help to the chosen leaf command before executing it. - -## Destination and ownership rules - -AllAgents keeps project, ordinary user, and named-profile state separate. - -- Use the destination explicitly requested by the user. -- When a mutating command supports destination flags, pass the explicit project, user, or profile selector advertised by its help. -- Never guess a named profile. -- Do not treat generated client files as declarations. Change the AllAgents-owned declaration through the CLI, then let AllAgents update client configuration. -- If an update fails after a declaration mutation, report that split state and use the command's documented update or retry path. Do not silently rewrite generated files. - -## MCP workflow - -1. Select exactly one project, user, or named-profile destination. -2. Inspect `allagents mcp --help --json`, then the selected MCP leaf command help. -3. List the destination before destructive or authentication-changing operations. -4. Execute the mutation with an explicit destination when the help supports one. -5. Verify the result with the corresponding MCP list or get command in the same destination. - -Never print credential values. Prefer environment-variable references for secret headers or environment values when the command contract supports them. Preserve OAuth browser and callback interaction when structured help marks it as required. - -## Safety - -- Review declared setup commands before running any workspace setup action. Setup is an explicit trust boundary. -- Use a dry-run option before a material mutation whenever the leaf help advertises one. -- Do not edit or delete user-owned client configuration outside AllAgents ownership records. -- Do not cross project, user, or profile boundaries to make a command succeed. -- Stop on validation, authentication, ownership, or partial-update errors; report the selected destination and the recovery command exposed by structured help. +- Read the structured error before retrying. +- If command syntax is rejected, reload root, group, and leaf help; do not fall back to stale syntax. +- If a mutation may have partially applied, inspect current state before any retry. +- Follow only recovery operations exposed by the installed CLI. ## Completion -Report the command path used, selected destination and clients, structured result, and verification command. For a mutation, completion requires the declared state and the corresponding live or generated client state to agree, or an explicit partial-update error with its recovery path. +Report the selected runner, command path, explicit selectors, structured result, and verification command. Completion requires the declared state and corresponding live or generated state to agree, or an explicit partial-update result with the CLI-advertised recovery path. From d1bcc09f9d39f9083d76f9ed8bb9cc1ee459a466 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sun, 20 Sep 2026 08:38:18 +1000 Subject: [PATCH 16/16] feat(cli): disclose structured help progressively --- CHANGELOG.md | 9 +- docs/src/content/docs/docs/reference/cli.mdx | 15 ++- plugins/allagents/skills/allagents/SKILL.md | 10 +- src/cli/index.ts | 42 ++++++- src/cli/json-output.ts | 14 ++- src/cli/structured-help.ts | 88 ++++++++++--- tests/e2e/mcp-proxy-command.test.ts | 125 +++++++++++++++---- tests/unit/cli/index-json-fields.test.ts | 23 ++++ 8 files changed, 272 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65f64b26..5091c716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,9 +31,12 @@ ### Added -- Added a first-party AllAgents skill that discovers current command contracts - through `--help --json` and guides workspace, plugin, skill, profile, and MCP - operations without relying on memorized flags. +- Added a thin first-party AllAgents skill that treats the installed CLI as + authoritative and follows its progressive `--help --json` indexes and leaf + contracts instead of relying on memorized commands. +- Added progressive machine-readable CLI help: concise root and group indexes + lead to leaf contracts with usage guidance, interaction requirements, + expected output, options, examples, and output schemas. - Added the official TradingView MCP plugin with OAuth-backed access to market data, analytics, watchlists, alerts, news, and screeners. diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index 1ec174df..a547c764 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -14,9 +14,18 @@ allagents mcp --help --json allagents mcp add --help --json ``` -Structured help includes usage guidance, options, examples, output schemas, and -interaction requirements when available. `--help --json` and -`--json --help` are equivalent when they follow the command path. +Structured help is progressively disclosed: + +1. Root help returns a concise index of top-level commands and groups. +2. Group help returns only its immediate commands and nested groups. +3. Each index entry provides a `help_command` for the next level. +4. Leaf help returns the complete contract: when to use the command, + positionals, options, examples, interaction requirements, expected output, + output schema, and JSON field allowlist. + +This keeps command discovery small while making side effects, automation +requirements, and output expectations available before execution. `--help +--json` and `--json --help` are equivalent when they follow the command path. Field selection with `--json=` is not supported for help output. ## Top-Level Commands diff --git a/plugins/allagents/skills/allagents/SKILL.md b/plugins/allagents/skills/allagents/SKILL.md index c2eb2fd7..6d40e09d 100644 --- a/plugins/allagents/skills/allagents/SKILL.md +++ b/plugins/allagents/skills/allagents/SKILL.md @@ -15,15 +15,15 @@ Choose one runner at the start of the task and use it for discovery, execution, 2. Otherwise, if `npx` is available, use `npx --yes allagents`. 3. If neither is available, stop and ask the user to install AllAgents or Node.js with npm. -Examples below use `allagents`; substitute the complete `npx --yes allagents` prefix when that is the selected runner. Do not mix runners within one operation. +Examples and returned `help_command` values use the canonical `allagents` token. When `npx --yes allagents` is the selected runner, replace only that leading token before executing every discovery, execution, and verification command. Do not mix runners within one operation. ## Load the current contract 1. Confirm the selected runner with `allagents --version`. -2. Discover the current top-level surface with `allagents --help --json`. -3. Select a command group from that response and load it with `allagents --help --json`. -4. Before execution, load the selected leaf with `allagents --help --json`. -5. Follow the returned `when_to_use`, positionals, options, examples, interaction requirement, output schema, and JSON field allowlist. +2. Discover the concise top-level index with `allagents --help --json`. +3. Choose an entry, rewrite its leading runner token when required, and execute its `help_command`; group responses reveal only their immediate children. +4. Continue through nested groups until a leaf command returns its full contract. +5. Follow the leaf's `when_to_use`, positionals, options, examples, interaction requirement, expected output, output schema, and JSON field allowlist. The structured response is authoritative. Do not rely on remembered flags, copied examples, aliases, destination behavior, or mutation semantics. If the installed CLI does not advertise an operation, do not invent it. diff --git a/src/cli/index.ts b/src/cli/index.ts index d29516dd..db896da6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -13,7 +13,7 @@ import { syncCmd, workspaceCmd, } from './commands/workspace.js'; -import { conciseSubcommands } from './help.js'; +import { type AgentCommandMeta, conciseSubcommands } from './help.js'; import { extractJqFlag, extractJsonFlag, @@ -46,6 +46,33 @@ const app = conciseSubcommands({ }, }); +function hasHelpFlag( + args: readonly string[], + meta: AgentCommandMeta | undefined, +): boolean { + const valueOptions = new Set( + (meta?.options ?? []) + .filter((option) => option.type === 'string') + .flatMap((option) => [option.flag, option.short].filter(Boolean)), + ); + let consumesNext = false; + + for (const arg of args) { + if (consumesNext) { + consumesNext = false; + continue; + } + if (arg === '--') break; + if (valueOptions.has(arg)) { + consumesNext = true; + continue; + } + if (arg === '--help' || arg === '-h') return true; + } + + return false; +} + const rawArgs = process.argv.slice(2); const { args: argsNoJson, json, jsonFields } = extractJsonFlag(rawArgs); const { args: argsNoJq, jqExpr } = extractJqFlag(argsNoJson); @@ -59,11 +86,18 @@ if (jqExpr && !json) { process.exit(2); } -// Validate `--json=` against the meta allowlist for the invoked command. +// Help owns its field-selection error so the structured formatter can explain +// that help output is not filterable. Option values and positionals that happen +// to equal a help token remain ordinary command input. +const requestsHelp = hasHelpFlag(finalArgs, commandMeta); let validatedFields: string[] | undefined; if (jsonFields) { - const result = validateJsonFields(jsonFields, commandMeta); - validatedFields = result ? [...result] : undefined; + if (requestsHelp) { + validatedFields = [...jsonFields]; + } else { + const result = validateJsonFields(jsonFields, commandMeta); + validatedFields = result ? [...result] : undefined; + } } setJsonMode(json, { diff --git a/src/cli/json-output.ts b/src/cli/json-output.ts index 65910cf2..4db3bfc2 100644 --- a/src/cli/json-output.ts +++ b/src/cli/json-output.ts @@ -166,6 +166,15 @@ export function extractJqFlag(args: string[]): { return { args: next, jqExpr: expr }; } +export function jsonFieldAllowlist( + meta: AgentCommandMeta | undefined, +): readonly string[] { + if (meta?.jsonFields && meta.jsonFields.length > 0) { + return meta.jsonFields; + } + return Object.keys(meta?.outputSchema ?? {}); +} + /** * Validate requested `--json=` against a meta's allowlist. Exits with * a sorted "Available fields" message on any unknown field. @@ -177,9 +186,8 @@ export function validateJsonFields( meta: AgentCommandMeta | undefined, ): readonly string[] | undefined { if (!fields || fields.length === 0) return undefined; - const allow = meta?.jsonFields; - if (!allow || allow.length === 0) { - // No allowlist declared → accept any field (no validation). + const allow = jsonFieldAllowlist(meta); + if (allow.length === 0) { return fields; } const unknown = fields.find((f) => !allow.includes(f)); diff --git a/src/cli/structured-help.ts b/src/cli/structured-help.ts index 630774f3..47536719 100644 --- a/src/cli/structured-help.ts +++ b/src/cli/structured-help.ts @@ -1,6 +1,10 @@ import type { HelpFormatter } from 'cmd-ts'; import type { AgentCommandMeta } from './help.js'; -import { formatJsonValue, getJsonFields } from './json-output.js'; +import { + formatJsonValue, + getJsonFields, + jsonFieldAllowlist, +} from './json-output.js'; import { mcpAddMeta, mcpGetMeta, @@ -103,6 +107,19 @@ const registeredCommands: RegisteredCommand[] = [ { command: 'profile remove', meta: profileRemoveMeta }, ]; +const groupDescriptions: Readonly> = { + workspace: 'Manage workspace lifecycle, synchronization, and repositories', + 'workspace repo': 'Manage repositories declared in the current workspace', + mcp: 'Manage MCP servers in project, user, and profile destinations', + plugin: 'Manage plugins, marketplaces, and plugin-scoped skills', + 'plugin marketplace': 'Manage plugin marketplace registrations and contents', + 'plugin skills': + 'Manage skills through the plugin compatibility command path', + skill: 'Discover, install, configure, and update skills', + self: 'Manage the installed AllAgents CLI', + profile: 'Manage declared and installed global agent profiles', +}; + function formatStructuredHelp( meta: AgentCommandMeta, command = meta.command, @@ -111,6 +128,8 @@ function formatStructuredHelp( command, description: meta.description, when_to_use: meta.whenToUse, + expected_output: meta.expectedOutput, + interaction: meta.interaction ?? 'none', }; if (meta.positionals && meta.positionals.length > 0) { result.positionals = meta.positionals; @@ -122,12 +141,52 @@ function formatStructuredHelp( if (meta.outputSchema) { result.output_schema = meta.outputSchema; } - if (meta.interaction) { - result.interaction = meta.interaction; + result.json_fields = [...jsonFieldAllowlist(meta)]; + return result; +} + +function helpCommand(command: string): string { + return `allagents ${command} --help --json`; +} + +function formatIndexEntry(command: string): Record { + const exact = registeredCommands.find((entry) => entry.command === command); + if (exact) { + return { + command, + kind: 'command', + description: exact.meta.description, + when_to_use: exact.meta.whenToUse, + help_command: helpCommand(command), + }; } - if (meta.jsonFields && meta.jsonFields.length > 0) { - result.json_fields = [...meta.jsonFields]; + + return { + command, + kind: 'group', + description: + groupDescriptions[command] ?? `Commands grouped under ${command}`, + help_command: helpCommand(command), + }; +} + +function immediateCommandIndex(commandPath: string): Record[] { + const prefix = commandPath ? `${commandPath} ` : ''; + const seen = new Set(); + const result: Record[] = []; + + for (const registered of registeredCommands) { + if (!registered.command.startsWith(prefix)) continue; + const remainder = registered.command.slice(prefix.length); + const next = remainder.split(' ')[0]; + if (!next) continue; + + const command = commandPath ? `${commandPath} ${next}` : next; + if (seen.has(command)) continue; + seen.add(command); + result.push(formatIndexEntry(command)); } + return result; } @@ -174,9 +233,8 @@ function buildStructuredHelp( version, description: 'CLI tool for managing multi-repo AI agent workspaces with plugin synchronization', - commands: registeredCommands.map(({ command, meta }) => - formatStructuredHelp(meta, command), - ), + next: 'Choose a command or group and run its help_command for the next level.', + commands: immediateCommandIndex(''), }; } @@ -187,15 +245,15 @@ function buildStructuredHelp( return formatStructuredHelp(match.meta, match.command); } - const matches = registeredCommands.filter((command) => - command.command.startsWith(`${commandPath} `), - ); - if (matches.length > 0) { + const commands = immediateCommandIndex(commandPath); + if (commands.length > 0) { return { name: commandPath, - commands: matches.map(({ command, meta }) => - formatStructuredHelp(meta, command), - ), + description: + groupDescriptions[commandPath] ?? + `Commands grouped under ${commandPath}`, + next: 'Choose a command or group and run its help_command for the next level.', + commands, }; } diff --git a/tests/e2e/mcp-proxy-command.test.ts b/tests/e2e/mcp-proxy-command.test.ts index 29bec45f..14db69b1 100644 --- a/tests/e2e/mcp-proxy-command.test.ts +++ b/tests/e2e/mcp-proxy-command.test.ts @@ -36,14 +36,41 @@ describe('mcp public command help', () => { expect(result.stdout).not.toContain('Expose a remote HTTP MCP server locally over stdio'); }); - test('exposes add and reauth through structured JSON help', () => { + test('exposes complete leaf context for add and reauth', () => { const addResult = runCli(['mcp', 'add', '--help', '--json']); const reauthResult = runCli(['--json', 'mcp', 'reauth', '-h']); expect(addResult.exitCode).toBe(0); - expect(JSON.parse(addResult.stdout).command).toBe('mcp add'); + expect(JSON.parse(addResult.stdout)).toMatchObject({ + command: 'mcp add', + description: expect.any(String), + when_to_use: expect.any(String), + expected_output: expect.stringContaining('reconciles installed clients'), + interaction: 'conditional', + positionals: expect.arrayContaining([ + expect.objectContaining({ name: 'name', required: true }), + expect.objectContaining({ name: 'commandOrUrl', required: true }), + ]), + options: expect.arrayContaining([ + expect.objectContaining({ flag: '--scope' }), + expect.objectContaining({ flag: '--profile' }), + ]), + examples: expect.arrayContaining([ + expect.stringContaining('allagents mcp add'), + ]), + output_schema: expect.objectContaining({ + destination: expect.any(Object), + name: 'string', + }), + json_fields: ['destination', 'name', 'config', 'mcpResults', 'sync'], + }); expect(reauthResult.exitCode).toBe(0); - expect(JSON.parse(reauthResult.stdout).command).toBe('mcp reauth'); + expect(JSON.parse(reauthResult.stdout)).toMatchObject({ + command: 'mcp reauth', + interaction: 'required', + expected_output: expect.stringContaining('Clears cached OAuth credentials'), + json_fields: [], + }); }); test('keeps exact bare command help human-readable', () => { @@ -89,8 +116,14 @@ describe('mcp public command help', () => { expect(result.stdout.trim()).toBe('"allagents"'); }); - test('covers workspace aliases and workspace-only command metadata', () => { + test('progressively discloses workspace commands and nested repo commands', () => { const groupResult = runCli(['workspace', '--help', '--json']); + const repoGroupResult = runCli([ + 'workspace', + 'repo', + '--help', + '--json', + ]); const repoResult = runCli([ 'workspace', 'repo', @@ -102,23 +135,50 @@ describe('mcp public command help', () => { expect(groupResult.exitCode).toBe(0); const group = JSON.parse(groupResult.stdout) as { - commands: Array<{ command: string }>; + commands: Array<{ + command: string; + kind: 'command' | 'group'; + help_command: string; + }>; }; - expect(group.commands.map(({ command }) => command)).toEqual([ - 'workspace init', - 'workspace setup', - 'workspace sync', - 'workspace status', - 'workspace prune', + expect(group.commands).toEqual([ + expect.objectContaining({ command: 'workspace init', kind: 'command' }), + expect.objectContaining({ command: 'workspace setup', kind: 'command' }), + expect.objectContaining({ command: 'workspace sync', kind: 'command' }), + expect.objectContaining({ command: 'workspace status', kind: 'command' }), + expect.objectContaining({ command: 'workspace prune', kind: 'command' }), + expect.objectContaining({ + command: 'workspace repo', + kind: 'group', + help_command: 'allagents workspace repo --help --json', + }), + ]); + for (const command of group.commands) { + expect(command.help_command).toBe( + `allagents ${command.command} --help --json`, + ); + } + expect(repoGroupResult.exitCode).toBe(0); + const repoGroup = JSON.parse(repoGroupResult.stdout) as { + commands: Array<{ command: string; help_command: string }>; + }; + expect(repoGroup.commands.map(({ command }) => command)).toEqual([ 'workspace repo add', 'workspace repo remove', 'workspace repo list', ]); + for (const command of repoGroup.commands) { + expect(command.help_command).toBe( + `allagents ${command.command} --help --json`, + ); + } expect(repoResult.exitCode).toBe(0); expect(JSON.parse(repoResult.stdout)).toMatchObject({ command: 'workspace repo add', positionals: [{ name: 'path', required: true }], output_schema: { repo: 'string | null' }, + expected_output: expect.any(String), + interaction: 'none', }); }); @@ -171,23 +231,46 @@ describe('mcp public command help', () => { expect(result.stdout).not.toContain('"when_to_use"'); }); - test('exposes the full command tree through structured JSON help', () => { + test('exposes a concise root index before group and leaf details', () => { const result = runCli(['--help', '--json']); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout) as { name: string; - commands: Array<{ command: string }>; + commands: Array<{ + command: string; + kind: 'command' | 'group'; + help_command: string; + options?: unknown; + output_schema?: unknown; + }>; }; expect(parsed.name).toBe('allagents'); - expect( - parsed.commands.some((command) => command.command === 'mcp add'), - ).toBe(true); - expect( - parsed.commands.some( - (command) => command.command === 'plugin skills list', - ), - ).toBe(true); + expect(parsed.commands.map(({ command }) => command)).toEqual([ + 'init', + 'update', + 'status', + 'workspace', + 'mcp', + 'plugin', + 'skill', + 'self', + 'profile', + ]); + expect(parsed.commands).toContainEqual( + expect.objectContaining({ + command: 'mcp', + kind: 'group', + help_command: 'allagents mcp --help --json', + }), + ); + for (const command of parsed.commands) { + expect(command.help_command).toBe( + `allagents ${command.command} --help --json`, + ); + expect(command.options).toBeUndefined(); + expect(command.output_schema).toBeUndefined(); + } }); test('rejects field selection for structured JSON help', () => { diff --git a/tests/unit/cli/index-json-fields.test.ts b/tests/unit/cli/index-json-fields.test.ts index ad830040..99b38b30 100644 --- a/tests/unit/cli/index-json-fields.test.ts +++ b/tests/unit/cli/index-json-fields.test.ts @@ -25,4 +25,27 @@ describe('CLI JSON field validation', () => { expect(proc.stderr.toString()).toContain('Available fields:'); expect(proc.stdout.toString()).toBe(''); }); + + test('derives JSON fields from the command output schema', () => { + const proc = Bun.spawnSync( + [ + 'bun', + 'run', + cliEntry, + '--json=definitely-invalid', + 'mcp', + 'list', + ], + { stdout: 'pipe', stderr: 'pipe' }, + ); + + expect(proc.exitCode).toBe(2); + expect(proc.stderr.toString()).toContain( + 'Unknown JSON field: "definitely-invalid"', + ); + expect(proc.stderr.toString()).toContain(' destination'); + expect(proc.stderr.toString()).toContain(' servers'); + expect(proc.stderr.toString()).toContain(' total'); + expect(proc.stdout.toString()).toBe(''); + }); });