diff --git a/TODO.md b/TODO.md
index 11b44a0..e529f94 100644
--- a/TODO.md
+++ b/TODO.md
@@ -465,6 +465,10 @@ within the limits of the server APIs and mobile lifecycle.
- [ ] Implement supported key, OAuth, and command connection flows.
- [ ] Keep callback state and temporary secrets in SecureStore.
- [ ] Implement MCP list, add, remove, connect, disconnect, and resource catalog.
+- [x] Add location-wide MCP list and runtime connect/disconnect from session
+ Location options, keeping the branch visible. Experimental generated
+ connect/disconnect operations are explicitly accepted for this slice;
+ add/remove, OAuth, resource browsing, and device verification remain.
- [ ] Implement saved-permission list and removal.
- [ ] Add reference browsing and web search where the current API supports it.
- [ ] Add read-only configuration and plugin inspection.
diff --git a/apps/mobile/src/screens/app-shell.test.ts b/apps/mobile/src/screens/app-shell.test.ts
index de13ad9..80a2727 100644
--- a/apps/mobile/src/screens/app-shell.test.ts
+++ b/apps/mobile/src/screens/app-shell.test.ts
@@ -2,7 +2,7 @@ import { expect, jest, test } from "@jest/globals";
import { fireEvent, render, screen, waitFor } from "@testing-library/react-native";
import * as Clipboard from "expo-clipboard";
import { createElement } from "react";
-import { Dimensions, Text } from "react-native";
+import { Dimensions, Pressable, Text } from "react-native";
import { useConnections } from "../connections/connections-context";
import { useConnectionRuntime } from "../state/connection-runtime-context";
@@ -132,6 +132,49 @@ test("session tabs replace healthy connection and server labels but retain conne
expect(navigate).toHaveBeenCalledWith("Settings");
});
+test("keeps branch and trailing location options together on narrow phones and large text", () => {
+ jest
+ .mocked(useConnections)
+ .mockReturnValue({ profiles: [], selectedProfileId: undefined } as never);
+ jest
+ .mocked(useConnectionRuntime)
+ .mockReturnValue({ reconnectAttempt: 0, status: "connected" } as never);
+ jest
+ .mocked(useWorkspaceSelection)
+ .mockReturnValue({ attentionCoverage: { completeness: "complete" }, pendingCount: 0 } as never);
+ const originalWindow = Dimensions.get("window");
+ const originalScreen = Dimensions.get("screen");
+ try {
+ for (const fontScale of [1, 2]) {
+ Dimensions.set({
+ window: { ...originalWindow, width: 320, fontScale },
+ screen: { ...originalScreen, width: 320, fontScale },
+ });
+ const view = render(
+ createElement(ShellFrame, {
+ active: "Workspace",
+ navigate: jest.fn(),
+ branch: { state: "known", name: "feature/a-long-branch-name" },
+ sessionTabs: { active: "session", onSelect: jest.fn() },
+ sessionOptions: createElement(
+ Pressable,
+ { accessibilityRole: "button", accessibilityLabel: "Location options" },
+ createElement(Text, null, "..."),
+ ),
+ }),
+ );
+ expect(
+ screen.getByRole("button", { name: "Current branch, feature/a-long-branch-name" }),
+ ).toBeOnTheScreen();
+ expect(screen.getByRole("button", { name: "Location options" })).toBeOnTheScreen();
+ expect(screen.getByRole("tab", { name: "Changes" })).toBeOnTheScreen();
+ view.unmount();
+ }
+ } finally {
+ Dimensions.set({ window: originalWindow, screen: originalScreen });
+ }
+});
+
test("reveals and copies the full session branch name", async () => {
jest.mocked(useConnections).mockReturnValue({
profiles: [{ id: "connection-1", name: "Test server" }],
diff --git a/apps/mobile/src/screens/app-shell.tsx b/apps/mobile/src/screens/app-shell.tsx
index 4eb4a72..9056e83 100644
--- a/apps/mobile/src/screens/app-shell.tsx
+++ b/apps/mobile/src/screens/app-shell.tsx
@@ -617,6 +617,7 @@ export function ShellFrame({
children,
hideConnectionBar,
navigate,
+ sessionOptions,
sessionTabs,
}: {
active: Section;
@@ -624,6 +625,7 @@ export function ShellFrame({
children?: ReactNode;
hideConnectionBar?: boolean;
navigate: (screen: Section) => void;
+ sessionOptions?: ReactNode;
sessionTabs?: { active: "session" | "changes"; onSelect: (tab: "session" | "changes") => void };
}) {
const { fontScale, width } = useWindowDimensions();
@@ -754,53 +756,65 @@ export function ShellFrame({
)}
- {branch ? (
- branch.state === "known" && branch.name ? (
- showDetail("branch")}
- style={({ pressed }) => [
- styles.branchControl,
- largeText && styles.branchControlLargeText,
- pressed && styles.branchControlPressed,
- ]}
- >
-
-
- {branch.name}
-
-
- ) : (
-
-
-
- {branch.state === "loading"
- ? "Checking branch"
- : branch.state === "none"
- ? "No branch"
- : "Branch unavailable"}
-
-
- )
+ {branch || sessionOptions ? (
+
+ {branch ? (
+ branch.state === "known" && branch.name ? (
+ showDetail("branch")}
+ style={({ pressed }) => [
+ styles.branchControl,
+ largeText && !sessionOptions && styles.branchControlLargeText,
+ pressed && styles.branchControlPressed,
+ ]}
+ >
+
+
+ {branch.name}
+
+
+ ) : (
+
+
+
+ {branch.state === "loading"
+ ? "Checking branch"
+ : branch.state === "none"
+ ? "No branch"
+ : "Branch unavailable"}
+
+
+ )
+ ) : null}
+ {sessionOptions}
+
) : null}
{!sessionTabs ? (
Promise<{ data: McpServer[] }>>();
+const mockConnect = jest.fn<(...args: unknown[]) => Promise>();
+const mockDisconnect = jest.fn<(...args: unknown[]) => Promise>();
+let mockRuntime = { connectionId: "connection-1", restClient: {}, status: "connected" };
+jest.mock("@opencode2-mobile/opencode-adapter", () => ({
+ listOpenCodeMcpServers: (...args: unknown[]) => mockList(...args),
+ connectOpenCodeMcpServer: (...args: unknown[]) => mockConnect(...args),
+ disconnectOpenCodeMcpServer: (...args: unknown[]) => mockDisconnect(...args),
+ classifyOpenCodeError: (error: unknown) =>
+ error instanceof Error && error.message === "unsupported" ? "INCOMPATIBLE" : "UNKNOWN",
+}));
+jest.mock("../state/connection-runtime-context", () => ({
+ useConnectionRuntime: () => mockRuntime,
+}));
+jest.mock("./app-shell", () => {
+ const { Pressable, Text } = jest.requireActual("react-native");
+ return {
+ ActionButton: ({
+ label,
+ onPress,
+ disabled,
+ accessibilityLabel,
+ }: {
+ label: string;
+ onPress: () => void;
+ disabled?: boolean;
+ accessibilityLabel?: string;
+ }) => (
+
+ {label}
+
+ ),
+ };
+});
+jest.mock("expo-clipboard", () => ({ setStringAsync: jest.fn(async () => undefined) }));
+
+beforeEach(() => {
+ mockRuntime = { connectionId: "connection-1", restClient: {}, status: "connected" };
+ mockList.mockReset().mockResolvedValue({ data: [] });
+ mockConnect.mockReset().mockResolvedValue(undefined);
+ mockDisconnect.mockReset().mockResolvedValue(undefined);
+});
+const location = { directory: "/workspace/child" };
+function element(queryClient: QueryClient, ready = true) {
+ return (
+
+
+
+ );
+}
+function setup() {
+ const client = new QueryClient({
+ defaultOptions: {
+ queries: { gcTime: Infinity, retry: false },
+ mutations: { gcTime: Infinity, retry: false },
+ },
+ });
+ const view = render(element(client));
+ fireEvent.press(screen.getByRole("button", { name: "Location options" }));
+ return { client, view };
+}
+
+test("loads on open, states the location-wide scope, and copies the branch", async () => {
+ const client = new QueryClient({
+ defaultOptions: { queries: { gcTime: Infinity }, mutations: { gcTime: Infinity } },
+ });
+ render(element(client));
+ expect(mockList).not.toHaveBeenCalled();
+ fireEvent.press(screen.getByRole("button", { name: "Location options" }));
+ expect(await screen.findByText("No MCP servers configured for this location.")).toBeOnTheScreen();
+ expect(screen.getByText(/MCP changes affect all sessions/)).toBeOnTheScreen();
+ expect(mockList).toHaveBeenCalledWith(
+ {},
+ location,
+ expect.objectContaining({ signal: expect.anything() }),
+ );
+ fireEvent.press(screen.getByRole("button", { name: "Copy branch name" }));
+ await waitFor(() => expect(Clipboard.setStringAsync).toHaveBeenCalledWith("feature/mobile"));
+});
+
+test("keeps the on-track distinct from the thumb using shared native switch colors", async () => {
+ mockList.mockResolvedValue({ data: [{ name: "docs", status: { status: "connected" } }] });
+ setup();
+ const toggle = await screen.findByRole("switch", { name: "MCP server docs for this location" });
+ expect(toggle.props.onTintColor).toBe(switchColors.trackColor.true);
+ expect(toggle.props.tintColor).toBe(switchColors.trackColor.false);
+ expect(toggle.props.thumbTintColor).toBe(switchColors.thumbColor);
+ expect(toggle).toHaveStyle({ backgroundColor: switchColors.ios_backgroundColor });
+ expect(toggle.props.onTintColor).not.toBe(toggle.props.thumbTintColor);
+});
+
+test("connects and disconnects using refreshed server state and exact cache keys", async () => {
+ mockList.mockResolvedValue({ data: [{ name: "docs", status: { status: "disabled" } }] });
+ mockConnect.mockImplementation(async () => {
+ mockList.mockResolvedValue({ data: [{ name: "docs", status: { status: "connected" } }] });
+ });
+ mockDisconnect.mockImplementation(async () => {
+ mockList.mockResolvedValue({ data: [{ name: "docs", status: { status: "disabled" } }] });
+ });
+ const { client } = setup();
+ client.setQueryData(openCodeQueryKeys.mcpServers("connection-2", location), { data: ["other"] });
+ const invalidate = jest.spyOn(client, "invalidateQueries");
+ const toggle = await screen.findByRole("switch", { name: "MCP server docs for this location" });
+ expect(toggle).not.toBeChecked();
+ fireEvent(toggle, "valueChange", true);
+ await waitFor(() => {
+ expect(toggle).toBeChecked();
+ expect(toggle).toBeEnabled();
+ });
+ fireEvent(toggle, "valueChange", false);
+ await waitFor(() => {
+ expect(toggle).not.toBeChecked();
+ expect(toggle).toBeEnabled();
+ });
+ expect(mockConnect).toHaveBeenCalledWith(
+ {},
+ location,
+ "docs",
+ expect.objectContaining({ signal: expect.anything() }),
+ );
+ expect(mockDisconnect).toHaveBeenCalledWith(
+ {},
+ location,
+ "docs",
+ expect.objectContaining({ signal: expect.anything() }),
+ );
+ expect(invalidate).toHaveBeenCalledWith({
+ queryKey: openCodeQueryKeys.mcpServers("connection-1", location),
+ });
+ expect(
+ client.getQueryState(openCodeQueryKeys.mcpServers("connection-2", location))?.isInvalidated,
+ ).toBe(false);
+});
+
+test("uses explicit pending/authentication/failure states without raw server errors", async () => {
+ mockList.mockResolvedValue({
+ data: [
+ { name: "pending", status: { status: "pending" } },
+ { name: "auth", status: { status: "needs_auth", error: "secret server error" } },
+ { name: "failed", status: { status: "failed", error: "secret server error" } },
+ ],
+ });
+ setup();
+ expect(await screen.findByText("Needs authentication")).toBeOnTheScreen();
+ expect(
+ screen.getByRole("switch", { name: "MCP server pending for this location" }),
+ ).toBeDisabled();
+ expect(
+ screen.getByRole("switch", { name: "MCP server pending for this location" }),
+ ).toBeChecked();
+ expect(screen.getByRole("switch", { name: "MCP server auth for this location" })).toBeChecked();
+ expect(screen.getByRole("switch", { name: "MCP server auth for this location" })).toBeEnabled();
+ expect(screen.getByRole("switch", { name: "MCP server failed for this location" })).toBeChecked();
+ expect(
+ screen.getByRole("button", { name: "Retry connection to MCP server failed" }),
+ ).toBeEnabled();
+ expect(screen.queryByText("secret server error")).toBeNull();
+});
+
+test("disables cached controls after losing or switching connection", async () => {
+ mockList.mockResolvedValue({ data: [{ name: "docs", status: { status: "connected" } }] });
+ const { client, view } = setup();
+ await screen.findByText("Connected");
+ mockRuntime.status = "offline";
+ view.rerender(element(client));
+ expect(screen.getByRole("switch", { name: "MCP server docs for this location" })).toBeDisabled();
+ mockRuntime.connectionId = "connection-2";
+ view.rerender(element(client));
+ expect(screen.queryByText("docs")).toBeNull();
+ expect(mockDisconnect).not.toHaveBeenCalled();
+});
+
+test("reconciles uncertain mutations and never retries automatically", async () => {
+ mockList.mockResolvedValue({ data: [{ name: "docs", status: { status: "disabled" } }] });
+ mockConnect.mockRejectedValue(new Error("private failure"));
+ setup();
+ fireEvent(
+ await screen.findByRole("switch", { name: "MCP server docs for this location" }),
+ "valueChange",
+ true,
+ );
+ expect(await screen.findByText(/MCP change could not be confirmed/)).toBeOnTheScreen();
+ await waitFor(() => expect(mockList.mock.calls.length).toBeGreaterThan(1));
+ expect(mockConnect).toHaveBeenCalledTimes(1);
+ expect(screen.queryByText("private failure")).toBeNull();
+ await waitFor(() => {
+ const toggle = screen.getByRole("switch", { name: "MCP server docs for this location" });
+ expect(toggle).not.toBeChecked();
+ expect(toggle).toBeEnabled();
+ });
+});
+
+test("shows unsupported and retryable list failures", async () => {
+ mockList.mockRejectedValue(new Error("unsupported"));
+ const { view } = setup();
+ expect(await screen.findByText("This server does not expose MCP management.")).toBeOnTheScreen();
+ view.unmount();
+ mockList.mockRejectedValue(new Error("network"));
+ setup();
+ expect(await screen.findByRole("button", { name: "Try again" })).toBeOnTheScreen();
+ mockList.mockResolvedValue({ data: [] });
+ fireEvent.press(screen.getByRole("button", { name: "Try again" }));
+ expect(await screen.findByText("No MCP servers configured for this location.")).toBeOnTheScreen();
+});
+
+test("blocks duplicate actions across sheet reopening and aborts when leaving", async () => {
+ mockList.mockResolvedValue({ data: [{ name: "docs", status: { status: "disabled" } }] });
+ let complete: (() => void) | undefined;
+ mockConnect.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ complete = resolve;
+ }),
+ );
+ const { view } = setup();
+ const connect = await screen.findByRole("switch", {
+ name: "MCP server docs for this location",
+ });
+ fireEvent(connect, "valueChange", true);
+ fireEvent(connect, "valueChange", true);
+ await waitFor(() => expect(mockConnect).toHaveBeenCalledTimes(1));
+ fireEvent.press(screen.getByRole("button", { name: "Close Location options" }));
+ fireEvent.press(screen.getByRole("button", { name: "Location options" }));
+ expect(screen.getByRole("switch", { name: "MCP server docs for this location" })).toBeDisabled();
+ expect(screen.getByRole("switch", { name: "MCP server docs for this location" })).toBeChecked();
+ const options = mockConnect.mock.calls[0]?.[3] as { signal: AbortSignal };
+ view.unmount();
+ expect(options.signal.aborted).toBe(true);
+ await act(async () => {
+ complete?.();
+ });
+});
+
+test("can switch off a server awaiting authentication", async () => {
+ mockList.mockResolvedValue({
+ data: [{ name: "auth", status: { status: "needs_auth", error: "private" } }],
+ });
+ mockDisconnect.mockImplementation(async () => {
+ mockList.mockResolvedValue({ data: [{ name: "auth", status: { status: "disabled" } }] });
+ });
+ setup();
+ const toggle = await screen.findByRole("switch", { name: "MCP server auth for this location" });
+ fireEvent(toggle, "valueChange", false);
+ await waitFor(() => expect(toggle).not.toBeChecked());
+ expect(mockDisconnect).toHaveBeenCalledWith(
+ {},
+ location,
+ "auth",
+ expect.objectContaining({ signal: expect.anything() }),
+ );
+ expect(mockConnect).not.toHaveBeenCalled();
+});
+
+test("does not confuse a failed connection with a switched-off server", async () => {
+ mockList.mockResolvedValue({ data: [{ name: "docs", status: { status: "disabled" } }] });
+ mockConnect.mockImplementation(async () => {
+ mockList.mockResolvedValue({
+ data: [{ name: "docs", status: { status: "failed", error: "private" } }],
+ });
+ });
+ setup();
+ const toggle = await screen.findByRole("switch", { name: "MCP server docs for this location" });
+ fireEvent(toggle, "valueChange", true);
+ expect(await screen.findByText("Connection failed")).toBeOnTheScreen();
+ expect(toggle).toBeChecked();
+ expect(
+ screen.getByRole("button", { name: "Retry connection to MCP server docs" }),
+ ).toBeOnTheScreen();
+});
diff --git a/apps/mobile/src/screens/session-location-options.tsx b/apps/mobile/src/screens/session-location-options.tsx
new file mode 100644
index 0000000..4ef1382
--- /dev/null
+++ b/apps/mobile/src/screens/session-location-options.tsx
@@ -0,0 +1,331 @@
+import Feather from "@expo/vector-icons/Feather";
+import {
+ classifyOpenCodeError,
+ connectOpenCodeMcpServer,
+ disconnectOpenCodeMcpServer,
+ type LocationRef,
+ listOpenCodeMcpServers,
+ type McpServer,
+} from "@opencode2-mobile/opencode-adapter";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import * as Clipboard from "expo-clipboard";
+import { useEffect, useRef, useState } from "react";
+import {
+ AccessibilityInfo,
+ ActivityIndicator,
+ Keyboard,
+ Pressable,
+ StyleSheet,
+ Switch,
+ Text,
+ View,
+} from "react-native";
+
+import { ModalSheet } from "../components/modal-sheet";
+import { useConnectionRuntime } from "../state/connection-runtime-context";
+import { openCodeQueryKeys } from "../state/open-code-query-keys";
+import { palette, space, switchColors, typography } from "../theme";
+import { ActionButton } from "./app-shell";
+
+export function SessionLocationOptions({
+ connectionId,
+ location,
+ ready,
+ branch,
+ branchStale,
+}: {
+ connectionId: string;
+ location: LocationRef;
+ ready: boolean;
+ branch?: string | undefined;
+ branchStale?: boolean;
+}) {
+ const runtime = useConnectionRuntime();
+ const client = runtime.restClient;
+ const queryClient = useQueryClient();
+ const [visible, setVisible] = useState(false);
+ const [copyState, setCopyState] = useState<"idle" | "copied" | "error">("idle");
+ const triggerRef = useRef(null);
+ const requestRef = useRef(null);
+ const available = Boolean(
+ ready && client && runtime.connectionId === connectionId && runtime.status === "connected",
+ );
+ const queryKey = openCodeQueryKeys.mcpServers(connectionId, location);
+ const query = useQuery({
+ enabled: visible && available,
+ queryKey,
+ queryFn: ({ signal }) => {
+ if (!client) throw new Error("CONNECTION_NOT_READY");
+ return listOpenCodeMcpServers(client, location, { signal });
+ },
+ retry: false,
+ refetchOnMount: "always",
+ refetchInterval: visible && available ? 10_000 : false,
+ });
+ useEffect(() => {
+ if (!client || !available) requestRef.current?.abort();
+ return () => requestRef.current?.abort();
+ }, [client, available]);
+ const mutation = useMutation({
+ retry: false,
+ mutationFn: async ({ server, connect }: { server: string; connect: boolean }) => {
+ const controller = requestRef.current;
+ if (!client || !available || !controller || controller.signal.aborted) {
+ throw new Error("CONNECTION_NOT_READY");
+ }
+ const operation = connect ? connectOpenCodeMcpServer : disconnectOpenCodeMcpServer;
+ await operation(client, location, server, { signal: controller.signal });
+ },
+ onSettled: async () => {
+ // A lost response may still have changed the server. Never retry the action
+ // automatically; reconcile the original location even on failure.
+ try {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey }),
+ queryClient.invalidateQueries({
+ queryKey: openCodeQueryKeys.commands(connectionId, location),
+ }),
+ ]);
+ } finally {
+ requestRef.current = null;
+ }
+ },
+ });
+ const matchingConnection = runtime.connectionId === connectionId;
+ const servers = matchingConnection ? query.data?.data : undefined;
+ const unsupported =
+ (query.isError && classifyOpenCodeError(query.error) === "INCOMPATIBLE") ||
+ (mutation.isError && classifyOpenCodeError(mutation.error) === "INCOMPATIBLE");
+ const canChange =
+ available &&
+ query.isSuccess &&
+ !query.isError &&
+ !query.isFetching &&
+ !mutation.isPending &&
+ !unsupported;
+
+ async function copyBranch() {
+ if (!branch) return;
+ try {
+ await Clipboard.setStringAsync(branch);
+ setCopyState("copied");
+ AccessibilityInfo.announceForAccessibility("Branch name copied");
+ } catch {
+ setCopyState("error");
+ }
+ }
+
+ function changeServer(server: string, connect: boolean) {
+ if (!canChange || requestRef.current) return;
+ requestRef.current = new AbortController();
+ mutation.mutate({ server, connect });
+ }
+
+ return (
+ <>
+ {
+ Keyboard.dismiss();
+ if (!requestRef.current) mutation.reset();
+ setCopyState("idle");
+ setVisible(true);
+ if (available) void query.refetch();
+ }}
+ style={({ pressed }) => [styles.trigger, pressed && { opacity: 0.55 }]}
+ >
+
+
+ setVisible(false)}
+ returnFocusRef={triggerRef}
+ >
+
+ MCP changes affect all sessions at this location. They do not change saved configuration
+ and may reset when the server restarts.
+
+
+ {location.directory}
+
+
+ Current branch
+
+
+ {branch ?? "Branch unavailable"}
+
+ {branchStale ? (
+ This branch may be outdated while the server reconnects.
+ ) : null}
+ {branch ? (
+ void copyBranch()}
+ />
+ ) : null}
+
+ MCP servers for this location
+
+ {!available ? (
+
+ Reconnect to this session's server and wait for its location to load before changing MCP
+ servers.
+
+ ) : null}
+ {unsupported ? (
+
+ This server does not expose MCP management.
+
+ ) : query.isError ? (
+
+
+ MCP servers could not be refreshed. Their status may be outdated.
+
+ void query.refetch()}
+ />
+
+ ) : available && query.isPending ? (
+
+ ) : null}
+ {mutation.isError && !unsupported ? (
+
+ The MCP change could not be confirmed. Check the refreshed status before trying again.
+
+ ) : null}
+ {servers?.length === 0 && query.isSuccess ? (
+ No MCP servers configured for this location.
+ ) : null}
+ {servers?.map((server) => {
+ const pending = mutation.isPending && mutation.variables?.server === server.name;
+ // Enabled is distinct from connected. An enabled server can still be
+ // connecting, require authentication, or have a connection failure.
+ const enabled = server.status.status !== "disabled";
+ const checked = pending ? Boolean(mutation.variables?.connect) : enabled;
+ const needsAuth = server.status.status === "needs_auth";
+ const status = pending
+ ? mutation.variables?.connect
+ ? "Connecting"
+ : "Disconnecting"
+ : mcpStatusLabel(server);
+ return (
+
+
+
+
+ {server.name}
+
+
+ {status}
+
+
+
+ {
+ if (next !== enabled) changeServer(server.name, next);
+ }}
+ />
+
+
+ {needsAuth ? (
+
+ Finish authentication in OpenCode on the server, then refresh this list.
+
+ ) : server.status.status === "failed" ? (
+ changeServer(server.name, true)}
+ />
+ ) : null}
+
+ );
+ })}
+ {query.isSuccess && !unsupported ? (
+ void query.refetch()}
+ />
+ ) : null}
+
+ >
+ );
+}
+
+function mcpStatusLabel(server: McpServer) {
+ switch (server.status.status) {
+ case "connected":
+ return "Connected";
+ case "pending":
+ return "Connecting";
+ case "disabled":
+ return "Disconnected";
+ case "failed":
+ return "Connection failed";
+ case "needs_auth":
+ return "Needs authentication";
+ }
+}
+
+const styles = StyleSheet.create({
+ trigger: {
+ minWidth: 44,
+ minHeight: 44,
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+ },
+ heading: { ...typography.control, color: palette.ink },
+ name: { ...typography.body, color: palette.ink },
+ note: { ...typography.caption, color: palette.dim },
+ path: { ...typography.code, color: palette.dim },
+ serverHeader: { flexDirection: "row", alignItems: "center", gap: space.md },
+ serverInfo: { flex: 1, minWidth: 0, gap: space.xs },
+ switchTarget: {
+ minWidth: 44,
+ minHeight: 44,
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+ },
+ server: {
+ borderTopColor: palette.border,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ paddingTop: space.md,
+ gap: space.xs,
+ },
+});
diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx
index 01ae97b..4ce0d64 100644
--- a/apps/mobile/src/screens/workspace-screen.tsx
+++ b/apps/mobile/src/screens/workspace-screen.tsx
@@ -69,6 +69,7 @@ import { FormRequestList } from "./form-request-list";
import { SessionComposer } from "./session-composer";
import { loadOpenCodeSessionTreeIds } from "./session-deletion";
import { SessionExecutionPanel } from "./session-execution-panel";
+import { SessionLocationOptions } from "./session-location-options";
import { SessionShellScope } from "./session-shell-output";
import {
buildTranscriptPresentation,
@@ -969,6 +970,16 @@ export function SessionScreen({ navigation, route }: SessionProps) {
+ }
sessionTabs={{
active: selectedTab,
onSelect: (tab) => {
diff --git a/apps/mobile/src/state/open-code-query-keys.test.ts b/apps/mobile/src/state/open-code-query-keys.test.ts
index 86aab58..b989fc7 100644
--- a/apps/mobile/src/state/open-code-query-keys.test.ts
+++ b/apps/mobile/src/state/open-code-query-keys.test.ts
@@ -34,6 +34,17 @@ test("does not collide across connections or workspace locations", () => {
expect(first).not.toEqual(workspace);
});
+test("MCP servers are exact-location scoped, not session scoped", () => {
+ const key = openCodeQueryKeys.mcpServers("connection-1", { directory: "/workspace" });
+ expect(key).toEqual(["opencode", "connection-1", "location", "/workspace", null, "mcp-servers"]);
+ expect(key).not.toEqual(
+ openCodeQueryKeys.mcpServers("connection-2", { directory: "/workspace" }),
+ );
+ expect(key).not.toEqual(
+ openCodeQueryKeys.mcpServers("connection-1", { directory: "/workspace/child" }),
+ );
+});
+
test("scopes composer catalogs and file searches to the exact location", () => {
const location = { directory: "/workspace", workspaceID: "wrk_test" };
diff --git a/apps/mobile/src/state/open-code-query-keys.ts b/apps/mobile/src/state/open-code-query-keys.ts
index 33126eb..b692ae3 100644
--- a/apps/mobile/src/state/open-code-query-keys.ts
+++ b/apps/mobile/src/state/open-code-query-keys.ts
@@ -99,6 +99,9 @@ export const openCodeQueryKeys = {
models(connectionId: string, location: LocationRef) {
return [...locationKey(connectionId, location), "models"] as const;
},
+ mcpServers(connectionId: string, location: LocationRef) {
+ return [...locationKey(connectionId, location), "mcp-servers"] as const;
+ },
permissions(connectionId: string, location: LocationRef) {
return [...locationKey(connectionId, location), "permissions"] as const;
},
diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md
index 140dcaa..6036cce 100644
--- a/docs/COMPATIBILITY.md
+++ b/docs/COMPATIBILITY.md
@@ -12,6 +12,67 @@ the fail-closed database backup-exclusion startup guard. Statements marked
pending in older dated entries describe the status at the time of that probe;
later entries supersede them.
+## 2026-10-01: location-wide MCP runtime controls, pending device verification
+
+### Switch contrast follow-up
+
+A user-supplied iPhone screenshot showed that on-state MCP switches rendered as
+solid white pills. Their white track matched the native white thumb. The MCP
+switches now use the shared native switch colors, with a medium-gray on-track,
+dark off-track, and explicitly colored light thumb. A regression test against
+the rendered iOS switch props failed with the white on-track and passed after
+the shared styling was applied.
+
+The fix passed the full checks, 440 mobile tests, both Hermes exports, and all 18
+Expo Doctor checks. The contrast fix was published locally through EAS Update to
+preview for both platforms, runtime 0.1.5, after both native fingerprints matched
+the installed baseline under the preview environment. Corrected physical-device
+appearance remains pending. The screenshot confirms the earlier switch UI loaded on iPhone,
+but does not establish MCP mutation behavior or Android appearance.
+
+### Initial implementation and publication
+
+The session context bar retains the current branch and adds Location options.
+The sheet lists MCP status and offers runtime Connect/Disconnect actions for the
+exact session location. These actions are not session-only switches and do not
+write persistent configuration. The generated experimental operations are an
+explicitly accepted exception for this feature, checked against client 2.0.21
+and the published V2 OpenAPI contract.
+
+Deterministic tests cover location isolation, encoded server names, malformed
+snapshots, authentication failures, cancellation, refreshed status, disconnected
+controls, duplicate taps across sheet reopening, and uncertain mutation outcomes.
+No raw server error text is displayed. The full lint, typecheck, test, and build
+sequence passed, including 91 adapter tests, 437 mobile tests, and iOS/Android
+Hermes exports. Expo Doctor passed all 18 checks.
+
+Real-server MCP behavior and physical iOS/Android verification remain pending.
+Verify shared-location effects, restart behavior, keyboard dismissal, narrow
+screens, large text, and VoiceOver/TalkBack focus before claiming device support.
+
+The preview deployment's iOS and Android native fingerprints and runtime matched
+the installed 0.1.5 baseline under the EAS preview environment. A local EAS Update
+publication succeeded for both platforms on preview. Device installation and MCP
+verification remain pending; publication alone does not establish either.
+
+A follow-up replaces the Connect/Disconnect buttons with native on/off switches.
+Enabled state is separate from connection status: pending, authentication-required,
+and failed servers remain on, while disabled servers are off. Authentication-required
+servers can be switched off. Pending user changes update the switch immediately,
+disable repeat actions, then reconcile or roll back to the server snapshot. Failed
+connections retain a separate retry action. This switch revision has not yet been
+verified on physical devices.
+
+The switch revision passed the full lint, typecheck, test, and build sequence,
+including 439 mobile tests and both Hermes exports. Expo Doctor passed all 18
+checks. Regression coverage includes switching off authentication-required servers
+and keeping failed connections on with a separate failure status.
+
+The switch revision was subsequently published locally through EAS Update to
+preview for iOS and Android, runtime 0.1.5, after both native fingerprints matched
+the installed baseline under the EAS preview environment. Device installation
+and switch behavior remain unverified.
+
## 2026-09-28: transcript range selection, pending native verification
The user reported that long-pressing transcript text on iOS offered Copy without
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 7d5b4a5..e57eeec 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -653,6 +653,18 @@ config-read operations.
Experimental debug, migration, session-log, and undocumented operations are not
release dependencies unless promoted into the supported generated contract.
+The session context bar keeps Session, Changes, and the current branch visible,
+with a trailing Location options action. Its sheet lists MCP servers with native
+on/off switches for the session's exact location. On means the server is enabled,
+not necessarily connected; connecting, authentication, and failure remain explicit
+status labels. A switch sends a runtime connect/disconnect request and reconciles
+server state, without saving a preference. These
+controls affect every session at that location, not just the open session, and
+do not edit persistent configuration. Connecting overrides disabled configuration
+until server restart. The generated experimental MCP connect/disconnect operations
+are an explicitly accepted exception for this feature. Adding/removing servers,
+OAuth onboarding, and resource browsing remain separate integration work.
+
## 19. Sources
-
diff --git a/packages/opencode-adapter/src/index.test.ts b/packages/opencode-adapter/src/index.test.ts
index f782e44..ef63b69 100644
--- a/packages/opencode-adapter/src/index.test.ts
+++ b/packages/opencode-adapter/src/index.test.ts
@@ -7,10 +7,12 @@ import {
cancelOpenCodeForm,
cancelOpenCodeSessionInboxItem,
classifyOpenCodeError,
+ connectOpenCodeMcpServer,
createBoundedOpenCodeFetch,
createOpenCodeClient,
createOpenCodeSession,
createRedirectSafeOpenCodeFetch,
+ disconnectOpenCodeMcpServer,
findOpenCodeFiles,
getCurrentOpenCodeProject,
getDefaultOpenCodeAgent,
@@ -30,6 +32,7 @@ import {
listOpenCodeAgents,
listOpenCodeCommands,
listOpenCodeFormRequests,
+ listOpenCodeMcpServers,
listOpenCodeMessages,
listOpenCodeModels,
listOpenCodePermissionRequests,
@@ -58,6 +61,70 @@ import {
waitForOpenCodeSession,
} from "./index";
+describe("location MCP controls", () => {
+ it("lists and connects/disconnects at the exact location without changing other locations", async () => {
+ const fixture = createFakeOpenCodeApi({
+ mcpServers: [{ name: "docs / tools", status: { status: "disabled" } }],
+ });
+ const client = createOpenCodeClient({ baseUrl: "http://fake.invalid", fetch: fixture.fetch });
+ const location = { directory: "/workspace/child" };
+ const controller = new AbortController();
+ await connectOpenCodeMcpServer(client, location, "docs / tools", { signal: controller.signal });
+ expect((await listOpenCodeMcpServers(client, location)).data[0]?.status.status).toBe(
+ "connected",
+ );
+ expect(
+ (await listOpenCodeMcpServers(client, { directory: "/workspace" })).data[0]?.status.status,
+ ).toBe("disabled");
+ await disconnectOpenCodeMcpServer(client, location, "docs / tools");
+ expect((await listOpenCodeMcpServers(client, location)).data[0]?.status.status).toBe(
+ "disabled",
+ );
+ expect(fixture.requests[0]).toEqual({
+ method: "POST",
+ path: "/api/experimental/mcp/docs%20%2F%20tools/connect",
+ query: { "location[directory]": ["/workspace/child"] },
+ });
+ expect(fixture.requests[3]).toMatchObject({
+ method: "POST",
+ path: "/api/experimental/mcp/docs%20%2F%20tools/disconnect",
+ query: { "location[directory]": ["/workspace/child"] },
+ });
+ });
+
+ it.each([
+ [{ name: "docs", status: { status: "unknown" } }],
+ [{ name: "docs", status: { status: "failed" } }],
+ [{ name: "", status: { status: "connected" } }],
+ [
+ { name: "docs", status: { status: "connected" } },
+ { name: "docs", status: { status: "disabled" } },
+ ],
+ ])("rejects malformed MCP snapshots %j", async (...servers) => {
+ const fixture = createFakeOpenCodeApi({ mcpServers: servers });
+ const client = createOpenCodeClient({ baseUrl: "http://fake.invalid", fetch: fixture.fetch });
+ await expect(listOpenCodeMcpServers(client, { directory: "/workspace" })).rejects.toThrow();
+ });
+
+ it("preserves authentication errors and forwards cancellation to runtime actions", async () => {
+ const fetch = vi.fn(
+ async () =>
+ new Response(JSON.stringify({ _tag: "UnauthorizedError", message: "Unauthorized" }), {
+ status: 401,
+ headers: { "content-type": "application/json" },
+ }),
+ );
+ const client = createOpenCodeClient({ baseUrl: "http://fake.invalid", fetch });
+ const signal = new AbortController().signal;
+ const error = await connectOpenCodeMcpServer(client, { directory: "/workspace" }, "docs", {
+ signal,
+ }).catch((error: unknown) => error);
+ expect(classifyOpenCodeError(error)).toBe("UNAUTHORIZED");
+ expect(fetch.mock.calls[0]?.[1]?.signal).toBe(signal);
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+});
+
describe("background shell output", () => {
const info = {
id: "sh_test",
diff --git a/packages/opencode-adapter/src/index.ts b/packages/opencode-adapter/src/index.ts
index 59a211d..d82f718 100644
--- a/packages/opencode-adapter/src/index.ts
+++ b/packages/opencode-adapter/src/index.ts
@@ -225,6 +225,58 @@ export async function getOpenCodeVcs(
return response;
}
+export async function listOpenCodeMcpServers(
+ client: OpenCodeClient,
+ location: LocationRef,
+ options?: OpenCodeRequestOptions,
+) {
+ const response = await client.mcp.list({ location: locationInput(location) }, options);
+ validateResolvedLocation(response.location);
+ const names = new Set();
+ if (
+ !Array.isArray(response.data) ||
+ !response.data.every((server) => {
+ if (
+ !isRecord(server) ||
+ typeof server.name !== "string" ||
+ !server.name.trim() ||
+ names.has(server.name) ||
+ !isRecord(server.status) ||
+ !["connected", "pending", "disabled", "failed", "needs_auth"].includes(
+ server.status.status,
+ ) ||
+ ((server.status.status === "failed" || server.status.status === "needs_auth") &&
+ typeof server.status.error !== "string")
+ )
+ return false;
+ names.add(server.name);
+ return true;
+ })
+ )
+ throw new Error("MALFORMED_MCP_LIST");
+ return response;
+}
+
+// Explicitly accepted experimental runtime operations. These affect the exact
+// location, not a session, and do not write persistent configuration.
+export function connectOpenCodeMcpServer(
+ client: OpenCodeClient,
+ location: LocationRef,
+ server: string,
+ options?: OpenCodeRequestOptions,
+) {
+ return client.mcp.connect({ server, location: locationInput(location) }, options);
+}
+
+export function disconnectOpenCodeMcpServer(
+ client: OpenCodeClient,
+ location: LocationRef,
+ server: string,
+ options?: OpenCodeRequestOptions,
+) {
+ return client.mcp.disconnect({ server, location: locationInput(location) }, options);
+}
+
export const maxShellOutputBytes = 64 * 1024;
export async function getOpenCodeShell(
@@ -1979,6 +2031,7 @@ export type {
FormState,
LocationGetOutput,
LocationRef,
+ McpServer,
MessageListInput,
ModelDefaultOutput,
ModelInfo,
diff --git a/packages/test-fixtures/src/index.ts b/packages/test-fixtures/src/index.ts
index a5e6e51..094a062 100644
--- a/packages/test-fixtures/src/index.ts
+++ b/packages/test-fixtures/src/index.ts
@@ -13,6 +13,7 @@ export type FakeOpenCodeApiOptions = {
workspaceID?: string;
};
models?: unknown[];
+ mcpServers?: unknown[];
messagePageSize?: number;
messages?: Record;
pageSize?: number;
@@ -61,6 +62,7 @@ export function createFakeOpenCodeApi(options: FakeOpenCodeApiOptions = {}) {
let sessions = [...(options.sessions ?? [])];
let pendingForms = [...(options.forms ?? [])];
const formStates = new Map();
+ const mcpByLocation = new Map();
const fetch: typeof globalThis.fetch = async (input, init) => {
const url = new URL(
@@ -117,6 +119,28 @@ export function createFakeOpenCodeApi(options: FakeOpenCodeApiOptions = {}) {
if (url.pathname === "/api/vcs/diff") {
return json({ data: options.vcsDiff ?? [], location: resolvedLocation(options, url) });
}
+ const mcpMatch = url.pathname.match(
+ /^\/api\/experimental\/mcp\/([^/]+)\/(connect|disconnect)$/,
+ );
+ if (url.pathname === "/api/mcp" || (mcpMatch && method === "POST")) {
+ const location = resolvedLocation(options, url);
+ const key = JSON.stringify(location);
+ const servers = mcpByLocation.get(key) ?? structuredClone(options.mcpServers ?? []);
+ mcpByLocation.set(key, servers);
+ if (!mcpMatch) return json({ location, data: servers });
+ const name = decodeURIComponent(mcpMatch[1] ?? "");
+ const server = servers.find(
+ (entry): entry is { name: string; status: unknown } =>
+ typeof entry === "object" && entry !== null && "name" in entry && entry.name === name,
+ );
+ if (!server)
+ return json(
+ { _tag: "McpServerNotFoundError", server: name, message: "MCP server not found" },
+ 404,
+ );
+ server.status = { status: mcpMatch[2] === "connect" ? "connected" : "disabled" };
+ return new Response(null, { status: 204 });
+ }
const shellMatch = url.pathname.match(/^\/api\/shell\/([^/]+)(\/output)?$/);
if (shellMatch && method === "GET") {
const id = decodeURIComponent(shellMatch[1] ?? "");