From c88eefe240a67b7e162f1c357eca96428bc0943b Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:38:03 +0000 Subject: [PATCH] feat(native-chat): manage MCP servers from /mcp in Claude and Codex chats /mcp in a Claude or Codex chat now opens an MCP servers panel instead of sending the text to the agent, where SDK mode only printed a one-line summary. The panel lists each server's status and lets you sign in, reconnect, and turn servers on or off. Claude goes through the Agent SDK's control requests on the running chat, including the runtime-only mcp_authenticate and mcp_oauth_callback_url. Codex goes through app-server: mcpServerStatus/list, mcpServer/oauth/login and config/batchWrite, with short-lived app-servers for chats at rest. Sign-in opens the browser; when the agent runs on another host, the user pastes the address the browser lands on. --- .../mobile-native-chat-composer-menu.ts | 2 +- .../claude-at-rest-commands.ts | 2 +- .../claude-agent-sdk-control-requests.ts | 86 ++++++ .../claude-structured-agent-definition.ts | 1 + .../structured/claude-structured-mcp.ts | 90 ++++++ .../claude-structured-session-adapter.ts | 3 + .../structured/catalog/codex-skill-list.ts | 2 +- .../codex-structured-agent-definition.ts | 1 + .../codex-mcp-enabled-write.ts | 46 +++ .../codex-mcp-server-status.ts | 149 ++++++++++ .../codex-mcp-sign-in-callback.ts | 51 ++++ .../codex-provider-session-rename.ts | 13 +- .../session-lifecycle/codex-session-mcp.ts | 204 +++++++++++++ .../codex-structured-session-adapter.ts | 11 +- .../structured-agent-session-adapter.ts | 9 + .../catalog/structured-agent-session-mcp.ts | 32 +++ .../structured-agent-session.ts | 2 + ...untime-rpc-mobile-agent-session-methods.ts | 2 + .../src/app-shell/AppRootSurfaces.tsx | 10 +- .../native-chat-slash-command-descriptions.ts | 4 + .../use-native-chat-composer-catalog.ts | 11 +- ...use-native-chat-composer-command-picker.ts | 12 +- .../pickers/use-native-chat-resume-command.ts | 32 ++- .../submit/native-chat-composer-send-state.ts | 10 +- ...tive-chat-structured-composer-transport.ts | 2 + .../submit/use-native-chat-composer-submit.ts | 3 +- ...se-native-chat-structured-composer-send.ts | 3 +- ...tive-chat-structured-composer-transport.ts | 33 ++- .../mcp/NativeChatMcpServerRow.tsx | 270 ++++++++++++++++++ .../NativeChatMcpServersDialog.fixture.tsx | 99 +++++++ .../mcp/NativeChatMcpServersDialog.tsx | 233 +++++++++++++++ .../mcp/native-chat-mcp-server-text.ts | 50 ++++ .../mcp/native-chat-mcp-servers-modal.ts | 10 + .../native-chat/mcp/use-deferred-pending.ts | 15 + .../use-native-chat-composer-edit-session.ts | 11 +- .../native-chat-session-modal-request.ts | 28 ++ .../usage/NativeChatAccountUsageDialog.tsx | 14 +- .../usage/native-chat-account-usage-modal.ts | 32 +-- .../src/i18n/en-runtime-required.json | 6 + src/renderer/src/i18n/locales/en.json | 48 +++- .../use-host-structured-agent.ts | 9 - ...ructured-agent-session-request-timeouts.ts | 2 + .../slices/ui/ui-slice-contract-contextual.ts | 1 + .../agent-session-capabilities.ts | 2 + src/shared/agent-session/agent-session-mcp.ts | 61 ++++ .../agent-session-registered-agents.ts | 2 + .../agent-session-runtime-capabilities.ts | 3 + .../agent-session-mcp-server-presentation.ts | 57 ++++ .../view/structured-agent-session-composer.ts | 92 +----- .../structured-agent-session-slash-menu.ts | 87 ++++++ .../view/use-agent-session-mcp-panel.ts | 181 ++++++++++++ src/shared/queued-message-edit-command.ts | 17 +- .../rpc-params-catalog.generated.ts | 2 + .../structured-agent-session-mcp-params.ts | 27 ++ 54 files changed, 2016 insertions(+), 169 deletions(-) create mode 100644 src/main/claude/structured/claude-structured-mcp.ts create mode 100644 src/main/codex/structured/session-lifecycle/codex-mcp-enabled-write.ts create mode 100644 src/main/codex/structured/session-lifecycle/codex-mcp-server-status.ts create mode 100644 src/main/codex/structured/session-lifecycle/codex-mcp-sign-in-callback.ts create mode 100644 src/main/codex/structured/session-lifecycle/codex-session-mcp.ts create mode 100644 src/main/runtime/rpc/methods/structured-sessions/catalog/structured-agent-session-mcp.ts create mode 100644 src/renderer/src/components/native-chat/mcp/NativeChatMcpServerRow.tsx create mode 100644 src/renderer/src/components/native-chat/mcp/NativeChatMcpServersDialog.fixture.tsx create mode 100644 src/renderer/src/components/native-chat/mcp/NativeChatMcpServersDialog.tsx create mode 100644 src/renderer/src/components/native-chat/mcp/native-chat-mcp-server-text.ts create mode 100644 src/renderer/src/components/native-chat/mcp/native-chat-mcp-servers-modal.ts create mode 100644 src/renderer/src/components/native-chat/mcp/use-deferred-pending.ts create mode 100644 src/renderer/src/components/native-chat/session/native-chat-session-modal-request.ts create mode 100644 src/shared/agent-session/agent-session-mcp.ts create mode 100644 src/shared/agent-session/view/agent-session-mcp-server-presentation.ts create mode 100644 src/shared/agent-session/view/structured-agent-session-slash-menu.ts create mode 100644 src/shared/agent-session/view/use-agent-session-mcp-panel.ts create mode 100644 src/shared/rpc-contract/structured-agent-session/structured-agent-session-mcp-params.ts diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.ts index ea5597fd311..d1ef3a4863c 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu.ts @@ -19,7 +19,7 @@ import type { NativeChatSessionSkill, SlashCommandSuggestion } from '../../../../../src/shared/native-chat/input/native-chat-slash-commands' -import { structuredSessionSlashCommands } from '../../../../../src/shared/agent-session/view/structured-agent-session-composer' +import { structuredSessionSlashCommands } from '../../../../../src/shared/agent-session/view/structured-agent-session-slash-menu' // Why: the menu is a plain ScrollView right above a streaming transcript, so it mounts a bounded // number of rows; typing narrows the rest. diff --git a/src/main/claude/command-catalog/claude-at-rest-commands.ts b/src/main/claude/command-catalog/claude-at-rest-commands.ts index 45133fd7631..1232d14a39b 100644 --- a/src/main/claude/command-catalog/claude-at-rest-commands.ts +++ b/src/main/claude/command-catalog/claude-at-rest-commands.ts @@ -8,7 +8,7 @@ import { type AgentSessionAccountHome } from '../../../shared/agent-session/lifecycle/agent-session-account-home' import type { AgentSessionSlashCommand } from '../../../shared/agent-session/agent-session-wire' -import { structuredSlashCommands } from '../../../shared/agent-session/view/structured-agent-session-composer' +import { structuredSlashCommands } from '../../../shared/agent-session/view/structured-agent-session-slash-menu' import { discoverSkills } from '../../skills/discovery/skill-native-discovery' import { scanClaudeCommandFolders } from './claude-command-folder-scan' import { supportsClaudeStructuredLocation } from '../structured/launch/claude-structured-location-support' diff --git a/src/main/claude/sdk-transport/claude-agent-sdk-control-requests.ts b/src/main/claude/sdk-transport/claude-agent-sdk-control-requests.ts index addeba66e28..1b37254251b 100644 --- a/src/main/claude/sdk-transport/claude-agent-sdk-control-requests.ts +++ b/src/main/claude/sdk-transport/claude-agent-sdk-control-requests.ts @@ -1,4 +1,5 @@ import type { + McpServerStatus, PermissionMode, Query, SDKControlInterruptResponse @@ -93,6 +94,88 @@ export function runClaudeControl( }) } +export type ClaudeMcpAuthentication = { + authUrl?: string + /** True when the CLI listens for the browser's redirect and finishes sign-in itself. */ + callbackExpected?: boolean +} + +export type ClaudeMcpControls = { + status: () => Promise + /** Waits for the server to connect. */ + reconnect: (name: string) => Promise + /** Persists like the TUI's toggle. */ + toggle: (name: string, enabled: boolean) => Promise + authenticate: (name: string) => Promise + /** Exchanges the code but leaves the server needing a reconnect. */ + submitOAuthCallbackUrl: (name: string, callbackUrl: string) => Promise +} + +/** mcp_authenticate's reply; only the fields the panel acts on. */ +function readClaudeMcpAuthentication(reply: unknown): ClaudeMcpAuthentication { + if (typeof reply !== 'object' || reply === null) { + return {} + } + const authUrl = 'authUrl' in reply ? reply.authUrl : undefined + const callbackExpected = 'callbackExpected' in reply ? reply.callbackExpected : undefined + return { + ...(typeof authUrl === 'string' ? { authUrl } : {}), + ...(typeof callbackExpected === 'boolean' ? { callbackExpected } : {}) + } +} + +// mcpAuthenticate and mcpSubmitOAuthCallbackUrl exist at runtime but are absent from Query's +// declaration. +function claudeQueryMcpAuthenticator(query: Query): ((name: string) => unknown) | null { + const authenticate = 'mcpAuthenticate' in query ? query.mcpAuthenticate : null + return typeof authenticate === 'function' + ? (name): unknown => authenticate.call(query, name) + : null +} + +function claudeQueryMcpCallbackSubmitter( + query: Query +): ((name: string, callbackUrl: string) => unknown) | null { + const submit = 'mcpSubmitOAuthCallbackUrl' in query ? query.mcpSubmitOAuthCallbackUrl : null + return typeof submit === 'function' + ? (name, callbackUrl): unknown => submit.call(query, name, callbackUrl) + : null +} + +const missingClaudeControl = (subtype: string): Promise => + Promise.reject(new ClaudeControlRequestError(subtype, `this SDK exposes no ${subtype} request`)) + +function createClaudeMcpControls(query: Query): ClaudeMcpControls { + const authenticate = claudeQueryMcpAuthenticator(query) + const submitCallback = claudeQueryMcpCallbackSubmitter(query) + return { + status: () => runClaudeControl('mcp_status', () => query.mcpServerStatus(), 15_000), + reconnect: (name) => + runClaudeControl('mcp_reconnect', () => query.reconnectMcpServer(name), 30_000), + toggle: (name, enabled) => + runClaudeControl('mcp_toggle', () => query.toggleMcpServer(name, enabled), 30_000), + authenticate: (name) => + runClaudeControl( + 'mcp_authenticate', + () => + authenticate + ? Promise.resolve(authenticate(name)) + : missingClaudeControl('mcp_authenticate'), + 15_000 + ).then(readClaudeMcpAuthentication), + // The token exchange happens inside this request. + submitOAuthCallbackUrl: (name, callbackUrl) => + runClaudeControl( + 'mcp_oauth_callback_url', + () => + submitCallback + ? Promise.resolve(submitCallback(name, callbackUrl)) + : missingClaudeControl('mcp_oauth_callback_url'), + 30_000 + ).then(() => {}) + } +} + export type ClaudeControlSurface = { interrupt: ( options?: ClaudeControlOptions & { cancelQueued?: boolean } @@ -115,6 +198,8 @@ export type ClaudeControlSurface = { /** Names `sessionId` as the user's rename; refused once the CLI has moved to another session. */ renameSession: (title: string, sessionId: string, options?: ClaudeControlOptions) => Promise generateSessionTitle?: (description: string) => Promise + /** The `/mcp` panel's requests; test connections leave it out. */ + mcp?: ClaudeMcpControls } type InterruptingQuery = { @@ -126,6 +211,7 @@ type InterruptingQuery = { export function createClaudeControlSurface(query: Query): ClaudeControlSurface { const title = 'generateSessionTitle' in query ? query.generateSessionTitle : null return { + mcp: createClaudeMcpControls(query), ...(typeof title === 'function' ? { generateSessionTitle: (description: string) => diff --git a/src/main/claude/structured/claude-structured-agent-definition.ts b/src/main/claude/structured/claude-structured-agent-definition.ts index c1bef215b6c..f6c9a85acb4 100644 --- a/src/main/claude/structured/claude-structured-agent-definition.ts +++ b/src/main/claude/structured/claude-structured-agent-definition.ts @@ -13,6 +13,7 @@ export const CLAUDE_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { compact: true, compactInstructions: true, compactBesideBackgroundTasks: true, + mcpServers: true, threadGoal: false, contextUsage: true, imagePrompts: true, diff --git a/src/main/claude/structured/claude-structured-mcp.ts b/src/main/claude/structured/claude-structured-mcp.ts new file mode 100644 index 00000000000..5d3e74966e4 --- /dev/null +++ b/src/main/claude/structured/claude-structured-mcp.ts @@ -0,0 +1,90 @@ +import type { McpServerStatus } from '@anthropic-ai/claude-agent-sdk' +import { + agentSessionMcpFailureMessage, + agentSessionMcpWebUrl, + type AgentSessionMcpOperation, + type AgentSessionMcpResult, + type AgentSessionMcpServer, + type AgentSessionMcpSignIn +} from '../../../shared/agent-session/agent-session-mcp' +import { + isClaudeQueryClosed, + type ClaudeMcpControls +} from '../sdk-transport/claude-agent-sdk-control-requests' +import type { ClaudeSession } from './claude-structured-session-state' + +// The CLI refuses OAuth for stdio and sdk servers. +const SIGN_IN_TRANSPORTS = new Set(['http', 'sse', 'claudeai-proxy']) + +export function claudeMcpServer(row: McpServerStatus): AgentSessionMcpServer { + return { + name: row.name, + status: row.status, + source: row.source ?? row.scope ?? null, + toolCount: row.status === 'connected' ? (row.tools?.length ?? 0) : null, + error: row.status === 'failed' ? agentSessionMcpFailureMessage(row.error ?? '') : null, + // A disabled server must be enabled before the CLI will authenticate it. + canSignIn: SIGN_IN_TRANSPORTS.has(row.config?.type ?? 'stdio') && row.status !== 'disabled', + canReconnect: row.status !== 'disabled', + canToggle: true + } +} + +async function runClaudeMcpOperation( + mcp: ClaudeMcpControls, + operation: AgentSessionMcpOperation +): Promise { + switch (operation.action) { + case 'status': + return undefined + case 'signIn': { + const { authUrl, callbackExpected } = await mcp.authenticate(operation.server) + const url = agentSessionMcpWebUrl(authUrl) + // claude.ai connectors finish on claude.ai; others wait on the CLI's localhost callback. + return url + ? { server: operation.server, url, finish: callbackExpected ? 'callback' : 'external' } + : undefined + } + case 'finishSignIn': + await mcp.submitOAuthCallbackUrl(operation.server, operation.callbackUrl) + await mcp.reconnect(operation.server) + return undefined + case 'reconnect': + await mcp.reconnect(operation.server) + return undefined + case 'toggle': + await mcp.toggle(operation.server, operation.enabled) + return undefined + } +} + +export async function manageClaudeMcp( + sessions: ReadonlyMap, + sessionId: string, + operation: AgentSessionMcpOperation +): Promise { + const session = sessions.get(sessionId) + const connection = session?.connection + if (!session || !connection || connection.closed) { + return { status: 'not-running' } + } + const mcp = connection.mcp + if (!mcp) { + return { status: 'unsupported' } + } + const live = (): boolean => + sessions.get(sessionId) === session && session.connection === connection && !connection.closed + try { + const signIn = await runClaudeMcpOperation(mcp, operation) + const servers = (await mcp.status()).map(claudeMcpServer) + if (!live()) { + return { status: 'not-running' } + } + return signIn ? { status: 'ok', servers, signIn } : { status: 'ok', servers } + } catch (error) { + if (!live() || isClaudeQueryClosed(error)) { + return { status: 'not-running' } + } + throw error + } +} diff --git a/src/main/claude/structured/claude-structured-session-adapter.ts b/src/main/claude/structured/claude-structured-session-adapter.ts index 8667d913b70..c3c87f1127f 100644 --- a/src/main/claude/structured/claude-structured-session-adapter.ts +++ b/src/main/claude/structured/claude-structured-session-adapter.ts @@ -1,5 +1,6 @@ import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import { isKillDeliveredExitReceipt } from './claude-kill-delivered-exit-receipt' +import { manageClaudeMcp } from './claude-structured-mcp' import { claudeAcquireFailure } from '../sdk-transport/claude-startup-failure' import { claudeStartPublication, openClaudeStart } from './claude-structured-start-ending' import { AgentSessionStartEndings } from '../../native-chat/agent-session-wire/startup/structured-agent-session-start-failure' @@ -196,6 +197,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda this.sessions.has(sessionId) ? { supportsTaskStop: true, supportsStopAll: true } : undefined readCommands: NonNullable = (sessionId) => this.sessions.get(sessionId)?.commands.commands + manageMcp: NonNullable = (record, operation) => + manageClaudeMcp(this.sessions, record.sessionId, operation) readonly atRestCommands: ClaudeStructuredSessionAdapterDeps['atRestCommands'] holdsDispatch = (sessionId: string): boolean => claudeHoldsDispatch(this.sessions.get(sessionId)) requiresModelTransitionRestart = claudeOptions.claudeModelTransitionRestart(this.sessions) diff --git a/src/main/codex/structured/catalog/codex-skill-list.ts b/src/main/codex/structured/catalog/codex-skill-list.ts index e885404f68d..d28e7492fb4 100644 --- a/src/main/codex/structured/catalog/codex-skill-list.ts +++ b/src/main/codex/structured/catalog/codex-skill-list.ts @@ -1,5 +1,5 @@ import type { AgentSessionSlashCommand } from '../../../../shared/agent-session/agent-session-wire' -import { structuredSlashCommands } from '../../../../shared/agent-session/view/structured-agent-session-composer' +import { structuredSlashCommands } from '../../../../shared/agent-session/view/structured-agent-session-slash-menu' import type { CodexAppServerConnection } from '../../app-server/codex-app-server-connection-types' import { type CodexAppServerRpc, diff --git a/src/main/codex/structured/launch/codex-structured-agent-definition.ts b/src/main/codex/structured/launch/codex-structured-agent-definition.ts index 76c149696fd..6be8cf7b4cc 100644 --- a/src/main/codex/structured/launch/codex-structured-agent-definition.ts +++ b/src/main/codex/structured/launch/codex-structured-agent-definition.ts @@ -11,6 +11,7 @@ export const CODEX_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { rewind: true, fork: 'checkpoint', compact: true, + mcpServers: true, // A goal change at rest starts the agent first. threadGoal: true, contextUsage: true, diff --git a/src/main/codex/structured/session-lifecycle/codex-mcp-enabled-write.ts b/src/main/codex/structured/session-lifecycle/codex-mcp-enabled-write.ts new file mode 100644 index 00000000000..13c3ccfd7ac --- /dev/null +++ b/src/main/codex/structured/session-lifecycle/codex-mcp-enabled-write.ts @@ -0,0 +1,46 @@ +import { join } from 'node:path' +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import { observeAgentStateFile } from '../../configuration/codex-path-observation' +import { getSystemCodexHomePath } from '../../configuration/homes/codex-home-paths' +import { observeCodexSettingsBaseline } from '../../configuration/settings/config-settings-baseline' +import { readMcpServerTomlOwnership } from '../../configuration/toml/config-toml-mcp-servers' +import { CODEX_MCP_RPC_TIMEOUT_MS, type CodexMcpRequest } from './codex-mcp-server-status' + +export function writeCodexMcpEnabled( + request: CodexMcpRequest, + server: string, + enabled: boolean +): Promise { + return request( + 'config/batchWrite', + { + edits: [ + { keyPath: `mcp_servers.${server}.enabled`, value: enabled, mergeStrategy: 'upsert' } + ], + reloadUserConfig: true + }, + { timeoutMs: CODEX_MCP_RPC_TIMEOUT_MS } + ) +} + +/** A managed home re-mirrors servers declared in ~/.codex at every launch, so an `enabled` written + * only there is reverted; those servers take the write in ~/.codex too. */ +export function codexMirrorSourceDeclaring(codexHome: string, server: string): string | null { + const source = getSystemCodexHomePath() + if (normalizeRuntimePathForComparison(codexHome) === normalizeRuntimePathForComparison(source)) { + return null + } + const baseline = observeCodexSettingsBaseline(codexHome) + if (baseline.kind === 'absent') { + return null + } + const config = observeAgentStateFile(join(source, 'config.toml')) + if (baseline.kind === 'indeterminate' || config.kind === 'indeterminate') { + throw new Error("Orca couldn't read your Codex settings. Try again.") + } + if (config.kind === 'absent') { + return null + } + const owner = readMcpServerTomlOwnership(config.value) + return owner.ownsRoot || owner.names.has(server) ? source : null +} diff --git a/src/main/codex/structured/session-lifecycle/codex-mcp-server-status.ts b/src/main/codex/structured/session-lifecycle/codex-mcp-server-status.ts new file mode 100644 index 00000000000..8a9d2b7d9ca --- /dev/null +++ b/src/main/codex/structured/session-lifecycle/codex-mcp-server-status.ts @@ -0,0 +1,149 @@ +import type { + AgentSessionMcpServer, + AgentSessionMcpServerStatus +} from '../../../../shared/agent-session/agent-session-mcp' +import type { CodexAppServerRpc } from '../../app-server/codex-app-server-session' + +export type CodexMcpRequest = CodexAppServerRpc['request'] + +// Without a thread, Codex opens a fresh connection to every server to answer. +export const CODEX_MCP_RPC_TIMEOUT_MS = 30_000 +const MAX_STATUS_PAGES = 20 +const ERROR_MAX_CHARS = 160 + +const RUNTIME_STATUS: Record = { + connected: 'connected', + notStarted: 'pending', + starting: 'pending', + authenticationRequired: 'needs-auth', + failed: 'failed', + cancelled: 'failed', + disabled: 'disabled' +} + +type CodexMcpServerStatusEntry = { + name: string + runtimeStatus?: string | null + pluginId?: string | null + tools?: Record | null + toolsError?: string | null + authStatus?: string | null +} + +type CodexMcpConfig = { + disabled: ReadonlySet + /** Only servers in the home's own config.toml take an `enabled` write. */ + userDeclared: ReadonlySet +} + +export const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +/** `threadId` reads the running thread's connections; without one Codex probes each server itself. */ +export async function listCodexMcpServers( + request: CodexMcpRequest, + scope: { threadId: string | null; cwd: string | null } +): Promise { + const config = await readCodexMcpConfig(request, scope.cwd) + const entries: CodexMcpServerStatusEntry[] = [] + let cursor: string | null = null + for (let page = 0; page < MAX_STATUS_PAGES; page++) { + const reply = await request( + 'mcpServerStatus/list', + { + detail: 'toolsAndAuthOnly', + ...(scope.threadId ? { threadId: scope.threadId } : {}), + ...(cursor ? { cursor } : {}) + }, + { timeoutMs: CODEX_MCP_RPC_TIMEOUT_MS } + ) + const data = isRecord(reply) && Array.isArray(reply.data) ? reply.data : [] + for (const entry of data) { + if (isRecord(entry) && typeof entry.name === 'string') { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Codex's McpServerStatus; every field read below is optional. + entries.push(entry as CodexMcpServerStatusEntry) + } + } + cursor = isRecord(reply) && typeof reply.nextCursor === 'string' ? reply.nextCursor : null + if (!cursor) { + break + } + } + return entries.map((entry) => codexMcpServerRow(entry, config, scope.threadId !== null)) +} + +async function readCodexMcpConfig( + request: CodexMcpRequest, + cwd: string | null +): Promise { + const reply = await request( + 'config/read', + { includeLayers: true, ...(cwd ? { cwd } : {}) }, + { timeoutMs: CODEX_MCP_RPC_TIMEOUT_MS } + ) + const servers = isRecord(reply) && isRecord(reply.config) ? reply.config.mcp_servers : null + const disabled = new Set( + isRecord(servers) + ? Object.entries(servers) + .filter(([, server]) => isRecord(server) && server.enabled === false) + .map(([name]) => name) + : [] + ) + const layers = isRecord(reply) && Array.isArray(reply.layers) ? reply.layers : [] + const user = layers.find( + (layer): layer is Record => + isRecord(layer) && isRecord(layer.name) && layer.name.type === 'user' + ) + const userConfig = user?.config + const userServers = isRecord(userConfig) ? userConfig.mcp_servers : null + return { disabled, userDeclared: new Set(isRecord(userServers) ? Object.keys(userServers) : []) } +} + +function codexMcpServerRow( + entry: CodexMcpServerStatusEntry, + config: CodexMcpConfig, + live: boolean +): AgentSessionMcpServer { + // Config decides disabled: a thread may still report the connection a toggle just closed. + const status: AgentSessionMcpServerStatus = config.disabled.has(entry.name) + ? 'disabled' + : entry.runtimeStatus + ? (RUNTIME_STATUS[entry.runtimeStatus] ?? 'pending') + : entry.authStatus === 'notLoggedIn' + ? 'needs-auth' + : entry.toolsError + ? 'failed' + : 'connected' + return { + name: entry.name, + status, + source: entry.pluginId ? 'plugin' : null, + toolCount: status === 'connected' ? Object.keys(entry.tools ?? {}).length : null, + error: status === 'failed' ? readableCodexMcpError(entry.toolsError) : null, + // Codex's OAuth callback listener lives in the app-server, which only a running chat keeps up. + canSignIn: + live && + status !== 'disabled' && + (entry.authStatus === 'notLoggedIn' || entry.authStatus === 'oAuth'), + // `config/mcpServer/reload` restarts a failed server only on a running thread. + canReconnect: live && status === 'failed', + canToggle: + !entry.pluginId && config.userDeclared.has(entry.name) && CODEX_MCP_TOML_KEY.test(entry.name) + } +} + +/** The `enabled` write's key path takes the name as one bare TOML key segment. */ +export const CODEX_MCP_TOML_KEY = /^[\w-]+$/ + +/** Codex reports rmcp's error chain with Rust type paths; keep its innermost cause. */ +export function readableCodexMcpError(raw: string | null | undefined): string | null { + const text = (raw ?? '') + .replace(/\s*\[[^\]]*\]/g, '') + .replace(/\s+/g, ' ') + .trim() + const at = text.lastIndexOf('error: ') + const cause = (at !== -1 ? text.slice(at + 'error: '.length) : text) + .replace(/, when send \w+ request$/, '') + .trim() + return cause ? `${cause[0].toUpperCase()}${cause.slice(1)}`.slice(0, ERROR_MAX_CHARS) : null +} diff --git a/src/main/codex/structured/session-lifecycle/codex-mcp-sign-in-callback.ts b/src/main/codex/structured/session-lifecycle/codex-mcp-sign-in-callback.ts new file mode 100644 index 00000000000..b1385ac7f3a --- /dev/null +++ b/src/main/codex/structured/session-lifecycle/codex-mcp-sign-in-callback.ts @@ -0,0 +1,51 @@ +const CALLBACK_FETCH_TIMEOUT_MS = 15_000 +const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']) + +export function readRedirectUri(authorizationUrl: string): string | null { + try { + // A server-planted second redirect_uri could steer the host's GET elsewhere. + const uris = new URL(authorizationUrl).searchParams.getAll('redirect_uri') + return uris.length === 1 ? uris[0]! : null + } catch { + return null + } +} + +/** A host-side GET of pasted input: only Codex's own loopback callback for this login. */ +export function matchingCodexMcpCallback( + callbackUrl: string, + redirectUri: string | null +): URL | null { + try { + const url = new URL(callbackUrl.trim()) + const expected = new URL(redirectUri ?? '') + return url.protocol === 'http:' && + LOOPBACK_HOSTS.has(url.hostname) && + !url.username && + !url.password && + url.origin === expected.origin && + url.pathname === expected.pathname + ? url + : null + } catch { + return null + } +} + +export async function deliverCodexMcpCallback(callback: URL): Promise { + let response: Response + try { + response = await fetch(callback, { + redirect: 'manual', + signal: AbortSignal.timeout(CALLBACK_FETCH_TIMEOUT_MS) + }) + } catch { + throw new Error('The sign-in is no longer waiting. Start it again.') + } + await response.body?.cancel() + if (!response.ok) { + throw new Error( + `Codex refused that address (HTTP ${response.status}). Start the sign-in again.` + ) + } +} diff --git a/src/main/codex/structured/session-lifecycle/codex-provider-session-rename.ts b/src/main/codex/structured/session-lifecycle/codex-provider-session-rename.ts index 5a039df6e15..cef6d181b60 100644 --- a/src/main/codex/structured/session-lifecycle/codex-provider-session-rename.ts +++ b/src/main/codex/structured/session-lifecycle/codex-provider-session-rename.ts @@ -118,10 +118,17 @@ export async function renameCodexProviderSession(input: { export async function runCodexProviderAppServer( record: AgentSessionRecord, invocation: CodexProviderInvocationDeps, - options: { timeoutMs: number; signal?: AbortSignal; experimentalApi?: boolean }, + options: { + timeoutMs: number + signal?: AbortSignal + experimentalApi?: boolean + /** Another home than the chat's, e.g. the source of a mirrored home's config. */ + codexHome?: string + }, body: (rpc: CodexAppServerRpc, cwd: string) => Promise ): Promise { - const codexHome = requireLegacyAgentSessionAccountHome(record.accountHome).path + const { codexHome = requireLegacyAgentSessionAccountHome(record.accountHome).path, ...session } = + options const cwd = await codexProviderProbeCwd(record, invocation) const { command, prefixArgs, environment } = await resolveCodexStructuredInvocation({ ...invocation, @@ -142,7 +149,7 @@ export async function runCodexProviderAppServer( cliPath: command, env: { ...env, CODEX_HOME: codexHome }, envToDelete: [NATIVE_CHAT_SCRATCH_DIR_ENV, LEGACY_NATIVE_CHAT_VISUALS_DIR_ENV], - ...options + ...session }, (rpc) => body(rpc, cwd) ) diff --git a/src/main/codex/structured/session-lifecycle/codex-session-mcp.ts b/src/main/codex/structured/session-lifecycle/codex-session-mcp.ts new file mode 100644 index 00000000000..d38f27bcb38 --- /dev/null +++ b/src/main/codex/structured/session-lifecycle/codex-session-mcp.ts @@ -0,0 +1,204 @@ +import { + agentSessionMcpWebUrl, + type AgentSessionMcpResult, + type AgentSessionMcpServer +} from '../../../../shared/agent-session/agent-session-mcp' +import { requireLegacyAgentSessionAccountHome } from '../../../../shared/agent-session/lifecycle/agent-session-account-home' +import type { AgentSessionRecord } from '../../../../shared/agent-session/lifecycle/agent-session-record' +import { + resolveCodexHomeProcessLockKey, + withCodexHomeProcessLock +} from '../../../codex-cli/codex-home-process-lock' +import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/providers/structured-agent-session-adapter' +import { + CODEX_MCP_RPC_TIMEOUT_MS, + CODEX_MCP_TOML_KEY, + isRecord, + listCodexMcpServers, + type CodexMcpRequest +} from './codex-mcp-server-status' +import { codexMirrorSourceDeclaring, writeCodexMcpEnabled } from './codex-mcp-enabled-write' +import { + deliverCodexMcpCallback, + matchingCodexMcpCallback, + readRedirectUri +} from './codex-mcp-sign-in-callback' +import { + runCodexProviderAppServer, + type CodexProviderInvocationDeps +} from './codex-provider-session-rename' +import type { CodexSession } from './codex-structured-session-state' + +const COLD_MCP_TIMEOUT_MS = 60_000 +// Codex's own default login window; its callback listener closes after it. +const SIGN_IN_WINDOW_SECONDS = 300 +// Codex's own server-name rule (codex-mcp validate_mcp_server_name). +const SERVER_NAME = /^[\w:@/.-]+$/ +const MISMATCH = "That address doesn't match the sign-in in progress." + +type PendingSignIn = { + server: string + redirectUri: string | null + expiresAt: number +} + +type CodexLogin = { authorizationUrl: string } + +/** The `/mcp` panel's reads and actions: on the chat's own app-server while it runs, otherwise on + * a short-lived one launched as the chat is, without resuming the thread. */ +export function manageCodexSessionMcp( + sessions: Map, + readInvocation: () => CodexProviderInvocationDeps | undefined +): NonNullable { + const signIns = new Map() + const invocationFor = (): CodexProviderInvocationDeps => { + const invocation = readInvocation() + if (!invocation) { + throw new Error('Codex MCP invocation is not configured') + } + return invocation + } + + const cold = ( + record: AgentSessionRecord, + body: (request: CodexMcpRequest, cwd: string) => Promise, + codexHome = requireLegacyAgentSessionAccountHome(record.accountHome).path + ): Promise => + withCodexHomeProcessLock(resolveCodexHomeProcessLockKey(codexHome), () => + runCodexProviderAppServer( + record, + invocationFor(), + { timeoutMs: COLD_MCP_TIMEOUT_MS, experimentalApi: true, codexHome }, + (rpc, cwd) => body(rpc.request, cwd) + ) + ) + + const list = ( + record: AgentSessionRecord, + live: CodexSession | null + ): Promise => { + if (live) { + return listLive(live) + } + return cold(record, (request, cwd) => listCodexMcpServers(request, { threadId: null, cwd })) + } + + const toggle = async ( + record: AgentSessionRecord, + live: CodexSession | null, + server: string, + enabled: boolean + ): Promise => { + const codexHome = + live?.codexHome ?? requireLegacyAgentSessionAccountHome(record.accountHome).path + const source = codexMirrorSourceDeclaring(codexHome, server) + if (source) { + await cold(record, (request) => writeCodexMcpEnabled(request, server, enabled), source) + } + if (live) { + await writeCodexMcpEnabled(liveRequest(live), server, enabled) + return listLive(live) + } + return cold(record, async (request, cwd) => { + await writeCodexMcpEnabled(request, server, enabled) + return listCodexMcpServers(request, { threadId: null, cwd }) + }) + } + + return async (record, operation) => { + const live = liveSession(sessions, record.sessionId) + if (operation.action === 'status') { + return ok(await list(record, live)) + } + if (!SERVER_NAME.test(operation.server)) { + throw new Error(`No MCP server named '${operation.server}'.`) + } + switch (operation.action) { + case 'signIn': { + // Codex's callback listener lives in the app-server, which only a running chat keeps up. + if (!live) { + return { status: 'not-running' } + } + const login = await requestLogin(liveRequest(live), operation.server, live.threadId) + const servers = await listLive(live) + signIns.set(record.sessionId, { + server: operation.server, + redirectUri: readRedirectUri(login.authorizationUrl), + expiresAt: Date.now() + SIGN_IN_WINDOW_SECONDS * 1000 + }) + // The URL comes from the MCP server's OAuth metadata; clients open only web pages. + const url = agentSessionMcpWebUrl(login.authorizationUrl) + return url + ? { status: 'ok', servers, signIn: { server: operation.server, url, finish: 'callback' } } + : ok(servers) + } + case 'finishSignIn': { + const pending = signIns.get(record.sessionId) + const callback = + pending && pending.server === operation.server && pending.expiresAt > Date.now() + ? matchingCodexMcpCallback(operation.callbackUrl, pending.redirectUri) + : null + if (!callback) { + throw new Error(MISMATCH) + } + await deliverCodexMcpCallback(callback) + signIns.delete(record.sessionId) + return ok(await list(record, live)) + } + case 'reconnect': { + if (!live) { + return { status: 'not-running' } + } + await liveRequest(live)('config/mcpServer/reload', undefined, { + timeoutMs: CODEX_MCP_RPC_TIMEOUT_MS + }) + return ok(await listLive(live)) + } + case 'toggle': + if (!CODEX_MCP_TOML_KEY.test(operation.server)) { + throw new Error(`Orca can't turn ${operation.server} on or off.`) + } + return ok(await toggle(record, live, operation.server, operation.enabled)) + } + } +} + +const ok = (servers: AgentSessionMcpServer[]): AgentSessionMcpResult => ({ status: 'ok', servers }) + +function liveSession(sessions: Map, sessionId: string): CodexSession | null { + const session = sessions.get(sessionId) + return session && !session.ended && !session.connection.closed ? session : null +} + +const liveRequest = + (live: CodexSession): CodexMcpRequest => + (method, params, options) => + live.connection.request(method, params, options) + +const listLive = (live: CodexSession): Promise => + listCodexMcpServers(liveRequest(live), { + threadId: live.threadId, + cwd: live.permissions?.cwd ?? null + }) + +async function requestLogin( + request: CodexMcpRequest, + name: string, + threadId: string | null +): Promise { + const reply = await request( + 'mcpServer/oauth/login', + { name, timeoutSecs: SIGN_IN_WINDOW_SECONDS, ...(threadId ? { threadId } : {}) }, + { timeoutMs: CODEX_MCP_RPC_TIMEOUT_MS } + ) + const authorizationUrl = readStringField(reply, 'authorizationUrl') + if (!authorizationUrl) { + throw new Error(`Codex did not start a sign-in for ${name}.`) + } + return { authorizationUrl } +} + +function readStringField(value: unknown, key: string): string | null { + const field = isRecord(value) ? value[key] : null + return typeof field === 'string' ? field : null +} diff --git a/src/main/codex/structured/session-lifecycle/codex-structured-session-adapter.ts b/src/main/codex/structured/session-lifecycle/codex-structured-session-adapter.ts index 42ca17db6ad..33c2cd55177 100644 --- a/src/main/codex/structured/session-lifecycle/codex-structured-session-adapter.ts +++ b/src/main/codex/structured/session-lifecycle/codex-structured-session-adapter.ts @@ -1,5 +1,6 @@ import { copyCodexConversation } from '../../codex-conversation-copy' import { readCodexSessionAccountUsage } from './codex-session-account-usage' +import { manageCodexSessionMcp } from './codex-session-mcp' import * as permission from '../turns/codex-structured-turn-permission' import { projectCodexSessionOptions } from '../catalog/codex-structured-service-tier' import type { AgentSessionBackgroundTaskState } from '../../../../shared/agent-session/agent-session-background-task-wire' @@ -20,10 +21,7 @@ import { applyCodexStructuredSessionOption, readLiveCodexSessionOptions } from '../catalog/codex-structured-session-options' -import { - prepareCodexMessageDispatch, - readCodexAppliedInputOptions -} from '../turns/codex-structured-message-options' +import * as messageOptions from '../turns/codex-structured-message-options' import { CodexAcquisitionRegistry, requireLiveCodexSession, @@ -87,6 +85,7 @@ export class CodexStructuredSessionAdapter implements SessionAdapter { copyConversation: NonNullable = (input) => copyCodexConversation(input, this.deps.providerInvocation) readAccountUsage = readCodexSessionAccountUsage(this.sessions, () => this.deps.providerInvocation) + manageMcp = manageCodexSessionMcp(this.sessions, () => this.deps.providerInvocation) supportsLocation = supportsCodexStructuredLocation @@ -245,7 +244,7 @@ export class CodexStructuredSessionAdapter implements SessionAdapter { try { const outcome = await dispatchCodexTurn( session, - await prepareCodexMessageDispatch( + await messageOptions.prepareCodexMessageDispatch( session, input, () => this.sessions.get(input.sessionId) === session @@ -326,7 +325,7 @@ export class CodexStructuredSessionAdapter implements SessionAdapter { readLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) readAppliedInputOptions: NonNullable = (input) => - readCodexAppliedInputOptions(this.sessions.get(input.sessionId), input) + messageOptions.readCodexAppliedInputOptions(this.sessions.get(input.sessionId), input) projectOptions = (sessionId: string, options: Readonly>) => projectCodexSessionOptions(this.sessions.get(sessionId), options) diff --git a/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter.ts index 31a3adcbf6b..100cb7aaa00 100644 --- a/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/providers/structured-agent-session-adapter.ts @@ -1,3 +1,7 @@ +import type { + AgentSessionMcpOperation, + AgentSessionMcpResult +} from '../../../../shared/agent-session/agent-session-mcp' import type { PrepareStructuredRewind } from './structured-agent-session-rewind-contract' import type { ProviderProcessCloseOptions } from '../../../provider-process/provider-process-close' import type { NativeCopyCoverage } from '../../agent-session-journal/context-epochs/journal-history-origin' @@ -175,6 +179,11 @@ export type StructuredAgentSessionSetOptionInput = { export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & { readAccountUsage?(record: AgentSessionRecord, signal: AbortSignal): Promise + /** The `/mcp` panel's read or action on the chat's MCP servers; never enters the mutation queue. */ + manageMcp?( + record: AgentSessionRecord, + operation: AgentSessionMcpOperation + ): Promise open: StructuredAgentSessionProviderOpen supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean /** Keep runtime/location support in adapter data for remote enablement. */ diff --git a/src/main/runtime/rpc/methods/structured-sessions/catalog/structured-agent-session-mcp.ts b/src/main/runtime/rpc/methods/structured-sessions/catalog/structured-agent-session-mcp.ts new file mode 100644 index 00000000000..d4fcd84fb6e --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-sessions/catalog/structured-agent-session-mcp.ts @@ -0,0 +1,32 @@ +import { + agentSessionMcpFailureMessage, + type AgentSessionMcpResult +} from '../../../../../../shared/agent-session/agent-session-mcp' +import { defineMethod } from '../../../core' +import { requireInstalledStructuredHost } from '../creation/structured-agent-session-gate' +import { AgentSessionMcpParams } from '../../../../../../shared/rpc-contract/structured-agent-session/structured-agent-session-mcp-params' + +export const STRUCTURED_AGENT_SESSION_MCP_METHODS = [ + defineMethod({ + name: 'agentSession.mcp', + permission: 'workspace', + params: AgentSessionMcpParams, + // MCP management talks to the running agent directly, outside the conversation's mutation queue. + handler: async ({ sessionId, operation }, ctx): Promise => { + const host = await requireInstalledStructuredHost(ctx, sessionId) + const saved = host.deps.store.getRecord(sessionId) + const manage = saved && host.deps.agents.registration(saved.provider)?.adapter.manageMcp + if (!saved || !manage) { + return { status: 'unsupported' } + } + // An agent at rest is managed on the account its next turn launches on, as /usage reads it. + const accountHome = await host.deps.launchAccountHome?.(saved).catch(() => null) + const record = accountHome ? { ...saved, accountHome } : saved + try { + return await manage(record, operation) + } catch (error) { + return { status: 'failed', message: agentSessionMcpFailureMessage(error) } + } + } + }) +] diff --git a/src/main/runtime/rpc/methods/structured-sessions/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-sessions/structured-agent-session.ts index c36db52db9c..77a310e36ea 100644 --- a/src/main/runtime/rpc/methods/structured-sessions/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-sessions/structured-agent-session.ts @@ -1,3 +1,4 @@ +import { STRUCTURED_AGENT_SESSION_MCP_METHODS } from './catalog/structured-agent-session-mcp' import { STRUCTURED_AGENT_SESSION_ACCOUNT_USAGE_METHODS } from './catalog/structured-agent-session-account-usage' import { STRUCTURED_AGENT_SESSION_USAGE_LIMIT_CONTINUE_METHODS } from './catalog/structured-agent-session-usage-limit-continue' import { STRUCTURED_AGENT_SESSION_CLOSE_METHOD } from './conversation/structured-agent-session-close-method' @@ -222,5 +223,6 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ ...STRUCTURED_AGENT_SESSION_SEND_RECORDED_METHODS, ...STRUCTURED_AGENT_SESSION_CURRENT_REQUESTS_METHODS, ...STRUCTURED_AGENT_SESSION_ACCOUNT_USAGE_METHODS, + ...STRUCTURED_AGENT_SESSION_MCP_METHODS, ...STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS ] diff --git a/src/main/runtime/rpc/server/runtime-rpc-mobile-agent-session-methods.ts b/src/main/runtime/rpc/server/runtime-rpc-mobile-agent-session-methods.ts index d71d74582b9..5e88913b8b7 100644 --- a/src/main/runtime/rpc/server/runtime-rpc-mobile-agent-session-methods.ts +++ b/src/main/runtime/rpc/server/runtime-rpc-mobile-agent-session-methods.ts @@ -27,6 +27,8 @@ export const MOBILE_AGENT_SESSION_RPC_METHODS = [ // Gated on the phone by agent-session.thread-goal.phone.v1. 'agentSession.threadGoal', 'agentSession.commands', + // The `/mcp` sheet; phones offer it where the host advertises agent-session.mcp.v1. + 'agentSession.mcp', 'agentSession.history', 'agentSession.currentRequests', 'agentSession.subscribe', diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index 4e71408df47..6dbbc33403c 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -65,6 +65,9 @@ const AiVaultSessionHistoryDialog = lazy( const NativeChatAccountUsageDialog = lazy( () => import('../components/native-chat/usage/NativeChatAccountUsageDialog') ) +const NativeChatMcpServersDialog = lazy( + () => import('../components/native-chat/mcp/NativeChatMcpServersDialog') +) const DeleteWorktreeDialog = lazy( () => import('../components/sidebar/workspace-actions/deletion/DeleteWorktreeDialog') ) @@ -210,7 +213,7 @@ export const AppRootSurfaces = memo(function AppRootSurfaces(props: { ) : null} - {/* Root hosting prevents chat capture handlers receiving /resume and /usage dialog events. */} + {/* Root hosting prevents chat capture handlers receiving /resume, /usage and /mcp dialog events. */} {activeModal === 'agent-session-history' ? ( @@ -221,6 +224,11 @@ export const AppRootSurfaces = memo(function AppRootSurfaces(props: { ) : null} + {activeModal === 'chat-mcp-servers' ? ( + + + + ) : null} {/* Own store, not activeModal: another modal must never evict a running move. */} {/* Opened by toast, SSH host row and terminal pane. */} diff --git a/src/renderer/src/components/native-chat/composer/pickers/native-chat-slash-command-descriptions.ts b/src/renderer/src/components/native-chat/composer/pickers/native-chat-slash-command-descriptions.ts index 07feca60b0f..28d053da03c 100644 --- a/src/renderer/src/components/native-chat/composer/pickers/native-chat-slash-command-descriptions.ts +++ b/src/renderer/src/components/native-chat/composer/pickers/native-chat-slash-command-descriptions.ts @@ -64,6 +64,10 @@ const SHARED_SLASH_COMMAND_DESCRIPTIONS: SlashCommandDescriptionCopy = { effort: { 'Choose reasoning effort': () => translate('components.native-chat.slashCommandDescriptions.effort', 'Choose reasoning effort') + }, + mcp: { + 'Manage MCP servers': () => + translate('components.native-chat.slashCommandDescriptions.mcpManage', 'Manage MCP servers') } } diff --git a/src/renderer/src/components/native-chat/composer/pickers/use-native-chat-composer-catalog.ts b/src/renderer/src/components/native-chat/composer/pickers/use-native-chat-composer-catalog.ts index 8ad8fc62d20..316dc63d87e 100644 --- a/src/renderer/src/components/native-chat/composer/pickers/use-native-chat-composer-catalog.ts +++ b/src/renderer/src/components/native-chat/composer/pickers/use-native-chat-composer-catalog.ts @@ -6,7 +6,7 @@ import type { SlashCommandSuggestion } from '../../../../../../shared/native-chat/input/native-chat-slash-commands' import { nativeChatSessionSkills } from '../../../../../../shared/native-chat/input/native-chat-skill-visibility' -import { structuredSessionSlashCommands } from '../../../../../../shared/agent-session/view/structured-agent-session-composer' +import { structuredSessionSlashCommands } from '../../../../../../shared/agent-session/view/structured-agent-session-slash-menu' import { withNativeChatResumeCommand } from '@/components/native-chat/composer/pickers/use-native-chat-resume-command' import { withNativeChatSideChatRows } from '@/components/native-chat/composer/pickers/use-native-chat-side-chat-command' import type { NativeChatStructuredComposerTransport } from '@/components/native-chat/composer/submit/native-chat-structured-composer-transport' @@ -16,7 +16,7 @@ export type NativeChatComposerCatalog = { sessionSkills: readonly NativeChatSessionSkill[] | undefined } -/** Structured chats use host and session-loaded commands/skills plus Orca /resume and /btw; PTY chats retain curated catalogs. */ +/** Structured chats use host and session-loaded commands/skills plus Orca /resume, /btw and /mcp; PTY chats retain curated catalogs. */ export function useNativeChatComposerCatalog( agent: AgentType, structuredTransport?: NativeChatStructuredComposerTransport @@ -28,6 +28,7 @@ export function useNativeChatComposerCatalog( const interactionModeSupported = structuredTransport?.optionSnapshot.some((entry) => entry.id === 'interactionMode') === true const accountUsage = structuredTransport?.accountUsage !== undefined + const mcpPanel = structuredTransport?.mcpPanel !== undefined const fork = structuredTransport?.fork !== undefined const sideChat = structuredTransport?.sideChat !== undefined const sideChatNotReady = structuredTransport?.sideChat?.notReadyReason ?? null @@ -41,7 +42,8 @@ export function useNativeChatComposerCatalog( conversationCommands, reported, compactInstructions, - interactionModeSupported + interactionModeSupported, + mcpPanel ), accountUsage, fork @@ -57,7 +59,8 @@ export function useNativeChatComposerCatalog( conversationCommands, reported, structured, - interactionModeSupported + interactionModeSupported, + mcpPanel ]) const sessionSkills = useMemo(() => nativeChatSessionSkills(agent, reported), [agent, reported]) return { agentCommands, sessionSkills } diff --git a/src/renderer/src/components/native-chat/composer/pickers/use-native-chat-composer-command-picker.ts b/src/renderer/src/components/native-chat/composer/pickers/use-native-chat-composer-command-picker.ts index 66606d8c160..7de956b59f0 100644 --- a/src/renderer/src/components/native-chat/composer/pickers/use-native-chat-composer-command-picker.ts +++ b/src/renderer/src/components/native-chat/composer/pickers/use-native-chat-composer-command-picker.ts @@ -40,9 +40,17 @@ export function useNativeChatComposerCommandPicker( const hostSetsGoals = structuredTransport?.threadGoal !== undefined const hostSetsInteractionMode = structuredTransport?.optionSnapshot.some((entry) => entry.id === 'interactionMode') === true + const opensMcpPanel = structuredTransport?.mcpPanel !== undefined const acceptsText = useMemo( - () => prepareQueuedMessageEditText(agent, reported, hostSetsGoals, hostSetsInteractionMode), - [agent, reported, hostSetsGoals, hostSetsInteractionMode] + () => + prepareQueuedMessageEditText( + agent, + reported, + hostSetsGoals, + hostSetsInteractionMode, + opensMcpPanel + ), + [agent, reported, hostSetsGoals, hostSetsInteractionMode, opensMcpPanel] ) const commandAvailable = useCallback( (name: string) => mode === undefined || acceptsText(`/${name}`), diff --git a/src/renderer/src/components/native-chat/composer/pickers/use-native-chat-resume-command.ts b/src/renderer/src/components/native-chat/composer/pickers/use-native-chat-resume-command.ts index 69a00657551..0e685155d14 100644 --- a/src/renderer/src/components/native-chat/composer/pickers/use-native-chat-resume-command.ts +++ b/src/renderer/src/components/native-chat/composer/pickers/use-native-chat-resume-command.ts @@ -19,6 +19,7 @@ import type { NativeChatStructuredComposerTransport } from '@/components/native- export const NATIVE_CHAT_RESUME_COMMAND = 'resume' const FORK_DRAFT = /^\/fork(?:\s+([\s\S]*))?$/i +const MCP_DRAFT = /^\/mcp\s*$/i /** Replace the agent’s /resume catalog entry with Orca’s Session History command, and its /usage * and /fork with Orca’s where this chat runs them. */ @@ -92,7 +93,10 @@ export type NativeChatResumeCommand = { * reaches the agent. */ export function useNativeChatResumeCommand(args: { structuredTransport: - | Pick + | Pick< + NativeChatStructuredComposerTransport, + 'worktreeId' | 'accountUsage' | 'fork' | 'mcpPanel' + > | undefined agent: AgentType terminalTabId: string @@ -114,12 +118,13 @@ export function useNativeChatResumeCommand(args: { const structuredWorktreeId = structuredTransport?.worktreeId const usage = structuredTransport?.accountUsage const fork = structuredTransport?.fork - const openUsage = useCallback( - (draft: string): void => { + const mcp = structuredTransport?.mcpPanel + const openPanel = useCallback( + (draft: string, panel: { open: () => void } | undefined): void => { const rest = draft.replace(/^\/\S+\s*/, '') // Focus before opening so closing the modal returns to the editor even after a Send click. textareaRef.current?.focus() - usage?.open() + panel?.open() setDraft(rest) setCaret(rest.length) setActiveSuggestion(0) @@ -130,7 +135,7 @@ export function useNativeChatResumeCommand(args: { writeNativeChatComposerText(input, rest, rest.length) } }, - [usage, setDraft, setCaret, setActiveSuggestion, setNotice, textareaRef] + [setDraft, setCaret, setActiveSuggestion, setNotice, textareaRef] ) const runFork = useCallback( @@ -212,8 +217,10 @@ export function useNativeChatResumeCommand(args: { !isPickedSkill(draft) && (parseNativeChatResumeDraft(draft) !== null || (usage !== undefined && /^\/usage\s*$/i.test(draft)) || + // Before goal mode too, so `/mcp` never becomes an objective. + (mcp !== undefined && MCP_DRAFT.test(draft)) || (fork !== undefined && FORK_DRAFT.test(draft.trim()))), - [fork, isPickedSkill, structured, usage] + [fork, isPickedSkill, mcp, structured, usage] ) const consume = useCallback( (draft: string): boolean => { @@ -221,7 +228,9 @@ export function useNativeChatResumeCommand(args: { return false } if (/^\/usage\s*$/i.test(draft)) { - openUsage(draft) + openPanel(draft, usage) + } else if (MCP_DRAFT.test(draft)) { + openPanel(draft, mcp) } else if (fork && FORK_DRAFT.test(draft.trim())) { runFork(draft) } else { @@ -229,7 +238,7 @@ export function useNativeChatResumeCommand(args: { } return true }, - [fork, matchesDraft, open, openUsage, runFork] + [fork, matchesDraft, mcp, open, openPanel, runFork, usage] ) const interceptPick = useCallback( @@ -241,14 +250,15 @@ export function useNativeChatResumeCommand(args: { if ( item.name !== 'resume' && !(item.name === 'usage' && usage) && + !(item.name === 'mcp' && mcp) && !(item.name === 'fork' && fork) ) { pick(item) return } clearSkillOrigin() - if (item.name === 'usage') { - openUsage(textareaRef.current?.value ?? '') + if (item.name === 'usage' || item.name === 'mcp') { + openPanel(textareaRef.current?.value ?? '', item.name === 'usage' ? usage : mcp) return } if (item.name === 'fork') { @@ -263,7 +273,7 @@ export function useNativeChatResumeCommand(args: { } pick(item) }, - [autocomplete, clearSkillOrigin, fork, open, openUsage, runFork, textareaRef, usage] + [autocomplete, clearSkillOrigin, fork, mcp, open, openPanel, runFork, textareaRef, usage] ) return { consume, interceptPick, matchesDraft } diff --git a/src/renderer/src/components/native-chat/composer/submit/native-chat-composer-send-state.ts b/src/renderer/src/components/native-chat/composer/submit/native-chat-composer-send-state.ts index 94d98623835..eab041454c2 100644 --- a/src/renderer/src/components/native-chat/composer/submit/native-chat-composer-send-state.ts +++ b/src/renderer/src/components/native-chat/composer/submit/native-chat-composer-send-state.ts @@ -35,7 +35,12 @@ export function nativeChatComposerSendState( structuredTransport: | Pick< NativeChatStructuredComposerTransport, - 'sendOut' | 'sendBlockedReason' | 'threadGoal' | 'sendFollowUp' | 'optionSnapshot' + | 'sendOut' + | 'sendBlockedReason' + | 'threadGoal' + | 'sendFollowUp' + | 'optionSnapshot' + | 'mcpPanel' > | undefined editSession: { canSave: boolean } | undefined @@ -65,7 +70,8 @@ export function nativeChatComposerSendState( input.draft, transport.threadGoal !== undefined, input.agent, - transport.optionSnapshot + transport.optionSnapshot, + transport.mcpPanel !== undefined ) && !structuredAgentSessionCommandChangesConversation(input.draft)) const draftHeld = localHold || (hostHold && !runsWhileHostHolds) diff --git a/src/renderer/src/components/native-chat/composer/submit/native-chat-structured-composer-transport.ts b/src/renderer/src/components/native-chat/composer/submit/native-chat-structured-composer-transport.ts index 0b03de00b88..06b1c7fb00d 100644 --- a/src/renderer/src/components/native-chat/composer/submit/native-chat-structured-composer-transport.ts +++ b/src/renderer/src/components/native-chat/composer/submit/native-chat-structured-composer-transport.ts @@ -55,6 +55,8 @@ export type NativeChatStructuredComposerTransport = { sideChat?: NativeChatSideChat /** Present only where the host can read this chat's Codex account usage. */ accountUsage?: { open: () => void } + /** Present only where the host manages this agent's MCP servers; `/mcp` opens Orca's panel. */ + mcpPanel?: { open: () => void } /** Present only where this chat can fork; `run` answers with a notice when it didn't fork. */ fork?: { run: () => string | null } onError: (message: string | null, detail?: NativeChatComposerErrorDetail) => void diff --git a/src/renderer/src/components/native-chat/composer/submit/use-native-chat-composer-submit.ts b/src/renderer/src/components/native-chat/composer/submit/use-native-chat-composer-submit.ts index e5d40212268..83897864651 100644 --- a/src/renderer/src/components/native-chat/composer/submit/use-native-chat-composer-submit.ts +++ b/src/renderer/src/components/native-chat/composer/submit/use-native-chat-composer-submit.ts @@ -268,7 +268,8 @@ export function useNativeChatComposerSubmit(args: { commandText, threadGoal !== undefined, args.agent, - structuredTransport.optionSnapshot + structuredTransport.optionSnapshot, + structuredTransport.mcpPanel !== undefined ) ) return { send, goalMode, messageDelivery } diff --git a/src/renderer/src/components/native-chat/composer/submit/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/composer/submit/use-native-chat-structured-composer-send.ts index c0fbb53d8d1..6386adb9dfd 100644 --- a/src/renderer/src/components/native-chat/composer/submit/use-native-chat-structured-composer-send.ts +++ b/src/renderer/src/components/native-chat/composer/submit/use-native-chat-structured-composer-send.ts @@ -92,7 +92,8 @@ export function useNativeChatStructuredComposerSend({ text, structuredTransport.threadGoal !== undefined, agent, - structuredTransport.optionSnapshot + structuredTransport.optionSnapshot, + structuredTransport.mcpPanel !== undefined ) // Blocked hosts cannot take turns/conversation commands; option commands only configure later turns. if ( diff --git a/src/renderer/src/components/native-chat/composer/submit/use-native-chat-structured-composer-transport.ts b/src/renderer/src/components/native-chat/composer/submit/use-native-chat-structured-composer-transport.ts index 76ee19910b8..f00ff4abf7c 100644 --- a/src/renderer/src/components/native-chat/composer/submit/use-native-chat-structured-composer-transport.ts +++ b/src/renderer/src/components/native-chat/composer/submit/use-native-chat-structured-composer-transport.ts @@ -1,8 +1,14 @@ import { openNativeChatAccountUsageModal } from '@/components/native-chat/usage/native-chat-account-usage-modal' +import { openNativeChatMcpServersModal } from '@/components/native-chat/mcp/native-chat-mcp-servers-modal' +import { useStructuredAgentSessionHostCapabilityState } from '@/runtime/native-chat/host-capabilities/structured-agent-session-host-capability' +import { AGENT_SESSION_MCP_RUNTIME_CAPABILITY } from '../../../../../../shared/agent-session/agent-session-runtime-capabilities' import { useMemo, type Dispatch, type SetStateAction } from 'react' import { dispatchStructuredAgentSessionComposerCommand } from '../../../../../../shared/agent-session/view/structured-agent-session-composer' import { localizeStructuredComposerOutcome } from '@/components/native-chat/composer/submit/structured-composer-command-error-text' -import { useStructuredAgentAcceptsImages } from '@/runtime/native-chat/agent-catalogs/use-host-structured-agent' +import { + structuredAgentAcceptsImages, + useHostStructuredAgent +} from '@/runtime/native-chat/agent-catalogs/use-host-structured-agent' import type { NativeChatOptionPickerRequest } from '@/components/native-chat/session/options/controls/native-chat-option-picker-request' import type { NativeChatStructuredComposerTransport } from '@/components/native-chat/composer/submit/native-chat-structured-composer-transport' import type { NativeChatStructuredViewProps } from '@/components/native-chat/native-chat-view-types' @@ -92,14 +98,30 @@ export function useNativeChatStructuredComposerTransport(args: { const { revealLatest: onSubmitted, captureRevealLatest: captureAdmittedReveal } = args.reveal const { transportEnabled } = args const sendBlockedReason = args.sendBlockedReason ?? null - const acceptsImages = useStructuredAgentAcceptsImages( - props.target, + const hostAgent = useHostStructuredAgent(props.target, props.agent) + const acceptsImages = structuredAgentAcceptsImages( + hostAgent, props.agent, controller.acceptsImages ) + // The local runtime is this build; a paired host must say it answers agentSession.mcp. + const remoteManagesMcp = + useStructuredAgentSessionHostCapabilityState( + props.target.kind === 'local' ? null : props.target, + AGENT_SESSION_MCP_RUNTIME_CAPABILITY + ) === 'supported' + const managesMcp = + hostAgent?.capabilities.mcpServers === true && + (props.target.kind === 'local' || remoteManagesMcp) const { fork, sideChat } = forks return useMemo((): NativeChatStructuredComposerTransport => { const threadGoal = controller.threadGoal + const mcpPanel = managesMcp + ? { + open: () => + openNativeChatMcpServersModal({ sessionId: props.sessionId, target: props.target }) + } + : undefined // A held host takes no turn; pausing or clearing a goal starts none. const changeThreadGoal = threadGoal?.hostChanges ? (change: AgentSessionThreadGoalChange) => @@ -120,6 +142,7 @@ export function useNativeChatStructuredComposerTransport(args: { }) } : undefined, + mcpPanel, fork: nativeChatForkCommand(fork, controller.isWorking), capturePresentation: controller.capturePresentation, ...(sideChat ? { sideChat } : {}), @@ -162,7 +185,8 @@ export function useNativeChatStructuredComposerTransport(args: { conversationCommands: controller.conversationCommands, compactInstructions: controller.compactInstructions, runConversationCommand: controller.runConversationCommand, - ...(changeThreadGoal ? { changeThreadGoal } : {}) + ...(changeThreadGoal ? { changeThreadGoal } : {}), + ...(mcpPanel ? { openMcpPanel: mcpPanel.open } : {}) }, onSent ).then(localizeStructuredComposerOutcome), @@ -205,6 +229,7 @@ export function useNativeChatStructuredComposerTransport(args: { }, [ acceptsImages, controller, + managesMcp, fork, sideChat, worktreeId, diff --git a/src/renderer/src/components/native-chat/mcp/NativeChatMcpServerRow.tsx b/src/renderer/src/components/native-chat/mcp/NativeChatMcpServerRow.tsx new file mode 100644 index 00000000000..1d6cadc88e0 --- /dev/null +++ b/src/renderer/src/components/native-chat/mcp/NativeChatMcpServerRow.tsx @@ -0,0 +1,270 @@ +import { useState, type ReactNode } from 'react' +import type { + AgentSessionMcpOperation, + AgentSessionMcpServer, + AgentSessionMcpSignIn +} from '../../../../../shared/agent-session/agent-session-mcp' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' +import { useTranslate } from '@/i18n/use-translate' +import { cn } from '@/lib/class-names' +import { mcpServerStatusLine } from './native-chat-mcp-server-text' +import { + agentSessionMcpRowFailure, + agentSessionMcpServerDisplayName +} from '../../../../../shared/agent-session/view/agent-session-mcp-server-presentation' +import { useDeferredPending } from './use-deferred-pending' + +export type McpServerAction = Exclude + +const DOT_CLASS: Record, string> = { + connected: 'bg-status-success', + 'needs-auth': 'bg-status-warning', + failed: 'bg-destructive', + disabled: 'bg-muted-foreground/40' +} + +/** The label keeps its width while a spinner stands in for it. */ +function PendingLabel({ pending, children }: { pending: boolean; children: ReactNode }) { + const spinning = useDeferredPending(pending) + return ( + + {children} + {spinning ? : null} + + ) +} + +export function NativeChatMcpServerRow({ + server, + runtime, + signIn, + pending, + busy, + failure, + run, + dismissSignIn, + openUrl +}: { + server: AgentSessionMcpServer + /** Where the agent runs: only an agent on this computer receives the browser's sign-in callback. */ + runtime: 'local' | 'remote' + /** This row's sign-in in progress. */ + signIn: AgentSessionMcpSignIn | null + /** This row's action in flight. */ + pending: McpServerAction | null + /** Any row's action is in flight; the panel runs one at a time. */ + busy: boolean + /** The panel's last refused action; a null message has no detail. */ + failure: { server: string; message: string | null } | null + run: (operation: McpServerAction) => void + dismissSignIn: () => void + openUrl: (url: string) => void +}): React.JSX.Element { + const t = useTranslate() + const name = server.name + const refusal = agentSessionMcpRowFailure(server, failure, signIn) + const enabled = pending?.action === 'toggle' ? pending.enabled : server.status !== 'disabled' + const statusLine = signIn + ? signIn.finish === 'callback' && runtime === 'local' + ? t( + 'components.native-chat.mcp.signIn.waitingBrowser', + 'Waiting for sign-in in your browser…' + ) + : t('components.native-chat.mcp.signIn.waiting', 'Waiting for sign-in…') + : mcpServerStatusLine(server, t) + const action = signIn ? ( + + ) : server.canSignIn && server.status === 'needs-auth' ? ( + + ) : server.canReconnect && server.status === 'failed' ? ( + + ) : null + return ( +
+
+
+ {signIn ? ( + + ) : null} + {refusal ? ( +
+ {refusal.kind === 'signInUnfinished' + ? t( + 'components.native-chat.mcp.signIn.unfinished', + 'Sign-in isn’t finished yet. Finish it on the page that opened, then press Done.' + ) + : (refusal.message ?? + t('components.native-chat.mcp.actionFailed', 'Couldn’t do that. Try again.'))} +
+ ) : null} +
+ ) +} + +function McpSignInBox({ + signIn, + runtime, + pending, + busy, + run, + openUrl +}: { + signIn: AgentSessionMcpSignIn + runtime: 'local' | 'remote' + pending: McpServerAction | null + busy: boolean + run: (operation: McpServerAction) => void + openUrl: (url: string) => void +}): React.JSX.Element { + const t = useTranslate() + const [callbackUrl, setCallbackUrl] = useState('') + const openAgain = ( + + ) + const box = 'mr-3 mb-2.5 ml-8.5 rounded-md border border-border bg-muted/40 p-2.5 text-xs' + const hint = 'mb-2 text-muted-foreground' + if (signIn.finish === 'external') { + return ( +
+

+ {t( + 'components.native-chat.mcp.signIn.external', + 'Finish signing in on the page that opened, then press Done.' + )} +

+
+ + {openAgain} +
+
+ ) + } + if (runtime === 'local') { + return ( +
+

+ {t( + 'components.native-chat.mcp.signIn.local', + 'Opened the sign-in page in your browser. This updates when you finish.' + )} +

+ {openAgain} +
+ ) + } + const address = callbackUrl.trim() + return ( +
{ + event.preventDefault() + if (address && !busy) { + run({ action: 'finishSignIn', server: signIn.server, callbackUrl: address }) + } + }} + > +

+ {t( + 'components.native-chat.mcp.signIn.remote', + 'After you sign in, the browser lands on a page that won’t load. Copy that page’s address and paste it here.' + )} +

+
+ setCallbackUrl(event.target.value)} + placeholder={t( + 'components.native-chat.mcp.signIn.pastePlaceholder', + 'http://localhost:…' + )} + aria-label={t('components.native-chat.mcp.signIn.addressLabel', 'Sign-in page address')} + spellCheck={false} + autoComplete="off" + /> + + {openAgain} +
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/mcp/NativeChatMcpServersDialog.fixture.tsx b/src/renderer/src/components/native-chat/mcp/NativeChatMcpServersDialog.fixture.tsx new file mode 100644 index 00000000000..9b4eaf699db --- /dev/null +++ b/src/renderer/src/components/native-chat/mcp/NativeChatMcpServersDialog.fixture.tsx @@ -0,0 +1,99 @@ +import { defineFixture } from '../../../component-fixtures/component-fixture' +import type { AgentSessionMcpServer } from '../../../../../shared/agent-session/agent-session-mcp' +import type { AgentSessionMcpPanelState } from '../../../../../shared/agent-session/view/use-agent-session-mcp-panel' +import { NativeChatMcpServersDialogView } from './NativeChatMcpServersDialog' + +function server( + name: string, + status: AgentSessionMcpServer['status'], + fields: Partial = {} +): AgentSessionMcpServer { + return { + name, + status, + source: null, + toolCount: null, + error: null, + canSignIn: false, + canReconnect: false, + canToggle: true, + ...fields + } +} + +const CLAUDE_SERVERS: AgentSessionMcpServer[] = [ + server('Linear', 'connected', { source: 'claudeai', toolCount: 24 }), + server('Slack', 'needs-auth', { source: 'claudeai', canSignIn: true }), + server('postgres', 'failed', { + source: 'project', + error: 'connection refused', + canReconnect: true + }), + server('playwright', 'disabled', { source: 'project' }), + server('github', 'pending', { source: 'user' }) +] + +const SLACK_ONLY = [server('Slack', 'needs-auth', { source: 'claudeai', canSignIn: true })] + +function fixture(state: Partial, runtime: 'local' | 'remote' = 'local') { + return defineFixture({ + render: () => ( + {}} + retry={() => {}} + retrying={false} + dismissSignIn={() => {}} + openUrl={() => {}} + onClose={() => {}} + /> + ) + }) +} + +export const groupedList = fixture({ servers: CLAUDE_SERVERS }) + +export const codexList = fixture({ + servers: [ + server('context7', 'connected', { toolCount: 2 }), + server('notion', 'needs-auth', { canSignIn: true }), + server('sentry', 'disabled') + ] +}) + +export const localCallbackSignIn = fixture({ + servers: SLACK_ONLY, + signIn: { server: 'Slack', url: 'https://slack.com/oauth', finish: 'callback' } +}) + +export const remoteCallbackSignIn = fixture( + { + servers: SLACK_ONLY, + signIn: { server: 'Slack', url: 'https://slack.com/oauth', finish: 'callback' } + }, + 'remote' +) + +export const externalSignIn = fixture({ + servers: SLACK_ONLY, + signIn: { server: 'Slack', url: 'https://claude.ai/connectors', finish: 'external' } +}) + +export const rowFailure = fixture({ + servers: CLAUDE_SERVERS, + failure: { server: 'postgres', message: null } +}) + +export const notRunning = fixture({ result: { status: 'not-running' } }) + +export const readFailed = fixture({ result: { status: 'failed', message: null } }) + +export const empty = fixture({}) diff --git a/src/renderer/src/components/native-chat/mcp/NativeChatMcpServersDialog.tsx b/src/renderer/src/components/native-chat/mcp/NativeChatMcpServersDialog.tsx new file mode 100644 index 00000000000..33bababc83a --- /dev/null +++ b/src/renderer/src/components/native-chat/mcp/NativeChatMcpServersDialog.tsx @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { X } from 'lucide-react' +import { useAppStore } from '@/store' +import { Button } from '@/components/ui/button' +import { Dialog, DialogClose, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { useTranslate } from '@/i18n/use-translate' +import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' +import { callStructuredAgentSession } from '@/runtime/native-chat/requests/structured-agent-session-call' +import type { + AgentSessionMcpOperation, + AgentSessionMcpResult +} from '../../../../../shared/agent-session/agent-session-mcp' +import { + createAgentSessionMcpPanelHook, + type AgentSessionMcpPanelState +} from '../../../../../shared/agent-session/view/use-agent-session-mcp-panel' +import { + readNativeChatSessionModalRequest, + type NativeChatSessionModalRequest +} from '../session/native-chat-session-modal-request' +import { mcpSourceLabel } from './native-chat-mcp-server-text' +import { groupAgentSessionMcpServers } from '../../../../../shared/agent-session/view/agent-session-mcp-server-presentation' +import { NativeChatMcpServerRow, type McpServerAction } from './NativeChatMcpServerRow' +import { useDeferredPending } from './use-deferred-pending' + +const useAgentSessionMcpPanel = createAgentSessionMcpPanelHook({ + useCallback, + useEffect, + useRef, + useState +}) + +// Sign-in belongs in the user's browser, where their accounts are. +const openSignInPage = (url: string): void => { + openAppHttpLink(url, { destination: 'system' }) +} + +/** `/mcp`'s modal: each opening mounts a fresh read of the chat's servers. */ +export default function NativeChatMcpServersDialog(): React.JSX.Element | null { + const modalData = useAppStore((s) => s.modalData) + // The opening's chat is fixed; a fresh object per render would restart its read. + const [request] = useState(() => readNativeChatSessionModalRequest(modalData)) + return request ? : null +} + +function McpServersDialog({ + request +}: { + request: NativeChatSessionModalRequest +}): React.JSX.Element { + const closeModal = useAppStore((s) => s.closeModal) + const { sessionId, target } = request + const call = useCallback( + (operation: AgentSessionMcpOperation) => + callStructuredAgentSession(target, 'agentSession.mcp', { + sessionId, + operation + }), + [sessionId, target] + ) + const panel = useAgentSessionMcpPanel(call, openSignInPage) + const readAgain = panel.retry + const [retrying, setRetrying] = useState(false) + const retry = useCallback(() => { + setRetrying(true) + void readAgain().finally(() => setRetrying(false)) + }, [readAgain]) + return ( + void panel.run(operation)} + retry={retry} + retrying={retrying} + dismissSignIn={panel.dismissSignIn} + openUrl={openSignInPage} + onClose={closeModal} + /> + ) +} + +export function NativeChatMcpServersDialogView({ + onClose, + ...body +}: McpServersBodyProps & { onClose: () => void }): React.JSX.Element { + const t = useTranslate() + // Why: the slot has no trigger element for the dialog to hand focus back to. + const [returnFocusTo] = useState(() => + document.activeElement instanceof HTMLElement ? document.activeElement : null + ) + return ( + !open && onClose()}> + { + event.preventDefault() + if (returnFocusTo?.isConnected) { + returnFocusTo.focus({ preventScroll: true }) + } + }} + > +
+ + {t('components.native-chat.mcp.title', 'MCP servers')} + + + + +
+
+ +
+
+
+ ) +} + +type McpServersBodyProps = { + state: AgentSessionMcpPanelState + runtime: 'local' | 'remote' + run: (operation: McpServerAction) => void + retry: () => void + retrying: boolean + dismissSignIn: () => void + openUrl: (url: string) => void +} + +function McpServersBody({ + state, + runtime, + run, + retry, + retrying, + dismissSignIn, + openUrl +}: McpServersBodyProps): React.JSX.Element { + const t = useTranslate() + const loading = useDeferredPending(state.result === null) + const notice = 'px-3 py-4 text-sm text-muted-foreground' + if (state.result === null) { + return ( +
+ + + {t('components.native-chat.mcp.loading', 'Loading MCP servers…')} + +
+ ) + } + if (state.result.status === 'not-running') { + return ( +
+ {t( + 'components.native-chat.mcp.notRunning', + 'MCP servers connect when you send your next message.' + )} +
+ ) + } + if (state.result.status === 'unsupported') { + return ( +
+ {t( + 'components.native-chat.mcp.unsupported', + 'Update Orca on this chat’s host to manage MCP servers here.' + )} +
+ ) + } + if (state.result.status === 'failed') { + return ( +
+ + {state.result.message ?? + t( + 'components.native-chat.mcp.readFailed', + 'MCP servers couldn’t be loaded. Try again.' + )} + + +
+ ) + } + if (state.servers.length === 0) { + return ( +
+ {t('components.native-chat.mcp.empty', 'No MCP servers are set up for this chat.')} +
+ ) + } + const pending = state.pending + return ( +
+ {groupAgentSessionMcpServers(state.servers).map((group) => ( +
+ {group.source !== null ? ( +
+ {mcpSourceLabel(group.source, t)} +
+ ) : null} + {group.servers.map((server) => ( + + ))} +
+ ))} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/mcp/native-chat-mcp-server-text.ts b/src/renderer/src/components/native-chat/mcp/native-chat-mcp-server-text.ts new file mode 100644 index 00000000000..69786a0138b --- /dev/null +++ b/src/renderer/src/components/native-chat/mcp/native-chat-mcp-server-text.ts @@ -0,0 +1,50 @@ +import type { AgentSessionMcpServer } from '../../../../../shared/agent-session/agent-session-mcp' +import type { Translate } from '@/i18n/use-translate' + +export function mcpSourceLabel(source: string, t: Translate): string { + switch (source) { + case 'claudeai': + return 'claude.ai' + case 'project': + return t('components.native-chat.mcp.source.project', 'Project') + case 'local': + return t('components.native-chat.mcp.source.local', 'Local') + case 'user': + return t('components.native-chat.mcp.source.user', 'User') + case 'dynamic': + return t('components.native-chat.mcp.source.session', 'Session') + case 'plugin': + return t('components.native-chat.mcp.source.plugin', 'Plugin') + default: + return source.charAt(0).toUpperCase() + source.slice(1) + } +} + +export function mcpServerStatusLine(server: AgentSessionMcpServer, t: Translate): string { + switch (server.status) { + case 'connected': + return server.toolCount === null + ? t('components.native-chat.mcp.status.connected', 'Connected') + : t('components.native-chat.mcp.status.connectedTools', 'Connected · {{count}} tools', { + count: server.toolCount, + defaultValue_one: 'Connected · {{count}} tool' + }) + case 'pending': + return t('components.native-chat.mcp.status.pending', 'Connecting…') + case 'needs-auth': + return server.canSignIn + ? t('components.native-chat.mcp.status.needsAuth', 'Needs sign-in') + : t( + 'components.native-chat.mcp.status.needsAuthAfterSend', + 'Needs sign-in. Send a message to sign in here.' + ) + case 'failed': + return server.error + ? t('components.native-chat.mcp.status.failedReason', 'Failed: {{error}}', { + error: server.error + }) + : t('components.native-chat.mcp.status.failed', 'Failed') + case 'disabled': + return t('components.native-chat.mcp.status.disabled', 'Disabled') + } +} diff --git a/src/renderer/src/components/native-chat/mcp/native-chat-mcp-servers-modal.ts b/src/renderer/src/components/native-chat/mcp/native-chat-mcp-servers-modal.ts new file mode 100644 index 00000000000..c5e8d56b203 --- /dev/null +++ b/src/renderer/src/components/native-chat/mcp/native-chat-mcp-servers-modal.ts @@ -0,0 +1,10 @@ +import { useAppStore } from '@/store' +import type { NativeChatSessionModalRequest } from '../session/native-chat-session-modal-request' + +/** The chat `/mcp` was typed in; the modal manages the servers that chat's agent connects. */ +export function openNativeChatMcpServersModal(request: NativeChatSessionModalRequest): void { + useAppStore.getState().openModal('chat-mcp-servers', { + sessionId: request.sessionId, + target: request.target + }) +} diff --git a/src/renderer/src/components/native-chat/mcp/use-deferred-pending.ts b/src/renderer/src/components/native-chat/mcp/use-deferred-pending.ts new file mode 100644 index 00000000000..9fedb23296b --- /dev/null +++ b/src/renderer/src/components/native-chat/mcp/use-deferred-pending.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react' + +/** Deferred so a fast local round trip never flashes a spinner; the control greys at once. */ +export function useDeferredPending(pending: boolean): boolean { + const [shown, setShown] = useState(false) + useEffect(() => { + if (!pending) { + setShown(false) + return + } + const timer = setTimeout(() => setShown(true), 200) + return () => clearTimeout(timer) + }, [pending]) + return shown +} diff --git a/src/renderer/src/components/native-chat/queue/editing/use-native-chat-composer-edit-session.ts b/src/renderer/src/components/native-chat/queue/editing/use-native-chat-composer-edit-session.ts index ed7937ff5ee..c297c9ae0de 100644 --- a/src/renderer/src/components/native-chat/queue/editing/use-native-chat-composer-edit-session.ts +++ b/src/renderer/src/components/native-chat/queue/editing/use-native-chat-composer-edit-session.ts @@ -60,10 +60,17 @@ export function useNativeChatComposerEditSession({ const hostSetsGoals = structuredTransport?.threadGoal !== undefined const hostSetsInteractionMode = structuredTransport?.optionSnapshot.some((entry) => entry.id === 'interactionMode') === true + const opensMcpPanel = structuredTransport?.mcpPanel !== undefined const acceptsText = useMemo( () => - prepareQueuedMessageEditText(agent, sessionCommands, hostSetsGoals, hostSetsInteractionMode), - [agent, sessionCommands, hostSetsGoals, hostSetsInteractionMode] + prepareQueuedMessageEditText( + agent, + sessionCommands, + hostSetsGoals, + hostSetsInteractionMode, + opensMcpPanel + ), + [agent, sessionCommands, hostSetsGoals, hostSetsInteractionMode, opensMcpPanel] ) const isEditing = editSession !== undefined useLayoutEffect(() => { diff --git a/src/renderer/src/components/native-chat/session/native-chat-session-modal-request.ts b/src/renderer/src/components/native-chat/session/native-chat-session-modal-request.ts new file mode 100644 index 00000000000..2586677fc8c --- /dev/null +++ b/src/renderer/src/components/native-chat/session/native-chat-session-modal-request.ts @@ -0,0 +1,28 @@ +import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' + +/** A root modal opened from a chat's composer; it acts on that chat's session on its host. */ +export type NativeChatSessionModalRequest = { + sessionId: string + target: RuntimeClientTarget +} + +export function readNativeChatSessionModalRequest( + data: Record +): NativeChatSessionModalRequest | null { + const { sessionId, target } = data + if (typeof sessionId !== 'string' || typeof target !== 'object' || target === null) { + return null + } + if ('kind' in target && target.kind === 'local') { + return { sessionId, target: { kind: 'local' } } + } + if ( + 'kind' in target && + target.kind === 'environment' && + 'environmentId' in target && + typeof target.environmentId === 'string' + ) { + return { sessionId, target: { kind: 'environment', environmentId: target.environmentId } } + } + return null +} diff --git a/src/renderer/src/components/native-chat/usage/NativeChatAccountUsageDialog.tsx b/src/renderer/src/components/native-chat/usage/NativeChatAccountUsageDialog.tsx index b6b48a70cc7..25c031e3406 100644 --- a/src/renderer/src/components/native-chat/usage/NativeChatAccountUsageDialog.tsx +++ b/src/renderer/src/components/native-chat/usage/NativeChatAccountUsageDialog.tsx @@ -6,24 +6,20 @@ import { Dialog, DialogClose, DialogContent, DialogTitle } from '@/components/ui import { useTranslate } from '@/i18n/use-translate' import { NativeChatAccountUsageCard } from './NativeChatAccountUsageCard' import { - readNativeChatAccountUsageModalRequest, - type NativeChatAccountUsageModalRequest -} from './native-chat-account-usage-modal' + readNativeChatSessionModalRequest, + type NativeChatSessionModalRequest +} from '../session/native-chat-session-modal-request' import { useNativeChatAccountUsage } from './use-native-chat-account-usage' /** `/usage`'s modal: each opening mounts a fresh read of the chat's account. */ export default function NativeChatAccountUsageDialog(): React.JSX.Element | null { const modalData = useAppStore((s) => s.modalData) // The opening's chat is fixed; a fresh object per render would restart its read. - const [request] = useState(() => readNativeChatAccountUsageModalRequest(modalData)) + const [request] = useState(() => readNativeChatSessionModalRequest(modalData)) return request ? : null } -function UsageDialog({ - request -}: { - request: NativeChatAccountUsageModalRequest -}): React.JSX.Element { +function UsageDialog({ request }: { request: NativeChatSessionModalRequest }): React.JSX.Element { const t = useTranslate() const closeModal = useAppStore((s) => s.closeModal) const usage = useNativeChatAccountUsage(request.sessionId, request.target) diff --git a/src/renderer/src/components/native-chat/usage/native-chat-account-usage-modal.ts b/src/renderer/src/components/native-chat/usage/native-chat-account-usage-modal.ts index 3020518c9df..ce8b3d30eac 100644 --- a/src/renderer/src/components/native-chat/usage/native-chat-account-usage-modal.ts +++ b/src/renderer/src/components/native-chat/usage/native-chat-account-usage-modal.ts @@ -1,36 +1,10 @@ import { useAppStore } from '@/store' -import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' +import type { NativeChatSessionModalRequest } from '../session/native-chat-session-modal-request' -export type NativeChatAccountUsageModalRequest = { - /** The chat `/usage` was typed in; the modal reads the account that chat runs on. */ - sessionId: string - target: RuntimeClientTarget -} - -export function openNativeChatAccountUsageModal(request: NativeChatAccountUsageModalRequest): void { +/** The chat `/usage` was typed in; the modal reads the account that chat runs on. */ +export function openNativeChatAccountUsageModal(request: NativeChatSessionModalRequest): void { useAppStore.getState().openModal('chat-account-usage', { sessionId: request.sessionId, target: request.target }) } - -export function readNativeChatAccountUsageModalRequest( - data: Record -): NativeChatAccountUsageModalRequest | null { - const { sessionId, target } = data - if (typeof sessionId !== 'string' || typeof target !== 'object' || target === null) { - return null - } - if ('kind' in target && target.kind === 'local') { - return { sessionId, target: { kind: 'local' } } - } - if ( - 'kind' in target && - target.kind === 'environment' && - 'environmentId' in target && - typeof target.environmentId === 'string' - ) { - return { sessionId, target: { kind: 'environment', environmentId: target.environmentId } } - } - return null -} diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index aed48de2de2..873682fbc63 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1838,6 +1838,12 @@ "settingsUnconfirmed": "Codex did not confirm the selected model and effort. This goal change was not sent. Check the chat and try again.", "stopUnconfirmed": "Codex could not confirm Stop. Check the chat before continuing." }, + "mcp": { + "status": { + "connectedTools_one": "Connected · {{count}} tool", + "connectedTools_other": "Connected · {{count}} tools" + } + }, "notices": { "compaction": "Context compacted", "contextCleared": "Context cleared", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 93e4a23c9a1..5af43e6740f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -21375,7 +21375,8 @@ "export": "Export the session to HTML", "settings": "Open settings in Terminal", "extensions": "Open the extension dashboard in Terminal", - "hotkeys": "Show keyboard shortcuts in Terminal" + "hotkeys": "Show keyboard shortcuts in Terminal", + "mcpManage": "Manage MCP servers" }, "modelDescriptions": { "grok_4_7": "xAI's latest frontier model", @@ -21491,6 +21492,51 @@ "alreadyRedeemed": "That reset credit was already used." } }, + "mcp": { + "title": "MCP servers", + "loading": "Loading MCP servers…", + "notRunning": "MCP servers connect when you send your next message.", + "unsupported": "Update Orca on this chat’s host to manage MCP servers here.", + "readFailed": "MCP servers couldn’t be loaded. Try again.", + "retry": "Retry", + "empty": "No MCP servers are set up for this chat.", + "cancel": "Cancel", + "reconnect": "Reconnect", + "toggle": "Use {{name}}", + "actionFailed": "Couldn’t do that. Try again.", + "source": { + "project": "Project", + "local": "Local", + "user": "User", + "plugin": "Plugin", + "session": "Session" + }, + "status": { + "connected": "Connected", + "connectedTools_one": "Connected · {{count}} tool", + "connectedTools_other": "Connected · {{count}} tools", + "pending": "Connecting…", + "needsAuth": "Needs sign-in", + "failed": "Failed", + "failedReason": "Failed: {{error}}", + "disabled": "Disabled", + "needsAuthAfterSend": "Needs sign-in. Send a message to sign in here." + }, + "signIn": { + "action": "Sign in", + "waiting": "Waiting for sign-in…", + "waitingBrowser": "Waiting for sign-in in your browser…", + "local": "Opened the sign-in page in your browser. This updates when you finish.", + "remote": "After you sign in, the browser lands on a page that won’t load. Copy that page’s address and paste it here.", + "external": "Finish signing in on the page that opened, then press Done.", + "done": "Done", + "finish": "Finish", + "openAgain": "Open again", + "addressLabel": "Sign-in page address", + "unfinished": "Sign-in isn’t finished yet. Finish it on the page that opened, then press Done.", + "pastePlaceholder": "http://localhost:…" + } + }, "cardKindHidden": { "description": "You can change this anytime in", "settingsLink": "Settings > Chat UI > Turn activity", diff --git a/src/renderer/src/runtime/native-chat/agent-catalogs/use-host-structured-agent.ts b/src/renderer/src/runtime/native-chat/agent-catalogs/use-host-structured-agent.ts index 8716e71f90b..4419c5cafed 100644 --- a/src/renderer/src/runtime/native-chat/agent-catalogs/use-host-structured-agent.ts +++ b/src/renderer/src/runtime/native-chat/agent-catalogs/use-host-structured-agent.ts @@ -48,12 +48,3 @@ export function useHostStructuredAgent( ) return useAppViewExternalStore(subscribeHostStructuredAgents, read, read) } - -/** `reported` is the session's options answer: absent before it lands and from an older host. */ -export function useStructuredAgentAcceptsImages( - target: RuntimeClientTarget, - agent: string, - reported: boolean | undefined -): boolean { - return structuredAgentAcceptsImages(useHostStructuredAgent(target, agent), agent, reported) -} diff --git a/src/renderer/src/runtime/native-chat/requests/structured-agent-session-request-timeouts.ts b/src/renderer/src/runtime/native-chat/requests/structured-agent-session-request-timeouts.ts index df2a7be4da3..8b869f5acec 100644 --- a/src/renderer/src/runtime/native-chat/requests/structured-agent-session-request-timeouts.ts +++ b/src/renderer/src/runtime/native-chat/requests/structured-agent-session-request-timeouts.ts @@ -7,6 +7,8 @@ export const STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS: ReadonlyMap openModal: (modal: UISliceContextual['activeModal'], data?: Record) => void diff --git a/src/shared/agent-session/agent-session-capabilities.ts b/src/shared/agent-session/agent-session-capabilities.ts index f8ecd46814d..2b0a13c07a0 100644 --- a/src/shared/agent-session/agent-session-capabilities.ts +++ b/src/shared/agent-session/agent-session-capabilities.ts @@ -10,6 +10,8 @@ export type AgentSessionCapabilities = { compactInstructions?: boolean /** `/compact` runs while background tasks keep running; absent, it waits for them to end. */ compactBesideBackgroundTasks?: boolean + /** `/mcp` opens Orca's panel for the agent's MCP servers (`agentSession.mcp`). */ + mcpServers?: boolean /** A goal the user can set and change on the thread. */ threadGoal: boolean /** Turns report how much of the context window they used. */ diff --git a/src/shared/agent-session/agent-session-mcp.ts b/src/shared/agent-session/agent-session-mcp.ts new file mode 100644 index 00000000000..ac2cb5ec08f --- /dev/null +++ b/src/shared/agent-session/agent-session-mcp.ts @@ -0,0 +1,61 @@ +/** The chat's agent connects these MCP servers; the `/mcp` panel lists and manages them. */ +export type AgentSessionMcpServerStatus = + | 'connected' + | 'pending' + | 'needs-auth' + | 'failed' + | 'disabled' + +export type AgentSessionMcpServer = { + name: string + status: AgentSessionMcpServerStatus + /** Where the agent found the server, e.g. claudeai, project, user, local, plugin; null when unknown. */ + source: string | null + toolCount: number | null + /** Short reason for a failed server, as the agent reported it. */ + error: string | null + canSignIn: boolean + canReconnect: boolean + canToggle: boolean +} + +export type AgentSessionMcpOperation = + | { action: 'status' } + | { action: 'signIn'; server: string } + /** The address the browser landed on after sign-in, for an agent the browser can't reach. */ + | { action: 'finishSignIn'; server: string; callbackUrl: string } + | { action: 'reconnect'; server: string } + | { action: 'toggle'; server: string; enabled: boolean } + +export type AgentSessionMcpSignIn = { + server: string + url: string + /** + * `callback`: the agent waits for the browser to come back to it; a browser on another device + * pastes that address instead. `external`: the provider's own site finishes it, then reconnect. + */ + finish: 'callback' | 'external' +} + +export type AgentSessionMcpResult = + | { status: 'ok'; servers: AgentSessionMcpServer[]; signIn?: AgentSessionMcpSignIn } + /** The chat has no running agent; its servers connect when it next starts. */ + | { status: 'not-running' } + | { status: 'unsupported' } + | { status: 'failed'; message: string | null } + +/** A sign-in page the client may open: providers pass on URLs from server-controlled metadata. */ +export function agentSessionMcpWebUrl(url: unknown): string | null { + if (typeof url !== 'string' || !URL.canParse(url)) { + return null + } + const { protocol } = new URL(url) + return protocol === 'https:' || protocol === 'http:' ? url : null +} + +/** Agents report free-form failures; keep one readable line. */ +export function agentSessionMcpFailureMessage(error: unknown): string | null { + const text = error instanceof Error ? error.message : typeof error === 'string' ? error : '' + const line = text.split('\n')[0]?.trim() ?? '' + return line ? line.slice(0, 300) : null +} diff --git a/src/shared/agent-session/agent-session-registered-agents.ts b/src/shared/agent-session/agent-session-registered-agents.ts index 071c0977929..f6ed5c19335 100644 --- a/src/shared/agent-session/agent-session-registered-agents.ts +++ b/src/shared/agent-session/agent-session-registered-agents.ts @@ -30,6 +30,8 @@ const registeredAgentSchema = z.object({ rewind: capabilityFlag, compact: capabilityFlag, threadGoal: capabilityFlag, + // Optional, like fork: absent stays absent, and anything but true offers no MCP panel. + mcpServers: z.literal(true).optional().catch(undefined), contextUsage: capabilityFlag, imagePrompts: capabilityFlag, // An unknown fork level reads as absent: the host never forks for this client. diff --git a/src/shared/agent-session/agent-session-runtime-capabilities.ts b/src/shared/agent-session/agent-session-runtime-capabilities.ts index 0ec3a1394d6..10bf55cc193 100644 --- a/src/shared/agent-session/agent-session-runtime-capabilities.ts +++ b/src/shared/agent-session/agent-session-runtime-capabilities.ts @@ -111,6 +111,8 @@ export const AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY = // Why: hosts before this report goals in options but refuse agentSession.threadGoal from a phone. export const AGENT_SESSION_PHONE_THREAD_GOAL_RUNTIME_CAPABILITY = 'agent-session.thread-goal.phone.v1' as const +// The host answers agentSession.mcp, from desktops and phones alike. +export const AGENT_SESSION_MCP_RUNTIME_CAPABILITY = 'agent-session.mcp.v1' as const // Why: hosts before this advertise usage-limit auto-continue but refuse its cancel from a phone. export const AGENT_SESSION_PHONE_USAGE_LIMIT_CONTINUE_CANCEL_RUNTIME_CAPABILITY = 'agent-session.usage-limit-auto-continue.phone-cancel.v1' as const @@ -176,6 +178,7 @@ export const AGENT_SESSION_RUNTIME_CAPABILITIES = /* @__PURE__ */ (() => AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY, AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY, AGENT_SESSION_PHONE_THREAD_GOAL_RUNTIME_CAPABILITY, + AGENT_SESSION_MCP_RUNTIME_CAPABILITY, AGENT_SESSION_PHONE_USAGE_LIMIT_CONTINUE_CANCEL_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, diff --git a/src/shared/agent-session/view/agent-session-mcp-server-presentation.ts b/src/shared/agent-session/view/agent-session-mcp-server-presentation.ts new file mode 100644 index 00000000000..a2e5905b89c --- /dev/null +++ b/src/shared/agent-session/view/agent-session-mcp-server-presentation.ts @@ -0,0 +1,57 @@ +import type { AgentSessionMcpServer, AgentSessionMcpSignIn } from '../agent-session-mcp' + +export type AgentSessionMcpServerGroup = { + /** null for an agent that reports no source; its servers show without a heading. */ + source: string | null + servers: AgentSessionMcpServer[] +} + +/** Groups keep the order the agent listed their first server in. */ +export function groupAgentSessionMcpServers( + servers: readonly AgentSessionMcpServer[] +): AgentSessionMcpServerGroup[] { + const groups = new Map() + for (const server of servers) { + const group = groups.get(server.source) + if (group) { + group.servers.push(server) + } else { + groups.set(server.source, { source: server.source, servers: [server] }) + } + } + return [...groups.values()] +} + +const CLAUDE_AI_PREFIX = 'claude.ai ' + +/** claude.ai connectors already sit under a claude.ai heading. */ +export function agentSessionMcpServerDisplayName(server: AgentSessionMcpServer): string { + return server.source === 'claudeai' && + server.name.startsWith(CLAUDE_AI_PREFIX) && + server.name.length > CLAUDE_AI_PREFIX.length + ? server.name.slice(CLAUDE_AI_PREFIX.length) + : server.name +} + +export type AgentSessionMcpRowFailure = + | { kind: 'signInUnfinished' } + | { kind: 'message'; message: string | null } + +/** What a row shows for its last refused action, if anything. */ +export function agentSessionMcpRowFailure( + server: AgentSessionMcpServer, + failure: { server: string; message: string | null } | null, + signIn: AgentSessionMcpSignIn | null +): AgentSessionMcpRowFailure | null { + if (failure?.server !== server.name) { + return null + } + // The provider refuses a reconnect until its own site finishes the sign-in. + if (signIn?.server === server.name && signIn.finish === 'external') { + return { kind: 'signInUnfinished' } + } + // The status line already says why the server failed. + return failure.message !== null && failure.message === server.error + ? null + : { kind: 'message', message: failure.message } +} diff --git a/src/shared/agent-session/view/structured-agent-session-composer.ts b/src/shared/agent-session/view/structured-agent-session-composer.ts index 86a99c3a627..f18d2aef9d8 100644 --- a/src/shared/agent-session/view/structured-agent-session-composer.ts +++ b/src/shared/agent-session/view/structured-agent-session-composer.ts @@ -1,14 +1,14 @@ import { agentSessionInteractionCommand } from '../options/agent-session-interaction-mode' -import { getTextDrivenNativeChatCommands } from '../../native-chat/native-chat-agent-profiles' import type { AgentType } from '../../agent-status/contracts/agent-status-types' import type { SessionOptionDescriptor, SessionOptionValue } from '../../native-chat/input/native-chat-session-options' +import type { SlashCommandSuggestion } from '../../native-chat/input/native-chat-slash-commands' import { - sessionSlashCommandSuggestions, - type SlashCommandSuggestion -} from '../../native-chat/input/native-chat-slash-commands' + CONVERSATION_COMMANDS, + STRUCTURED_AGENT_SESSION_SLASH_COMMANDS +} from './structured-agent-session-slash-menu' import type { AgentSessionConversationCommand } from '../agent-session-conversation-command' import type { AgentSessionThreadGoalChange } from '../agent-session-thread-goal-wire' import type { AgentSessionSlashCommand } from '../agent-session-wire' @@ -18,19 +18,6 @@ import { type StructuredComposerFailureParams } from '../failures/structured-agent-session-composer-failure' -const COMPACT_INSTRUCTIONS_HINT = '[instructions]' - -const CONVERSATION_COMMANDS: readonly SlashCommandSuggestion[] = [ - { name: 'clear', description: 'Clear conversation context' }, - { name: 'compact', description: 'Compact conversation context' } -] - -/** Session options remain available on hosts predating conversation commands. */ -export const STRUCTURED_AGENT_SESSION_SLASH_COMMANDS: readonly SlashCommandSuggestion[] = [ - { name: 'model', description: 'Choose the model' }, - { name: 'effort', description: 'Choose reasoning effort' } -] - export type StructuredAgentSessionComposerOptions = { agent?: AgentType snapshot: readonly SessionOptionDescriptor[] @@ -48,6 +35,8 @@ export type StructuredAgentSessionComposerOptions = { ) => Promise> /** Without host goal support, /goal remains agent-handled text. */ changeThreadGoal?: (change: AgentSessionThreadGoalChange) => Promise + /** Without it, /mcp remains agent-handled text. */ + openMcpPanel?: () => void } /** Visible condition holding a refusal: agent/prompt/background work, an in-flight send or a Retry card. */ @@ -86,63 +75,6 @@ function commandParts(text: string): { name: string; argument: string } | null { : { name, argument } } -/** Fallback menu combines host-owned and agent text-driven commands when no catalog is reported. */ -export function structuredSlashCommands( - commands: readonly AgentSessionConversationCommand[] = [], - agent?: AgentType | null, - compactInstructions = false -): readonly SlashCommandSuggestion[] { - const hostOwned = [ - ...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS, - ...CONVERSATION_COMMANDS.filter((entry) => - commands.includes(entry.name as AgentSessionConversationCommand) - ).map((entry) => - entry.name === 'compact' && compactInstructions - ? { ...entry, argumentHint: COMPACT_INSTRUCTIONS_HINT } - : entry - ) - ] - // No reported catalog must not hide agent-handled commands such as Codex /goal. - return [ - ...hostOwned, - ...getTextDrivenNativeChatCommands(agent).filter( - (entry) => !hostOwned.some((offered) => offered.name === entry.name) - ) - ] -} - -/** Host commands remain first; reported catalogs add other names. */ -export function structuredSessionSlashCommands( - agent: AgentType, - conversationCommands: readonly AgentSessionConversationCommand[] | undefined, - reported: readonly AgentSessionSlashCommand[] | undefined, - compactInstructions?: boolean, - interactionModeSupported = false -): readonly SlashCommandSuggestion[] { - const modeCommands: SlashCommandSuggestion[] = interactionModeSupported - ? [ - { name: 'plan', description: 'Switch to Plan mode', orcaOwned: true }, - ...(agent === 'codex' - ? [{ name: 'default', description: 'Switch to Build mode', orcaOwned: true as const }] - : []) - ] - : [] - const hostOwned = [ - ...modeCommands, - ...structuredSlashCommands(conversationCommands, agent, compactInstructions).filter( - ({ name }) => !modeCommands.some((entry) => entry.name === name) - ) - ] - if (reported === undefined) { - return hostOwned - } - const offered = new Set(hostOwned.map(({ name }) => name)) - return [ - ...hostOwned, - ...sessionSlashCommandSuggestions(agent, reported).filter(({ name }) => !offered.has(name)) - ] -} - function structuredRecognizedCommands(): readonly SlashCommandSuggestion[] { return [...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS, ...CONVERSATION_COMMANDS] } @@ -163,16 +95,18 @@ export function isStructuredAgentSessionComposerCommand( ) } -/** Host actions send no message; /goal is an action only on hosts that set goals. */ +/** Host actions send no message; /goal and /mcp are actions only where the host offers them. */ export function isStructuredAgentSessionHostCommand( text: string, hostSetsGoals: boolean, agent: AgentType | undefined, - snapshot: readonly SessionOptionDescriptor[] + snapshot: readonly SessionOptionDescriptor[], + opensMcpPanel = false ): boolean { return ( isStructuredAgentSessionComposerCommand(text, agent, snapshot) || - (hostSetsGoals && isStructuredAgentSessionGoalCommand(text)) + (hostSetsGoals && isStructuredAgentSessionGoalCommand(text)) || + (opensMcpPanel && commandParts(text)?.name === 'mcp') ) } @@ -258,6 +192,10 @@ export async function dispatchStructuredAgentSessionComposerCommand( } } const command = commandParts(text) + if (command?.name === 'mcp' && controller.openMcpPanel) { + controller.openMcpPanel() + return { handled: true, accepted: true, error: null } + } if (command?.name === 'goal' && controller.changeThreadGoal) { if (!command.argument) { return { diff --git a/src/shared/agent-session/view/structured-agent-session-slash-menu.ts b/src/shared/agent-session/view/structured-agent-session-slash-menu.ts new file mode 100644 index 00000000000..ba334560ff0 --- /dev/null +++ b/src/shared/agent-session/view/structured-agent-session-slash-menu.ts @@ -0,0 +1,87 @@ +import { getTextDrivenNativeChatCommands } from '../../native-chat/native-chat-agent-profiles' +import type { AgentType } from '../../agent-status/contracts/agent-status-types' +import { + sessionSlashCommandSuggestions, + type SlashCommandSuggestion +} from '../../native-chat/input/native-chat-slash-commands' +import type { AgentSessionConversationCommand } from '../agent-session-conversation-command' +import type { AgentSessionSlashCommand } from '../agent-session-wire' + +const COMPACT_INSTRUCTIONS_HINT = '[instructions]' + +export const CONVERSATION_COMMANDS: readonly SlashCommandSuggestion[] = [ + { name: 'clear', description: 'Clear conversation context' }, + { name: 'compact', description: 'Compact conversation context' } +] + +/** Orca's MCP panel, offered where the host manages this agent's servers. */ +export const STRUCTURED_AGENT_SESSION_MCP_COMMAND: SlashCommandSuggestion = { + name: 'mcp', + description: 'Manage MCP servers', + orcaOwned: true +} + +/** Session options remain available on hosts predating conversation commands. */ +export const STRUCTURED_AGENT_SESSION_SLASH_COMMANDS: readonly SlashCommandSuggestion[] = [ + { name: 'model', description: 'Choose the model' }, + { name: 'effort', description: 'Choose reasoning effort' } +] + +/** Fallback menu combines host-owned and agent text-driven commands when no catalog is reported. */ +export function structuredSlashCommands( + commands: readonly AgentSessionConversationCommand[] = [], + agent?: AgentType | null, + compactInstructions = false +): readonly SlashCommandSuggestion[] { + const hostOwned = [ + ...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS, + ...CONVERSATION_COMMANDS.filter((entry) => + commands.includes(entry.name as AgentSessionConversationCommand) + ).map((entry) => + entry.name === 'compact' && compactInstructions + ? { ...entry, argumentHint: COMPACT_INSTRUCTIONS_HINT } + : entry + ) + ] + // No reported catalog must not hide agent-handled commands such as Codex /goal. + return [ + ...hostOwned, + ...getTextDrivenNativeChatCommands(agent).filter( + (entry) => !hostOwned.some((offered) => offered.name === entry.name) + ) + ] +} + +/** Host commands remain first; reported catalogs add other names. */ +export function structuredSessionSlashCommands( + agent: AgentType, + conversationCommands: readonly AgentSessionConversationCommand[] | undefined, + reported: readonly AgentSessionSlashCommand[] | undefined, + compactInstructions?: boolean, + interactionModeSupported = false, + mcpPanel = false +): readonly SlashCommandSuggestion[] { + const modeCommands: SlashCommandSuggestion[] = interactionModeSupported + ? [ + { name: 'plan', description: 'Switch to Plan mode', orcaOwned: true }, + ...(agent === 'codex' + ? [{ name: 'default', description: 'Switch to Build mode', orcaOwned: true as const }] + : []) + ] + : [] + const orcaOwned = [...modeCommands, ...(mcpPanel ? [STRUCTURED_AGENT_SESSION_MCP_COMMAND] : [])] + const hostOwned = [ + ...orcaOwned, + ...structuredSlashCommands(conversationCommands, agent, compactInstructions).filter( + ({ name }) => !orcaOwned.some((entry) => entry.name === name) + ) + ] + if (reported === undefined) { + return hostOwned + } + const offered = new Set(hostOwned.map(({ name }) => name)) + return [ + ...hostOwned, + ...sessionSlashCommandSuggestions(agent, reported).filter(({ name }) => !offered.has(name)) + ] +} diff --git a/src/shared/agent-session/view/use-agent-session-mcp-panel.ts b/src/shared/agent-session/view/use-agent-session-mcp-panel.ts new file mode 100644 index 00000000000..6d932698aa4 --- /dev/null +++ b/src/shared/agent-session/view/use-agent-session-mcp-panel.ts @@ -0,0 +1,181 @@ +import type * as React from 'react' +import type { + AgentSessionMcpOperation, + AgentSessionMcpResult, + AgentSessionMcpServer, + AgentSessionMcpSignIn +} from '../agent-session-mcp' + +export type AgentSessionMcpRequest = ( + operation: AgentSessionMcpOperation +) => Promise + +export type AgentSessionMcpPanelState = { + /** null while the first read is in flight. */ + result: Exclude | { status: 'ok' } | null + servers: readonly AgentSessionMcpServer[] + /** The action in flight, one at a time. */ + pending: Exclude | null + signIn: AgentSessionMcpSignIn | null + /** The last action's refusal, as the agent worded it; null message means no detail. */ + failure: { server: string; message: string | null } | null +} + +const POLL_MS = 2_000 +const MAX_POLL_MS = 15_000 +// Agents close their sign-in callback after five minutes. +const SIGN_IN_WINDOW_MS = 5 * 60_000 + +/** State for the `/mcp` panel; `openUrl` opens a sign-in page in the device's browser. */ +export function createAgentSessionMcpPanelHook({ + useCallback, + useEffect, + useRef, + useState +}: Pick) { + return function useAgentSessionMcpPanel( + request: AgentSessionMcpRequest, + openUrl: (url: string) => void + ) { + const [state, setState] = useState({ + result: null, + servers: [], + pending: null, + signIn: null, + failure: null + }) + const busy = useRef(false) + const reading = useRef(false) + // Bumped as each action starts and ends, so a read that overlapped one can't show older state. + const actions = useRef(0) + const mounted = useRef(true) + useEffect(() => { + mounted.current = true + return () => { + mounted.current = false + } + }, []) + + const apply = useCallback( + (reply: AgentSessionMcpResult, operation: AgentSessionMcpOperation) => { + if (!mounted.current) { + return + } + setState((current) => { + // A status read lands beside an action still in flight; only the action's reply ends it. + const pending = operation.action === 'status' ? current.pending : null + if (reply.status !== 'ok') { + const server = 'server' in operation ? operation.server : null + // A failed sign-in poll keeps the list and the sign-in it watches. + if (!server && reply.status === 'failed' && current.result?.status === 'ok') { + return current + } + // A failed action keeps the list; a failed first read replaces it. + return server && reply.status === 'failed' + ? { ...current, pending, failure: { server, message: reply.message } } + : { ...current, pending, result: reply, servers: [], signIn: null } + } + const signIn = + reply.signIn ?? + (current.signIn && + reply.servers.some( + (entry) => entry.name === current.signIn?.server && entry.status !== 'connected' + ) + ? current.signIn + : null) + return { + result: { status: 'ok' }, + servers: reply.servers, + pending, + signIn, + failure: operation.action === 'status' ? current.failure : null + } + }) + }, + [] + ) + + const read = useCallback(async () => { + if (reading.current) { + return + } + reading.current = true + const operation = { action: 'status' } as const + const started = actions.current + const reply = await request(operation).catch((): AgentSessionMcpResult => ({ + status: 'failed', + message: null + })) + reading.current = false + if (actions.current === started) { + apply(reply, operation) + } + }, [apply, request]) + + const run = useCallback( + async (operation: Exclude) => { + if (busy.current) { + return + } + busy.current = true + actions.current += 1 + setState((current) => ({ ...current, pending: operation, failure: null })) + const reply = await request(operation).catch((): AgentSessionMcpResult => ({ + status: 'failed', + message: null + })) + busy.current = false + actions.current += 1 + // A panel closed while the request ran must not open a browser. + if (reply.status === 'ok' && reply.signIn && mounted.current) { + openUrl(reply.signIn.url) + } + apply(reply, operation) + // Agents refuse an action that half-happened (a server enabled but needing sign-in); show + // where it ended up. + if (reply.status === 'failed') { + void read() + } + }, + [apply, openUrl, read, request] + ) + + const dismissSignIn = useCallback(() => { + setState((current) => ({ ...current, signIn: null, failure: null })) + }, []) + + useEffect(() => { + void read() + }, [read]) + + // Nothing reports when an agent finishes a callback sign-in or a server settles from + // connecting; re-read with backoff, and stop at the agents' own five-minute window. + const watching = state.signIn?.finish === 'callback' ? state.signIn : null + const connecting = state.servers.some((server) => server.status === 'pending') + useEffect(() => { + if (!watching && !connecting) { + return + } + const started = Date.now() + let delay = POLL_MS + let timer: ReturnType | undefined + const tick = (): void => { + if (Date.now() - started > SIGN_IN_WINDOW_MS) { + setState((current) => + watching && current.signIn === watching ? { ...current, signIn: null } : current + ) + return + } + if (!busy.current) { + void read() + } + delay = Math.min(delay * 1.5, MAX_POLL_MS) + timer = setTimeout(tick, delay) + } + timer = setTimeout(tick, delay) + return () => clearTimeout(timer) + }, [connecting, read, watching]) + + return { ...state, run, retry: read, dismissSignIn } + } +} diff --git a/src/shared/queued-message-edit-command.ts b/src/shared/queued-message-edit-command.ts index ef827bfea6d..e874d2de2c1 100644 --- a/src/shared/queued-message-edit-command.ts +++ b/src/shared/queued-message-edit-command.ts @@ -12,16 +12,24 @@ export function queuedMessageEditAcceptsText( agent: AgentType, reported: readonly AgentSessionSlashCommand[] | undefined, hostSetsGoals: boolean, - hostSetsInteractionMode: boolean + hostSetsInteractionMode: boolean, + opensMcpPanel = false ): boolean { - return prepareQueuedMessageEditText(agent, reported, hostSetsGoals, hostSetsInteractionMode)(text) + return prepareQueuedMessageEditText( + agent, + reported, + hostSetsGoals, + hostSetsInteractionMode, + opensMcpPanel + )(text) } export function prepareQueuedMessageEditText( agent: AgentType, reported: readonly AgentSessionSlashCommand[] | undefined, hostSetsGoals: boolean, - hostSetsInteractionMode: boolean + hostSetsInteractionMode: boolean, + opensMcpPanel = false ): (text: string) => boolean { const hostCommand = prepareStructuredAgentSessionComposerCommand(agent, reported) return (text) => @@ -29,6 +37,7 @@ export function prepareQueuedMessageEditText( hostCommand(text) || (hostSetsInteractionMode && agentSessionInteractionCommand(agent, text) !== null) || (hostSetsGoals && isStructuredAgentSessionGoalCommand(text)) || - /^\/resume(?:\s|$)/i.test(text) + /^\/resume(?:\s|$)/i.test(text) || + (opensMcpPanel && /^\/mcp(?:\s|$)/i.test(text)) ) } diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 0ec49aa2ac4..e08337b755d 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -660,6 +660,7 @@ import { CurrentRequestsParams } from './structured-agent-session/agent-session- import { ReadVisualParams } from './structured-agent-session/agent-session-visual-params' import { ContinueInterruptedParams } from './structured-agent-session/structured-agent-session-continue-params' import { ContinuationContextParams } from './structured-agent-session/structured-agent-session-identifiers' +import { AgentSessionMcpParams } from './structured-agent-session/structured-agent-session-mcp-params' import { AcknowledgeAttentionParams, AgentsParams, @@ -878,6 +879,7 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.history': HistoryParams, 'agentSession.hold': HoldParams, 'agentSession.item': ItemParams, + 'agentSession.mcp': AgentSessionMcpParams, 'agentSession.modelCatalog': ModelCatalogParams, 'agentSession.options': OptionsParams, 'agentSession.queuedMessageDelete': QueuedMessageActionParams, diff --git a/src/shared/rpc-contract/structured-agent-session/structured-agent-session-mcp-params.ts b/src/shared/rpc-contract/structured-agent-session/structured-agent-session-mcp-params.ts new file mode 100644 index 00000000000..d07c0003579 --- /dev/null +++ b/src/shared/rpc-contract/structured-agent-session/structured-agent-session-mcp-params.ts @@ -0,0 +1,27 @@ +import * as z from 'zod' +import { SessionId } from './structured-agent-session-identifiers' + +const ServerName = z + .string() + .min(1) + .max(512) + .refine((name) => [...name].every((char) => char >= ' ' && char !== '\u007f')) + +export const AgentSessionMcpParams = z + .object({ + sessionId: SessionId, + operation: z.discriminatedUnion('action', [ + z.object({ action: z.literal('status') }).strict(), + z.object({ action: z.literal('signIn'), server: ServerName }).strict(), + z + .object({ + action: z.literal('finishSignIn'), + server: ServerName, + callbackUrl: z.string().min(1).max(8192) + }) + .strict(), + z.object({ action: z.literal('reconnect'), server: ServerName }).strict(), + z.object({ action: z.literal('toggle'), server: ServerName, enabled: z.boolean() }).strict() + ]) + }) + .strict()