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] ?? "");