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[] = [];