diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index f66ff3f90..c24f3bff0 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -13,7 +13,7 @@ import { View, ActivityIndicator, Text } from "react-native"; import { UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { darkTheme } from "@/styles/theme"; import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context"; -import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context"; +import { DaemonConnectionsProvider, useDaemonConnections } from "@/contexts/daemon-connections-context"; import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host"; import { QueryClientProvider } from "@tanstack/react-query"; import { useState, useEffect, type ReactNode, useMemo, useRef } from "react"; @@ -253,8 +253,53 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { function ProvidersWrapper({ children }: { children: ReactNode }) { const { settings, isLoading: settingsLoading } = useAppSettings(); const { daemons, isLoading: registryLoading, upsertDaemonFromOfferUrl } = useDaemonRegistry(); + const { connectionStates } = useDaemonConnections(); const isLoading = settingsLoading || registryLoading; + const isInitialConnectionPending = useMemo(() => { + if (daemons.length === 0) return false; + return daemons.some((daemon) => { + const record = connectionStates.get(daemon.serverId); + if (!record) return true; + if (record.hasEverReceivedAgentList) return false; + return ( + record.status === "idle" || + record.status === "connecting" || + (record.status === "online" && !record.agentListReady) + ); + }); + }, [daemons, connectionStates]); + + const [connectionTimedOut, setConnectionTimedOut] = useState(false); + + useEffect(() => { + if (!isInitialConnectionPending) { + setConnectionTimedOut(false); + return; + } + const timer = setTimeout(() => setConnectionTimedOut(true), 5000); + return () => clearTimeout(timer); + }, [isInitialConnectionPending]); + + const connectingMessage = useMemo(() => { + if (!isInitialConnectionPending) return null; + const pending = daemons.filter((daemon) => { + const record = connectionStates.get(daemon.serverId); + if (!record) return true; + if (record.hasEverReceivedAgentList) return false; + return ( + record.status === "idle" || + record.status === "connecting" || + (record.status === "online" && !record.agentListReady) + ); + }); + if (pending.length === 1) { + const label = pending[0].label?.trim(); + return label ? `Connecting to ${label}...` : "Connecting..."; + } + return "Connecting..."; + }, [isInitialConnectionPending, daemons, connectionStates]); + // Apply theme setting on mount and when it changes useEffect(() => { if (isLoading) return; @@ -270,6 +315,10 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { return ; } + if (connectingMessage && !connectionTimedOut) { + return ; + } + return ( @@ -339,7 +388,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) { ); } -function LoadingView() { +function LoadingView({ message }: { message?: string } = {}) { return ( + {message ? ( + + {message} + + ) : null} ); } diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 75d9e5435..50843c8ce 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -23,6 +23,7 @@ import type { AgentUsage, ListModelsOptions, ListPersistedAgentsOptions, + McpServerConfig, PersistedAgentDescriptor, } from "../agent-sdk-types.js"; @@ -45,6 +46,58 @@ const DEFAULT_MODES: AgentMode[] = [ type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" }; +type OpenCodeMcpConfig = + | { + type: "local"; + command: string[]; + environment?: Record; + enabled?: boolean; + } + | { + type: "remote"; + url: string; + headers?: Record; + enabled?: boolean; + }; + +const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const; + +function toOpenCodeMcpConfig(config: McpServerConfig): OpenCodeMcpConfig { + if (config.type === "stdio") { + return { + type: "local", + command: [config.command, ...(config.args ?? [])], + ...(config.env ? { environment: config.env } : {}), + enabled: true, + }; + } + + return { + type: "remote", + url: config.url, + ...(config.headers ? { headers: config.headers } : {}), + enabled: true, + }; +} + +function stringifyUnknownError(error: unknown): string { + if (typeof error === "string") { + return error; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +function isAlreadyPresentMcpError(error: unknown): boolean { + const normalized = stringifyUnknownError(error).toLowerCase(); + return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => + normalized.includes(token) + ); +} + async function findAvailablePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -354,6 +407,8 @@ class OpenCodeAgentSession implements AgentSession { private pendingPermissions = new Map(); private abortController: AbortController | null = null; private accumulatedUsage: AgentUsage = {}; + private mcpConfigured = false; + private mcpSetupPromise: Promise | null = null; /** Tracks the role of each message by ID to distinguish user from assistant messages */ private messageRoles = new Map(); @@ -426,6 +481,7 @@ class OpenCodeAgentSession implements AgentSession { _options?: AgentRunOptions ): AsyncGenerator { this.abortController = new AbortController(); + await this.ensureMcpServersConfigured(); const parts = this.buildPromptParts(prompt); const model = this.parseModel(this.config.model); @@ -438,6 +494,7 @@ class OpenCodeAgentSession implements AgentSession { sessionID: this.sessionId, directory: this.config.cwd, parts, + ...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}), ...(model ? { model } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}), }); @@ -648,6 +705,78 @@ class OpenCodeAgentSession implements AgentSession { return { providerID: "opencode", modelID: model }; } + private async ensureMcpServersConfigured(): Promise { + if (this.mcpConfigured) { + return; + } + + const mcpServers = this.config.mcpServers; + if (!mcpServers || Object.keys(mcpServers).length === 0) { + this.mcpConfigured = true; + return; + } + + if (!this.mcpSetupPromise) { + this.mcpSetupPromise = this.configureMcpServers(mcpServers); + } + + try { + await this.mcpSetupPromise; + this.mcpConfigured = true; + } catch (error) { + this.mcpSetupPromise = null; + throw error; + } + } + + private async configureMcpServers( + mcpServers: Record + ): Promise { + for (const [name, serverConfig] of Object.entries(mcpServers)) { + const mappedConfig = toOpenCodeMcpConfig(serverConfig); + await this.registerMcpServer(name, mappedConfig); + } + } + + private async registerMcpServer( + name: string, + config: OpenCodeMcpConfig + ): Promise { + await this.runMcpOperation("add", name, () => + this.client.mcp.add({ + directory: this.config.cwd, + name, + config, + }) + ); + await this.runMcpOperation("connect", name, () => + this.client.mcp.connect({ + directory: this.config.cwd, + name, + }) + ); + } + + private async runMcpOperation( + operation: "add" | "connect", + name: string, + run: () => Promise<{ error?: unknown }> + ): Promise { + const response = await run(); + const error = response.error; + if (!error) { + return; + } + + if (isAlreadyPresentMcpError(error)) { + return; + } + + throw new Error( + `Failed to ${operation} OpenCode MCP server '${name}': ${stringifyUnknownError(error)}` + ); + } + private translateEvent(event: unknown): AgentStreamEvent[] { const events: AgentStreamEvent[] = [];