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()