diff --git a/README.md b/README.md index 8aa78f81b9ad..bca0300716a5 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,15 @@ yay -S t3code-nightly-bin The AUR packaging is maintained in this repository under [`packaging/aur`](./packaging/aur). +## Share MCP servers with every agent + +Add an MCP server once in **Settings → Integrations → Shared MCP servers**, and every new agent session can use its tools, whichever provider runs it. T3 Code connects to the server for the agents, so logins stay in T3 Code: + +- **OAuth servers** such as Linear (`https://mcp.linear.app/mcp`), Notion (`https://mcp.notion.com/mcp`), or Sentry (`https://mcp.sentry.dev/mcp`): add the URL, then choose **Sign in** and approve in your browser. T3 Code keeps the login and refreshes it. +- **Token servers** such as GitHub (`https://api.githubcopilot.com/mcp/`): add a header like `Authorization: Bearer `. It's stored as a secret. + +Agents never see the tokens. See [Shared MCP servers](./docs/user/shared-mcp-servers.md). + ## Some notes We are very very early in this project. Expect bugs. @@ -101,6 +110,7 @@ Full docs live in [docs/](./docs). There's no docs site yet. - [Appearance preferences](./docs/user/appearance.md) - [Remote access from a phone or another machine](./docs/user/remote-access.md) - [Connect Claude Code, Codex, ChatGPT and other agents over MCP](./docs/user/outside-agents.md) +- [Share MCP servers, including ones that need a login, with every agent](./docs/user/shared-mcp-servers.md) - [Keeping app and server in sync](./docs/user/updating.md) - [Source control integrations](./docs/user/source-control.md) - Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 4e3c53268f24..fbd11ce5bc48 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -84,6 +84,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc import { SettingsEnvironmentDetailRouteScreen } from "./features/settings/SettingsEnvironmentDetailRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsFollowUpRouteScreen } from "./features/settings/SettingsFollowUpRouteScreen"; +import { SettingsSharedMcpServersRouteScreen } from "./features/settings/SettingsSharedMcpServersRouteScreen"; import { SettingsEnvironmentAgentBehaviorRouteScreen, SettingsEnvironmentMaintenanceRouteScreen, @@ -244,6 +245,11 @@ const SettingsContentStack = createV5SheetStackNavigator({ linking: "provider-accounts", options: { title: "Provider accounts" }, }), + SettingsSharedMcpServers: createNativeStackScreen({ + screen: SettingsSharedMcpServersRouteScreen, + linking: "shared-mcp-servers", + options: { title: "Shared MCP servers" }, + }), SettingsEnvironmentMaintenance: createNativeStackScreen({ screen: SettingsEnvironmentMaintenanceRouteScreen, linking: "maintenance", diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index adb6551cf5fa..4209ff172b69 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -205,6 +205,12 @@ function SettingsIndexSections() { target="SettingsEnvironmentAgentBehavior" disabled={noServerTargets} /> + + + }> + + {selectedTargets.length === 0 ? ( + + Use the filter above to select a connected environment. + + ) : ( + selectedTargets.map((environment) => ( + + )) + )} + + + + ); +} + +/** One environment's saved servers. Every write sends the whole list back. */ +function SharedMcpServersSection({ environment }: { readonly environment: SettingsTarget }) { + const environmentId = environment.environmentId; + const servers = environment.serverConfig.settings.sharedMcpServers; + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "shared MCP servers update", + reportFailure: true, + }); + const [form, setForm] = useState<{ + readonly editing: SharedMcpServer | undefined; + readonly draft: SharedMcpServerDraft; + } | null>(null); + const [pending, setPending] = useState(false); + const pendingRef = useRef(false); + const [error, setError] = useState(null); + + async function write(next: ReadonlyArray) { + if (pendingRef.current) return false; + pendingRef.current = true; + setPending(true); + setError(null); + const result = await updateSettings({ + environmentId, + input: { patch: { sharedMcpServers: next } }, + }); + pendingRef.current = false; + setPending(false); + if (result._tag === "Success") return true; + if (!isAtomCommandInterrupted(result)) setError("Could not save shared MCP servers."); + return false; + } + + const save = () => { + if (form === null) return; + const parsed = parseSharedMcpServerDraft(servers, form.draft, form.editing); + if ("error" in parsed) { + setError(parsed.error); + return; + } + void write(upsertSharedMcpServer(servers, parsed.server, form.editing)).then((saved) => { + if (saved) setForm(null); + }); + }; + + const confirmRemove = (server: SharedMcpServer) => + Alert.alert( + `Remove ${server.name}?`, + `New agent sessions on ${environment.label} stop getting it.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: () => void write(servers.filter((entry) => entry.name !== server.name)), + }, + ], + ); + + return ( + + {servers.length === 0 && form === null ? ( + + Add a server once and every new agent session on this environment gets it: Claude, Codex, + Cursor, OpenCode, Pi, and ACP agents. Point it at a local MCP gateway to share many + servers at once. + + ) : null} + {servers.map((server, index) => ( + 0} + disabled={pending || form !== null} + onEnabledChange={(enabled) => + void write( + servers.map((entry) => (entry.name === server.name ? { ...entry, enabled } : entry)), + ) + } + onEdit={() => { + setError(null); + setForm({ editing: server, draft: sharedMcpServerDraft(server) }); + }} + onRemove={() => confirmRemove(server)} + /> + ))} + {form !== null ? ( + + + {form.editing ? `Edit ${form.editing.name}` : "New MCP server"} + + setForm({ ...form, draft: { ...form.draft, name } })} + /> + setForm({ ...form, draft: { ...form.draft, url } })} + /> + setForm({ ...form, draft: { ...form.draft, headers } })} + /> + + One header per line, as Name: value. Leave •••••• to keep a saved value. + + + ) : null} + {error ? ( + + {error} + + ) : null} + {form !== null ? ( + <> + + { + setError(null); + setForm(null); + }} + /> + + ) : ( + { + setError(null); + setForm({ editing: undefined, draft: EMPTY_DRAFT }); + }} + /> + )} + + ); +} + +function SharedMcpServerRow(props: { + readonly environmentId: SettingsTarget["environmentId"]; + readonly server: SharedMcpServer; + readonly separated: boolean; + readonly disabled: boolean; + readonly onEnabledChange: (enabled: boolean) => void; + readonly onEdit: () => void; + readonly onRemove: () => void; +}) { + const { server } = props; + const testServer = useAtomCommand(serverEnvironment.testSharedMcpServer, { + label: "shared MCP server test", + reportFailure: false, + }); + const signInServer = useAtomCommand(serverEnvironment.signInSharedMcpServer, { + label: "shared MCP server sign-in", + reportFailure: false, + }); + // The provider sends the browser back to this environment's server, which + // keeps the tokens. + const connection = usePreparedConnection(props.environmentId); + const redirectBaseUrl = Option.isSome(connection) ? connection.value.httpBaseUrl : null; + const [testing, setTesting] = useState(false); + const [result, setResult] = useState<{ readonly ok: boolean; readonly message: string } | null>( + null, + ); + const headerCount = Object.keys(server.headers).length; + + const test = () => { + setTesting(true); + setResult(null); + void testServer({ environmentId: props.environmentId, input: { name: server.name } }) + .then((outcome) => { + if (outcome._tag === "Success") { + const { serverName, toolCount } = outcome.value; + const tools = `${toolCount} ${toolCount === 1 ? "tool" : "tools"}`; + setResult({ + ok: true, + message: serverName ? `Connected: ${serverName} · ${tools}` : `Connected · ${tools}`, + }); + } else if (!isAtomCommandInterrupted(outcome)) { + const failure = squashAtomCommandFailure(outcome); + setResult({ + ok: false, + message: failure instanceof Error ? failure.message : "Could not reach the server.", + }); + } + }) + .finally(() => setTesting(false)); + }; + + const signIn = () => { + if (redirectBaseUrl === null) return; + setTesting(true); + setResult(null); + void signInServer({ + environmentId: props.environmentId, + input: { name: server.name, redirectBaseUrl }, + }) + .then((outcome) => { + if (outcome._tag === "Success") { + const { authorizationUrl } = outcome.value; + if (authorizationUrl === null) { + setResult({ ok: true, message: "Connected · no sign-in needed" }); + return; + } + setResult({ + ok: true, + message: "Finish signing in in your browser, then test the connection.", + }); + void Linking.openURL(authorizationUrl).catch(() => + setResult({ ok: false, message: "Could not open the sign-in page." }), + ); + } else if (!isAtomCommandInterrupted(outcome)) { + const failure = squashAtomCommandFailure(outcome); + setResult({ + ok: false, + message: failure instanceof Error ? failure.message : "Could not start the sign-in.", + }); + } + }) + .finally(() => setTesting(false)); + }; + + return ( + + 0 + ? `${server.url} · ${headerCount} ${headerCount === 1 ? "header" : "headers"}` + : server.url + } + value={server.enabled} + disabled={props.disabled} + onValueChange={props.onEnabledChange} + /> + + + + + + + {result ? ( + + {result.message} + + ) : null} + + ); +} + +function ServerAction(props: { + readonly label: string; + readonly danger?: boolean; + readonly disabled: boolean; + readonly onPress: () => void; +}) { + return ( + + + {props.label} + + + ); +} diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index 611a1b3410c7..b901c2461fb0 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -12,6 +12,7 @@ export type SettingsSheetTarget = | "SettingsEnvironmentAgentBehavior" | "SettingsEnvironmentMaintenance" | "SettingsProviderAccounts" + | "SettingsSharedMcpServers" | "SettingsKeyboard" | "SettingsMicrophone" | "SettingsFollowUp" diff --git a/apps/server/package.json b/apps/server/package.json index cc1bddb2ee6d..3b2c9c7af5b9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -34,6 +34,7 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@ff-labs/fff-node": "0.11.0", + "@modelcontextprotocol/sdk": "1.29.0", "@muse-code/sdk": "1.4.2", "@napi-rs/keyring": "^1.3.0", "@opencode-ai/sdk": "^1.3.15", @@ -64,7 +65,6 @@ }, "devDependencies": { "@effect/vitest": "catalog:", - "@modelcontextprotocol/sdk": "1.29.0", "@t3tools/contracts": "workspace:*", "@t3tools/provider-testing": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 7d67d92f4f71..cb60307ef43d 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -87,6 +87,10 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetStorageCleanupReport]: AuthOrchestrationReadScope, [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope, + // Sends the saved headers to the server's URL, so it needs the same scope as editing them. + [WS_METHODS.serverTestSharedMcpServer]: AuthSettingsWriteScope, + // Registers T3 as an OAuth client of the server and stores its tokens. + [WS_METHODS.serverSignInSharedMcpServer]: AuthSettingsWriteScope, [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope, [WS_METHODS.serverPrepareAcpRegistryAgent]: AuthProvidersManageScope, [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: AuthProvidersManageScope, diff --git a/apps/server/src/mcp/SharedMcpHttp.test.ts b/apps/server/src/mcp/SharedMcpHttp.test.ts new file mode 100644 index 000000000000..38960e7a6b35 --- /dev/null +++ b/apps/server/src/mcp/SharedMcpHttp.test.ts @@ -0,0 +1,135 @@ +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import { assert, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientRequest, HttpRouter } from "effect/http"; + +import * as ServerSettings from "../serverSettings.ts"; +import type * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpSessionRegistry from "./McpSessionRegistry.ts"; +import * as SharedMcpHttp from "./SharedMcpHttp.ts"; +import { SharedMcpProxy, SharedMcpProxyError } from "./SharedMcpProxy.ts"; + +const calls: Array<{ readonly server: string; readonly params: unknown }> = []; + +const layerDependencies = Layer.mergeAll( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + resolve: (token) => + Effect.succeed( + token === "thread-token" + ? ({} as McpInvocationContext.McpThreadInvocationScope) + : undefined, + ), + }), + Layer.mock(SharedMcpProxy)({ + listTools: (server) => Effect.succeed({ tools: [{ name: `${server.name}_tool` }] }), + callTool: (server, params) => + Effect.suspend(() => { + calls.push({ server: server.name, params }); + return Effect.fail( + new SharedMcpProxyError({ + server: server.name, + message: "Sign in first.", + needsSignIn: true, + }), + ); + }), + finishSignIn: () => + Effect.fail( + new SharedMcpProxyError({ + server: "", + message: "This link has .", + needsSignIn: true, + }), + ), + }), + ServerSettings.layerTest({ + sharedMcpServers: [ + { id: "docs", name: "docs", url: "http://127.0.0.1:9/mcp", enabled: true, headers: {} }, + { id: "off", name: "off", url: "http://127.0.0.1:9/mcp", enabled: false, headers: {} }, + ], + }).pipe(Layer.orDie), +); + +const makeClient = Effect.gen(function* () { + const services = yield* Layer.build( + HttpRouter.serve(SharedMcpHttp.layer, { disableListenLog: true }).pipe( + Layer.provide(layerDependencies), + Layer.provideMerge(NodeHttpServer.layerTest), + ), + ); + const client = Context.get(services, HttpClient.HttpClient); + return { + rpc: (path: string, body: unknown, token = "thread-token") => + client.execute( + HttpClientRequest.post(path).pipe( + HttpClientRequest.setHeader("authorization", `Bearer ${token}`), + HttpClientRequest.bodyJsonUnsafe(body), + ), + ), + get: (path: string) => client.get(path), + }; +}); + +it.effect("serves each enabled shared server to agents holding a thread credential", () => + Effect.gen(function* () { + const { rpc } = yield* makeClient; + + assert.equal((yield* rpc("/mcp/shared/docs", {}, "stolen")).status, 401); + assert.equal((yield* rpc("/mcp/shared/off", {})).status, 404); + + const initialize = yield* rpc("/mcp/shared/docs", { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2025-03-26", capabilities: {} }, + }); + const initialized = (yield* initialize.json) as { + result: { protocolVersion: string; serverInfo: { name: string } }; + }; + assert.equal(initialized.result.protocolVersion, "2025-03-26"); + assert.equal(initialized.result.serverInfo.name, "docs"); + + const notified = yield* rpc("/mcp/shared/docs", { + jsonrpc: "2.0", + method: "notifications/initialized", + }); + assert.equal(notified.status, 202); + + const listed = yield* rpc("/mcp/shared/docs", { jsonrpc: "2.0", id: 2, method: "tools/list" }); + assert.deepEqual(yield* listed.json, { + jsonrpc: "2.0", + id: 2, + result: { tools: [{ name: "docs_tool" }] }, + }); + + // An upstream failure comes back as a tool error the agent can read. + const called = yield* rpc("/mcp/shared/docs", { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "search", arguments: { q: "x" } }, + }); + assert.deepEqual(yield* called.json, { + jsonrpc: "2.0", + id: 3, + result: { isError: true, content: [{ type: "text", text: "Sign in first." }] }, + }); + assert.deepEqual(calls, [ + { server: "docs", params: { name: "search", arguments: { q: "x" } } }, + ]); + }).pipe(Effect.scoped), +); + +it.effect("answers the OAuth callback with a page, escaping what it echoes", () => + Effect.gen(function* () { + const { get } = yield* makeClient; + const failed = yield* (yield* get("/api/mcp-oauth/callback?state=s&code=c")).text; + assert.include(failed, "Sign-in failed"); + assert.include(failed, "This link has <expired>."); + + const denied = yield* (yield* get("/api/mcp-oauth/callback?error=access_denied")).text; + assert.include(denied, "access_denied"); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/mcp/SharedMcpHttp.ts b/apps/server/src/mcp/SharedMcpHttp.ts new file mode 100644 index 000000000000..3c3822be03ec --- /dev/null +++ b/apps/server/src/mcp/SharedMcpHttp.ts @@ -0,0 +1,146 @@ +/** + * HTTP routes for shared MCP servers: the per-server MCP endpoint agents call + * with their thread credential, and the OAuth callback a sign-in returns to. + * + * The endpoint speaks stateless streamable HTTP: each POST carries one + * JSON-RPC message and gets a JSON reply. Only tools are proxied. + * + * @module mcp/SharedMcpHttp + */ +import { sharedMcpServerKey } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; + +import { ServerSettingsService } from "../serverSettings.ts"; +import * as McpSessionRegistry from "./McpSessionRegistry.ts"; +import { + SHARED_MCP_OAUTH_CALLBACK_PATH, + SHARED_MCP_PATH_SEGMENT, + SharedMcpProxy, + type SharedMcpProxyError, +} from "./SharedMcpProxy.ts"; + +const ROUTE_PREFIX = `/mcp/${SHARED_MCP_PATH_SEGMENT}/`; +const SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"]; + +type JsonRpcRequest = { + readonly jsonrpc?: unknown; + readonly id?: string | number | null; + readonly method?: unknown; + readonly params?: Record; +}; + +const reply = (id: JsonRpcRequest["id"], result: unknown) => + HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: id ?? null, result }); +const replyError = (id: JsonRpcRequest["id"], code: number, message: string) => + HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: id ?? null, error: { code, message } }); + +/** A tool call that can't reach the server is a tool error the agent can read, not a transport failure. */ +const toolError = (error: SharedMcpProxyError) => ({ + isError: true, + content: [{ type: "text", text: error.message }], +}); + +const handleMcp = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const authorization = request.headers.authorization; + const token = + authorization?.startsWith("Bearer ") === true + ? authorization.slice("Bearer ".length).trim() + : ""; + if ((yield* registry.resolve(token)) === undefined) { + return HttpServerResponse.text("Unauthorized", { status: 401 }); + } + + const pathname = new URL(request.originalUrl, "http://localhost").pathname; + const key = decodeURIComponent(pathname.slice(ROUTE_PREFIX.length)); + const settings = yield* (yield* ServerSettingsService).getSettings.pipe(Effect.orDie); + const server = settings.sharedMcpServers.find( + (entry) => entry.enabled && sharedMcpServerKey(entry) === key, + ); + if (server === undefined) return HttpServerResponse.text("Not Found", { status: 404 }); + + const message = (yield* request.json.pipe(Effect.orElseSucceed(() => undefined))) as + | JsonRpcRequest + | undefined; + if (typeof message !== "object" || message === null || Array.isArray(message)) { + return replyError(null, -32600, "Expected one JSON-RPC message."); + } + // Notifications and responses need no reply. + if (message.id === undefined || typeof message.method !== "string") { + return HttpServerResponse.empty({ status: 202 }); + } + const proxy = yield* SharedMcpProxy; + switch (message.method) { + case "initialize": { + const requested = message.params?.protocolVersion; + return reply(message.id, { + protocolVersion: + typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested) + ? requested + : SUPPORTED_PROTOCOL_VERSIONS[0], + capabilities: { tools: {} }, + serverInfo: { name: server.name, version: "1.0.0" }, + }); + } + case "ping": + return reply(message.id, {}); + case "tools/list": + return yield* proxy.listTools(server, message.params).pipe( + Effect.map((result) => reply(message.id, result)), + Effect.catch((error) => Effect.succeed(replyError(message.id, -32000, error.message))), + ); + case "tools/call": + return yield* proxy.callTool(server, message.params).pipe( + Effect.map((result) => reply(message.id, result)), + Effect.catch((error) => Effect.succeed(reply(message.id, toolError(error)))), + ); + default: + return replyError(message.id, -32601, `${message.method} is not supported by this proxy.`); + } +}); + +const page = (title: string, detail: string) => + HttpServerResponse.html( + `${title}` + + `` + + `

${title}

${detail}

`, + ); +const escapeHtml = (value: string) => + value.replaceAll(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); + +/** Public: the provider sends the browser here; the one-time `state` ties it to a sign-in T3 started. */ +const handleOAuthCallback = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const params = new URL(request.originalUrl, "http://localhost").searchParams; + const state = params.get("state"); + const code = params.get("code"); + if (state === null || code === null) { + const denied = + params.get("error_description") ?? params.get("error") ?? "No code was returned."; + return page("Sign-in did not finish", escapeHtml(denied)); + } + return yield* (yield* SharedMcpProxy).finishSignIn(state, code).pipe( + Effect.map(({ server }) => + page( + `Signed in to ${escapeHtml(server)}`, + "You can close this tab. Agents in T3 Code can use this server in their next session.", + ), + ), + Effect.catch((error) => Effect.succeed(page("Sign-in failed", escapeHtml(error.message)))), + ); +}); + +export const layer = Layer.mergeAll( + HttpRouter.add("POST", `${ROUTE_PREFIX}*`, handleMcp), + // Clients may open a server-sent event stream or end a session; neither is needed here. + HttpRouter.add( + "GET", + `${ROUTE_PREFIX}*`, + HttpServerResponse.text("Method Not Allowed", { status: 405 }), + ), + HttpRouter.add("DELETE", `${ROUTE_PREFIX}*`, HttpServerResponse.empty({ status: 204 })), + HttpRouter.add("GET", SHARED_MCP_OAUTH_CALLBACK_PATH, handleOAuthCallback), +); diff --git a/apps/server/src/mcp/SharedMcpProxy.test.ts b/apps/server/src/mcp/SharedMcpProxy.test.ts new file mode 100644 index 000000000000..0661b5fe8f66 --- /dev/null +++ b/apps/server/src/mcp/SharedMcpProxy.test.ts @@ -0,0 +1,207 @@ +// A fake upstream MCP server and OAuth provider on a local port. +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off +import * as NodeHttp from "node:http"; + +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import { assert, describe, it } from "@effect/vitest"; +import type { SharedMcpServer } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as SharedMcpProxy from "./SharedMcpProxy.ts"; + +const layerMemorySecretStore = Layer.sync(ServerSecretStore.ServerSecretStore, () => { + const stored = new Map(); + return ServerSecretStore.ServerSecretStore.of({ + get: (name) => Effect.succeed(Option.fromNullishOr(stored.get(name))), + set: (name, value) => Effect.sync(() => void stored.set(name, value)), + create: (name, value) => Effect.sync(() => void stored.set(name, value)), + getOrCreateRandom: () => Effect.die("unused"), + remove: (name) => Effect.sync(() => void stored.delete(name)), + }); +}); + +const layer = SharedMcpProxy.layer.pipe( + Layer.provide(Layer.mergeAll(layerMemorySecretStore, NodeCrypto.layer)), +); + +type Fake = { readonly base: string; readonly tokenRequests: Array }; + +/** + * An MCP server whose `/mcp` answers `initialize`, `tools/list` and + * `tools/call` once `authorized(request)` holds. With `oauth`, it also serves + * the discovery documents, client registration, and a token endpoint that + * trades `the-code` for `good-token`. + */ +const fakeUpstream = (options: { + readonly oauth: boolean; + readonly authorized: (request: NodeHttp.IncomingMessage) => boolean; +}) => + Effect.acquireRelease( + Effect.promise( + () => + new Promise<{ readonly server: NodeHttp.Server; readonly fake: Fake }>((resolve) => { + const tokenRequests: Array = []; + const fake: Fake = { base: "", tokenRequests }; + const json = (response: NodeHttp.ServerResponse, status: number, body: unknown) => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); + }; + const server = NodeHttp.createServer((request, response) => { + let body = ""; + request.on("data", (chunk) => (body += chunk)); + request.on("end", () => { + const path = new URL(request.url ?? "/", fake.base).pathname; + if (options.oauth && path === "/.well-known/oauth-protected-resource/mcp") { + return json(response, 200, { + resource: `${fake.base}/mcp`, + authorization_servers: [fake.base], + }); + } + if (options.oauth && path === "/.well-known/oauth-authorization-server") { + return json(response, 200, { + issuer: fake.base, + authorization_endpoint: `${fake.base}/authorize`, + token_endpoint: `${fake.base}/token`, + registration_endpoint: `${fake.base}/register`, + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }); + } + if (options.oauth && path === "/register") { + return json(response, 201, { ...JSON.parse(body), client_id: "client-1" }); + } + if (options.oauth && path === "/token") { + const form = new URLSearchParams(body); + tokenRequests.push(form); + return form.get("code") === "the-code" + ? json(response, 200, { access_token: "good-token", token_type: "Bearer" }) + : json(response, 400, { error: "invalid_grant" }); + } + if (path !== "/mcp") return json(response, 404, {}); + if (request.method !== "POST") { + response.writeHead(405); + return response.end(); + } + if (!options.authorized(request)) { + response.writeHead( + 401, + options.oauth + ? { + "www-authenticate": `Bearer resource_metadata="${fake.base}/.well-known/oauth-protected-resource/mcp"`, + } + : {}, + ); + return response.end(); + } + const message = JSON.parse(body) as { + id?: number; + method: string; + params?: { arguments?: unknown }; + }; + if (message.id === undefined) { + response.writeHead(202); + return response.end(); + } + const result = + message.method === "initialize" + ? { + protocolVersion: "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name: "fake", version: "1" }, + } + : message.method === "tools/list" + ? { tools: [{ name: "echo", inputSchema: { type: "object" } }] } + : { + content: [ + { type: "text", text: JSON.stringify(message.params?.arguments) }, + ], + }; + json(response, 200, { jsonrpc: "2.0", id: message.id, result }); + }); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as { port: number }; + (fake as { base: string }).base = `http://127.0.0.1:${address.port}`; + resolve({ server, fake }); + }); + }), + ), + ({ server }) => Effect.promise(() => new Promise((done) => server.close(() => done()))), + ).pipe(Effect.map(({ fake }) => fake)); + +const shared = (url: string, headers: Record = {}): SharedMcpServer => ({ + id: "fake", + name: "fake", + url, + enabled: true, + headers, +}); + +describe("SharedMcpProxy", () => { + it.effect("signs in once with OAuth, then lists and calls tools with the stored token", () => + Effect.gen(function* () { + const fake = yield* fakeUpstream({ + oauth: true, + authorized: (request) => request.headers.authorization === "Bearer good-token", + }); + const proxy = yield* SharedMcpProxy.SharedMcpProxy; + const server = shared(`${fake.base}/mcp`); + + const before = yield* proxy.probe(server).pipe(Effect.flip); + assert.isTrue(before.needsSignIn, before.message); + + const { authorizationUrl } = yield* proxy.startSignIn(server, "http://t3.local:3773"); + assert.isNotNull(authorizationUrl); + const authorize = new URL(authorizationUrl!); + assert.equal(`${authorize.origin}${authorize.pathname}`, `${fake.base}/authorize`); + assert.equal( + authorize.searchParams.get("redirect_uri"), + "http://t3.local:3773/api/mcp-oauth/callback", + ); + assert.equal(authorize.searchParams.get("code_challenge_method"), "S256"); + + const finished = yield* proxy.finishSignIn(authorize.searchParams.get("state")!, "the-code"); + assert.equal(finished.server, "fake"); + // PKCE: the token request proves it started the sign-in. + assert.isNotEmpty(fake.tokenRequests[0]?.get("code_verifier") ?? ""); + + assert.deepEqual(yield* proxy.probe(server), { serverName: "fake", toolCount: 1 }); + const called = yield* proxy.callTool(server, { name: "echo", arguments: { q: "hi" } }); + assert.deepEqual(called, { content: [{ type: "text", text: '{"q":"hi"}' }] }); + + // The callback's state is single-use. + const replay = yield* proxy + .finishSignIn(authorize.searchParams.get("state")!, "the-code") + .pipe(Effect.flip); + assert.include(replay.message, "expired"); + }).pipe(Effect.provide(layer), Effect.scoped), + ); + + it.effect("sends a server's saved headers, and reports a refusal without asking to sign in", () => + Effect.gen(function* () { + const fake = yield* fakeUpstream({ + oauth: false, + authorized: (request) => request.headers.authorization === "Bearer k-123", + }); + const proxy = yield* SharedMcpProxy.SharedMcpProxy; + + assert.deepEqual( + yield* proxy.probe(shared(`${fake.base}/mcp`, { Authorization: "Bearer k-123" })), + { + serverName: "fake", + toolCount: 1, + }, + ); + // A changed header is a new connection, not the cached one. + const refused = yield* proxy + .probe(shared(`${fake.base}/mcp`, { Authorization: "Bearer wrong" })) + .pipe(Effect.flip); + // A refused token is fixed by editing the header, not by an OAuth sign-in. + assert.isFalse(refused.needsSignIn); + assert.include(refused.message, "refused the saved Authorization header"); + }).pipe(Effect.provide(layer), Effect.scoped), + ); +}); diff --git a/apps/server/src/mcp/SharedMcpProxy.ts b/apps/server/src/mcp/SharedMcpProxy.ts new file mode 100644 index 000000000000..f2554fb4eba4 --- /dev/null +++ b/apps/server/src/mcp/SharedMcpProxy.ts @@ -0,0 +1,400 @@ +/** + * SharedMcpProxy — T3 Code's own client for the user's shared MCP servers. + * + * Agents never connect to a shared server themselves: each one reaches it at + * `/shared/` with its thread credential, and T3 opens + * the real connection with the server's saved headers and OAuth tokens, which + * stay in the secret store. One connection per server is kept and reused by + * every thread; a sign-in started from Settings completes through the OAuth + * callback route and is picked up by the next request. + * + * @module mcp/SharedMcpProxy + */ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { + type OAuthClientProvider, + UnauthorizedError, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import { SseError, SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { + StreamableHTTPClientTransport, + StreamableHTTPError, +} from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { type SharedMcpServer, sharedMcpServerKey } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { sharedMcpOAuthSecretName } from "../serverSettings.ts"; + +/** Path under T3's MCP endpoint that serves one shared server. */ +export const SHARED_MCP_PATH_SEGMENT = "shared"; +/** Route the OAuth provider redirects back to after a sign-in. */ +export const SHARED_MCP_OAUTH_CALLBACK_PATH = "/api/mcp-oauth/callback"; + +/** Long enough for slow tools; progress notifications keep extending it. */ +const TOOL_CALL_TIMEOUT_MS = 10 * 60_000; +/** A sign-in the user never finishes is dropped after this long. */ +const PENDING_SIGN_IN_TTL_MS = 15 * 60_000; + +export class SharedMcpProxyError extends Schema.TaggedError()( + "SharedMcpProxyError", + { + server: Schema.String, + message: Schema.String, + /** The server needs a sign-in from Settings before it can be used. */ + needsSignIn: Schema.Boolean, + }, +) {} + +/** OAuth state kept per server: the registered client, its tokens, and the in-flight PKCE verifier. */ +const StoredOAuth = Schema.Struct({ + redirectUrl: Schema.optionalKey(Schema.String), + clientInformation: Schema.optionalKey(Schema.Unknown), + tokens: Schema.optionalKey(Schema.Unknown), + codeVerifier: Schema.optionalKey(Schema.String), +}); +type StoredOAuth = typeof StoredOAuth.Type; +const decodeStoredOAuth = Schema.decodeUnknownOption(Schema.fromJsonString(StoredOAuth)); +const encodeStoredOAuth = Schema.encodeSync(Schema.fromJsonString(StoredOAuth)); + +/** The server refused us for want of a login, whether or not an OAuth flow ran. */ +const isUnauthorized = (cause: unknown) => + cause instanceof UnauthorizedError || + ((cause instanceof StreamableHTTPError || cause instanceof SseError) && cause.code === 401); + +export class SharedMcpProxy extends Context.Service< + SharedMcpProxy, + { + /** Upstream tool list, as the agent's `tools/list` would see it. */ + readonly listTools: ( + server: SharedMcpServer, + params: unknown, + ) => Effect.Effect; + /** Forward one tool call; the result is passed back unchanged. */ + readonly callTool: ( + server: SharedMcpServer, + params: unknown, + ) => Effect.Effect; + /** Connect and count tools, for Settings' Test connection. */ + readonly probe: ( + server: SharedMcpServer, + ) => Effect.Effect< + { readonly serverName: string | null; readonly toolCount: number }, + SharedMcpProxyError + >; + /** + * Start an OAuth sign-in, redirecting back to `redirectBaseUrl`. Returns the + * URL to open, or null when the server needs no sign-in. + */ + readonly startSignIn: ( + server: SharedMcpServer, + redirectBaseUrl: string, + ) => Effect.Effect<{ readonly authorizationUrl: string | null }, SharedMcpProxyError>; + /** Complete a sign-in from the provider's callback. */ + readonly finishSignIn: ( + state: string, + code: string, + ) => Effect.Effect<{ readonly server: string }, SharedMcpProxyError>; + } +>()("t3/mcp/SharedMcpProxy") {} + +/** + * The SDK's transports declare optional fields without `| undefined`, which + * this repo's `exactOptionalPropertyTypes` rejects; they are the SDK's own + * types on both sides, so the cast is sound. + */ +const asTransport = (transport: StreamableHTTPClientTransport | SSEClientTransport) => + transport as unknown as Parameters[0]; + +type Upstream = { readonly fingerprint: string; readonly client: Promise }; +type PendingSignIn = { + readonly server: SharedMcpServer; + readonly transport: StreamableHTTPClientTransport; + readonly expiresAt: number; +}; + +const make = Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + const crypto = yield* Crypto.Crypto; + const context = yield* Effect.context(); + const run = Effect.runPromiseWith(context); + const textEncoder = new TextEncoder(); + const textDecoder = new TextDecoder(); + + const loadOAuth = (key: string) => + secretStore.get(sharedMcpOAuthSecretName(key)).pipe( + Effect.map((secret) => + Option.isSome(secret) + ? Option.getOrElse( + decodeStoredOAuth(textDecoder.decode(secret.value)), + (): StoredOAuth => ({}), + ) + : ({} satisfies StoredOAuth), + ), + Effect.orElseSucceed((): StoredOAuth => ({})), + ); + const saveOAuth = (key: string, stored: StoredOAuth) => + secretStore + .set(sharedMcpOAuthSecretName(key), textEncoder.encode(encodeStoredOAuth(stored))) + .pipe(Effect.ignore({ log: true })); + const newState = crypto.randomUUIDv4.pipe(Effect.orDie); + + /** + * The SDK's view of a server's OAuth state, written through to the secret + * store. `interactive` providers record the authorization URL for a sign-in; + * the others leave a missing or expired login as "needs sign-in". + */ + const makeProvider = ( + key: string, + initial: StoredOAuth, + interactive: boolean, + ): OAuthClientProvider & { + readonly authorizationUrl: () => URL | undefined; + readonly lastState: () => string | undefined; + } => { + let stored = initial; + let authorizationUrl: URL | undefined; + let lastState: string | undefined; + const update = (patch: Partial) => { + stored = { ...stored, ...patch }; + return run(saveOAuth(key, stored)); + }; + return { + get redirectUrl() { + return stored.redirectUrl; + }, + get clientMetadata(): OAuthClientMetadata { + return { + client_name: "T3 Code", + redirect_uris: stored.redirectUrl === undefined ? [] : [stored.redirectUrl], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }; + }, + state: async () => { + lastState = await run(newState); + return lastState; + }, + clientInformation: () => stored.clientInformation as OAuthClientInformationMixed | undefined, + saveClientInformation: (clientInformation) => update({ clientInformation }), + tokens: () => stored.tokens as OAuthTokens | undefined, + saveTokens: (tokens) => update({ tokens }), + redirectToAuthorization: (url) => { + if (interactive) authorizationUrl = url; + }, + saveCodeVerifier: (codeVerifier) => update({ codeVerifier }), + codeVerifier: () => { + if (stored.codeVerifier === undefined) throw new Error("No sign-in is in progress."); + return stored.codeVerifier; + }, + authorizationUrl: () => authorizationUrl, + lastState: () => lastState, + }; + }; + + const fail = (server: SharedMcpServer, cause: unknown) => { + // With a saved `Authorization` header, a 401 means that token was + // refused, and signing in with OAuth would not replace it. + const tokenRefused = + isUnauthorized(cause) && + Object.keys(server.headers).some((name) => name.toLowerCase() === "authorization"); + return new SharedMcpProxyError({ + server: server.name, + needsSignIn: isUnauthorized(cause) && !tokenRefused, + message: tokenRefused + ? `${server.name} refused the saved Authorization header. Update it in Settings → Integrations → Shared MCP servers.` + : isUnauthorized(cause) + ? `Sign in to ${server.name} in T3 Code (Settings → Integrations → Shared MCP servers).` + : `Could not reach ${server.name}: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + }; + + const connect = async (server: SharedMcpServer): Promise => { + const key = sharedMcpServerKey(server); + const stored = await run(loadOAuth(key)); + const url = new URL(server.url); + // Without tokens there is nothing to refresh, and a 401 means "sign in"; + // the SDK would otherwise try a token request with no code. + const options = () => ({ + requestInit: { headers: { ...server.headers } }, + ...(stored.tokens === undefined ? {} : { authProvider: makeProvider(key, stored, false) }), + }); + const client = new Client({ name: "T3 Code", version: "1.0.0" }); + try { + await client.connect(asTransport(new StreamableHTTPClientTransport(url, options()))); + return client; + } catch (error) { + // A server that predates streamable HTTP still speaks the SSE transport. + if (isUnauthorized(error)) throw error; + const fallback = new Client({ name: "T3 Code", version: "1.0.0" }); + try { + await fallback.connect(asTransport(new SSEClientTransport(url, options()))); + return fallback; + } catch { + throw error; + } + } + }; + + const upstreams = new Map(); + const fingerprintOf = (server: SharedMcpServer) => + `${server.url}\n${Object.entries(server.headers) + .map(([name, value]) => `${name.toLowerCase()}:${value}`) + .toSorted() + .join("\n")}`; + const clientFor = (server: SharedMcpServer): Promise => { + const key = sharedMcpServerKey(server); + const fingerprint = fingerprintOf(server); + const existing = upstreams.get(key); + if (existing !== undefined && existing.fingerprint === fingerprint) return existing.client; + if (existing !== undefined) + void existing.client.then((client) => client.close()).catch(() => {}); + const client = connect(server); + upstreams.set(key, { fingerprint, client }); + // A failed connect is retried on the next request rather than cached. + client.catch(() => { + if (upstreams.get(key)?.client === client) upstreams.delete(key); + }); + return client; + }; + const dropClient = (key: string) => { + const existing = upstreams.get(key); + upstreams.delete(key); + if (existing !== undefined) + void existing.client.then((client) => client.close()).catch(() => {}); + }; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const key of upstreams.keys()) dropClient(key); + }), + ); + + /** Runs `use` on the server's connection, reconnecting once if it went stale. */ + const withClient = (server: SharedMcpServer, use: (client: Client) => Promise) => + Effect.tryPromise({ + try: async () => { + try { + return await use(await clientFor(server)); + } catch (error) { + if (isUnauthorized(error)) throw error; + dropClient(sharedMcpServerKey(server)); + return await use(await clientFor(server)); + } + }, + catch: (cause) => fail(server, cause), + }); + + const pending = new Map(); + const prunePending = (now: number) => { + for (const [state, entry] of pending) if (entry.expiresAt <= now) pending.delete(state); + }; + + const startSignIn = Effect.fn("SharedMcpProxy.startSignIn")(function* ( + server: SharedMcpServer, + redirectBaseUrl: string, + ) { + const key = sharedMcpServerKey(server); + const redirectUrl = new URL(SHARED_MCP_OAUTH_CALLBACK_PATH, redirectBaseUrl).toString(); + const stored: StoredOAuth = yield* loadOAuth(key); + // A client registered for another origin can't receive this redirect. + const { clientInformation: _client, codeVerifier: _verifier, ...kept } = stored; + const next: StoredOAuth = + stored.redirectUrl === redirectUrl ? stored : { ...kept, redirectUrl }; + yield* saveOAuth(key, next); + const provider = makeProvider(key, next, true); + const transport = new StreamableHTTPClientTransport(new URL(server.url), { + requestInit: { headers: { ...server.headers } }, + authProvider: provider, + }); + const client = new Client({ name: "T3 Code", version: "1.0.0" }); + // An unauthorized connect is the expected path: the provider has just + // recorded where to send the user. + const connected = yield* Effect.tryPromise({ + try: () => + client.connect(asTransport(transport)).then( + () => true, + (cause: unknown) => { + if (isUnauthorized(cause)) return false; + throw cause; + }, + ), + catch: (cause) => fail(server, cause), + }); + if (connected) { + // Already signed in (or no sign-in needed): use this connection. + dropClient(key); + upstreams.set(key, { fingerprint: fingerprintOf(server), client: Promise.resolve(client) }); + return { authorizationUrl: null }; + } + const authorizationUrl = provider.authorizationUrl(); + const state = provider.lastState(); + if (authorizationUrl === undefined || state === undefined) { + return yield* new SharedMcpProxyError({ + server: server.name, + needsSignIn: true, + message: `${server.name} asked for a sign-in T3 Code could not start.`, + }); + } + const now = yield* Clock.currentTimeMillis; + prunePending(now); + pending.set(state, { server, transport, expiresAt: now + PENDING_SIGN_IN_TTL_MS }); + return { authorizationUrl: authorizationUrl.toString() }; + }); + + const finishSignIn = Effect.fn("SharedMcpProxy.finishSignIn")(function* ( + state: string, + code: string, + ) { + const entry = pending.get(state); + pending.delete(state); + if (entry === undefined || entry.expiresAt <= (yield* Clock.currentTimeMillis)) { + return yield* new SharedMcpProxyError({ + server: "", + needsSignIn: true, + message: "This sign-in link has expired. Start it again from T3 Code.", + }); + } + yield* Effect.tryPromise({ + try: () => entry.transport.finishAuth(code), + catch: (cause) => fail(entry.server, cause), + }); + // The next request connects with the new tokens. + dropClient(sharedMcpServerKey(entry.server)); + return { server: entry.server.name }; + }); + + return SharedMcpProxy.of({ + listTools: (server, params) => + withClient(server, (client) => + client.listTools(params as Parameters[0]), + ), + callTool: (server, params) => + withClient(server, (client) => + client.callTool(params as Parameters[0], undefined, { + timeout: TOOL_CALL_TIMEOUT_MS, + resetTimeoutOnProgress: true, + }), + ), + probe: (server) => + withClient(server, async (client) => ({ + serverName: client.getServerVersion()?.name ?? null, + toolCount: (await client.listTools()).tools.length, + })), + startSignIn, + finishSignIn, + }); +}); + +export const layer = Layer.effect(SharedMcpProxy, make); diff --git a/apps/server/src/mcp/SharedMcpServerProbe.test.ts b/apps/server/src/mcp/SharedMcpServerProbe.test.ts new file mode 100644 index 000000000000..98af97126ca5 --- /dev/null +++ b/apps/server/src/mcp/SharedMcpServerProbe.test.ts @@ -0,0 +1,54 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as SharedMcpProxy from "./SharedMcpProxy.ts"; +import * as SharedMcpServerProbe from "./SharedMcpServerProbe.ts"; + +const layerEmptySecretStore = Layer.succeed( + ServerSecretStore.ServerSecretStore, + ServerSecretStore.ServerSecretStore.of({ + get: () => Effect.succeed(Option.none()), + set: () => Effect.void, + create: () => Effect.void, + getOrCreateRandom: () => Effect.die("unused"), + remove: () => Effect.void, + }), +); + +const layer = SharedMcpServerProbe.layer.pipe( + Layer.provide( + SharedMcpProxy.layer.pipe( + Layer.provide(Layer.mergeAll(layerEmptySecretStore, NodeCrypto.layer)), + ), + ), + Layer.provide( + ServerSettings.layerTest({ + sharedMcpServers: [ + // Port 9 (discard) refuses connections, so the probe fails fast. + { name: "offline", url: "http://127.0.0.1:9/mcp", enabled: true, headers: {} }, + ], + }).pipe(Layer.orDie), + ), +); + +it.effect("reports a server that is no longer saved", () => + Effect.gen(function* () { + const probe = yield* SharedMcpServerProbe.SharedMcpServerProbe; + const error = yield* probe.test({ name: "missing" }).pipe(Effect.flip); + assert.equal(error.message, "This server is no longer saved."); + }).pipe(Effect.provide(layer)), +); + +it.effect("turns an unreachable server into a readable test failure", () => + Effect.gen(function* () { + const probe = yield* SharedMcpServerProbe.SharedMcpServerProbe; + const error = yield* probe.test({ name: "offline" }).pipe(Effect.flip); + assert.equal(error._tag, "SharedMcpServerTestError"); + assert.equal(error.server, "offline"); + }).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/mcp/SharedMcpServerProbe.ts b/apps/server/src/mcp/SharedMcpServerProbe.ts new file mode 100644 index 000000000000..e899f91d8bd7 --- /dev/null +++ b/apps/server/src/mcp/SharedMcpServerProbe.ts @@ -0,0 +1,80 @@ +/** + * SharedMcpServerProbe — Settings' view of a saved shared MCP server: check + * that it answers through T3's proxy, and start its OAuth sign-in. + * + * @module mcp/SharedMcpServerProbe + */ +import { + type SharedMcpServer, + type SharedMcpServerSignInResult, + SharedMcpServerTestError, + type SharedMcpServerTestResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ServerSettingsService } from "../serverSettings.ts"; +import { SharedMcpProxy, type SharedMcpProxyError } from "./SharedMcpProxy.ts"; + +const PROBE_TIMEOUT = "10 seconds"; + +export class SharedMcpServerProbe extends Context.Service< + SharedMcpServerProbe, + { + /** Connect to the named saved server through the proxy and count its tools. */ + readonly test: (input: { + readonly name: string; + }) => Effect.Effect; + /** Start the named server's OAuth sign-in, redirecting back to `redirectBaseUrl`. */ + readonly signIn: (input: { + readonly name: string; + readonly redirectBaseUrl: string; + }) => Effect.Effect; + } +>()("t3/mcp/SharedMcpServerProbe") {} + +const make = Effect.gen(function* () { + const settingsService = yield* ServerSettingsService; + const proxy = yield* SharedMcpProxy; + + const fail = (server: string, message: string, needsSignIn = false) => + Effect.fail(new SharedMcpServerTestError({ server, message, needsSignIn })); + const fromProxy = (error: SharedMcpProxyError) => + fail(error.server, error.message, error.needsSignIn); + const saved = (name: string) => + Effect.gen(function* () { + const settings = yield* settingsService.getSettings.pipe( + Effect.catch(() => fail(name, "Could not read settings.")), + ); + const server: SharedMcpServer | undefined = settings.sharedMcpServers.find( + (entry) => entry.name === name, + ); + return server ?? (yield* fail(name, "This server is no longer saved.")); + }); + + const test = Effect.fn("SharedMcpServerProbe.test")(function* (input: { readonly name: string }) { + const server = yield* saved(input.name); + return yield* proxy.probe(server).pipe( + Effect.timeoutOrElse({ + duration: PROBE_TIMEOUT, + orElse: () => fail(input.name, "The server did not answer within 10 seconds."), + }), + Effect.catchTags({ SharedMcpProxyError: fromProxy }), + ); + }); + + const signIn = Effect.fn("SharedMcpServerProbe.signIn")(function* (input: { + readonly name: string; + readonly redirectBaseUrl: string; + }) { + const server = yield* saved(input.name); + return yield* proxy + .startSignIn(server, input.redirectBaseUrl) + .pipe(Effect.catchTags({ SharedMcpProxyError: fromProxy })); + }); + + return SharedMcpServerProbe.of({ test, signIn }); +}); + +export const layer = Layer.effect(SharedMcpServerProbe, make); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 69b8b3d325ab..0d59a23015f6 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -59,6 +59,8 @@ const RPC_AGGREGATES = { [WS_METHODS.serverGetStorageCleanupReport]: "server", [WS_METHODS.serverGetSettings]: "server", [WS_METHODS.serverUpdateSettings]: "server", + [WS_METHODS.serverTestSharedMcpServer]: "server", + [WS_METHODS.serverSignInSharedMcpServer]: "server", [WS_METHODS.serverSearchAcpRegistry]: "server", [WS_METHODS.serverPrepareAcpRegistryAgent]: "server", [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: "server", diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index cfde3367a593..6296afdd022d 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -2503,6 +2503,13 @@ describe("AcpAdapterV2", () => { endpoint: "http://127.0.0.1:43123/mcp", authorizationHeader: "Bearer target-thread-token", browserToolsAvailable: true, + sharedServers: [ + { + name: "docs", + url: "http://127.0.0.1:43123/mcp/shared/docs", + headers: { Authorization: "Bearer target-thread-token" }, + }, + ], }); const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", @@ -2554,6 +2561,21 @@ describe("AcpAdapterV2", () => { sessionId: "mock-session-1", cwd: process.cwd(), mcpServers: [ + // A shared server is a T3 endpoint, reached through the same bridge. + { + type: "stdio", + name: "docs", + command: process.execPath, + args: [ + process.argv[1] === undefined ? "t3" : NodePath.resolve(process.argv[1]), + "acp-mcp-bridge", + ], + env: [ + { name: "ELECTRON_RUN_AS_NODE", value: "1" }, + { name: "T3_ACP_MCP_ENDPOINT", value: "http://127.0.0.1:43123/mcp/shared/docs" }, + { name: "T3_ACP_MCP_AUTHORIZATION", value: "Bearer target-thread-token" }, + ], + }, { type: "stdio", name: "t3-code", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 7652b5636926..e3fe5beff65d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -580,6 +580,34 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { }); }); + it("attaches shared MCP servers next to t3-code without pre-approving them", () => { + const threadId = ThreadId.make("thread-claude-shared-mcp"); + const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ + mcpSession: { + ...mcpSessionFor(threadId), + sharedServers: [ + { + name: "gateway", + url: "http://127.0.0.1:3050/mcp", + headers: { Authorization: "Bearer gateway-token" }, + }, + ], + }, + readOnlySandbox: false, + }); + assert.deepEqual(overrides, { + allowedTools: [ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD], + mcpServers: { + gateway: { + type: "http", + url: "http://127.0.0.1:3050/mcp", + headers: { Authorization: "Bearer gateway-token" }, + }, + ...T3_MCP_SERVERS, + }, + }); + }); + it("extends an explicit allowlist with the t3-code wildcard", () => { const threadId = ThreadId.make("thread-claude-mcp-with-allowlist"); const mcpSession = mcpSessionFor(threadId); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 80eb47436534..54bada0a9477 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1128,6 +1128,16 @@ export function claudeMcpQueryOverrides(input: { return { allowedTools: Array.from(new Set([...(input.allowedTools ?? []), ...mcpAllowedTools])), mcpServers: { + ...Object.fromEntries( + (session.sharedServers ?? []).map((server) => [ + server.name, + { + type: "http" as const, + url: server.url, + ...(Object.keys(server.headers).length === 0 ? {} : { headers: server.headers }), + }, + ]), + ), "t3-code": { type: "http", url: session.endpoint, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 9a1c8f6c900f..79a19e80be91 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -677,6 +677,36 @@ describe("CodexAdapterV2 process spawning", () => { ); }); + it("adds shared MCP servers to the thread's MCP table", () => { + const threadId = ThreadId.make("thread-codex-shared-mcp"); + const mcpSession = { + environmentId: EnvironmentId.make("environment-codex-shared-mcp"), + threadId, + providerSessionId: "mcp-session-codex-shared", + providerInstanceId: ProviderInstanceId.make("codex"), + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer secret-codex-token", + browserToolsAvailable: true, + sharedServers: [ + { + name: "gateway", + url: "http://127.0.0.1:3050/mcp", + headers: { Authorization: "Bearer gateway-token" }, + }, + ], + }; + assert.deepEqual(CodexAdapterV2.codexThreadRuntimeParams({ mcpSession }).config.mcp_servers, { + gateway: { + url: "http://127.0.0.1:3050/mcp", + http_headers: { Authorization: "Bearer gateway-token" }, + }, + "t3-code": { + url: "http://127.0.0.1:43123/mcp", + http_headers: { Authorization: "Bearer secret-codex-token" }, + }, + }); + }); + it.effect("resolves Windows command shims through the shared spawn policy", () => Effect.gen(function* () { const command = yield* CodexAdapterV2.makeCodexAppServerSpawnCommand({ diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 38e64af02e12..f21c7513cf73 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1316,6 +1316,17 @@ export function codexThreadRuntimeParams(input: { ? {} : { mcp_servers: { + ...Object.fromEntries( + (mcpSession.sharedServers ?? []).map((server) => [ + server.name, + { + url: server.url, + ...(Object.keys(server.headers).length === 0 + ? {} + : { http_headers: server.headers }), + }, + ]), + ), "t3-code": { url: mcpSession.endpoint, http_headers: { diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 548ff5cb6643..33468ec8740c 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -45,6 +45,7 @@ import * as ProviderContinuationRequests from "@t3tools/provider-core/server/Pro import { OPENCODE_2_STILL_STOPPING, OPENCODE_PROVIDER, + sharedMcpServerPrefix, t3McpServerName, } from "@t3tools/provider-opencode/testing"; import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts"; @@ -88,7 +89,16 @@ const mcpRules = [ { action: "t3-code-*", resource: "*", effect: "deny" }, { action: "t3-code-thread_opencode2-adapter_*", resource: "*", effect: "allow" }, ]; -const t3Rules = [{ action: "*", resource: "*", effect: "allow" }, ...mcpRules]; +/** Other threads' shared MCP servers are denied; this thread's run under the mode. */ +const sharedRules = (effect: "allow" | "ask", thread = "thread_opencode2-adapter") => [ + { action: "t3-shared-*", resource: "*", effect: "deny" }, + { action: `t3-shared-${thread}_*`, resource: "*", effect }, +]; +const t3Rules = [ + { action: "*", resource: "*", effect: "allow" }, + ...sharedRules("allow"), + ...mcpRules, +]; /** The thread's title, which the recorded session already carries. */ const TITLE = "Prime check"; const sessionInfo = (overrides: Record = {}) => ({ @@ -167,8 +177,8 @@ const withInstructions = ( "skill.list", ].includes(String(entry.frame.type)), ); - // T3's MCP server is added before the entry that describes it. - const after = entries.findIndex( + // T3's MCP servers are added before the entry that describes them. + const after = entries.findLastIndex( (entry, index) => index < first && entry.type === "emit_inbound" && @@ -287,6 +297,7 @@ const supervisedRules = [ { action: "shell", resource: "*", effect: "ask" }, { action: "edit", resource: "*", effect: "ask" }, { action: "external_directory", resource: "*", effect: "ask" }, + ...sharedRules("ask"), ...buildPaths, ...mcpRules, ]; @@ -908,7 +919,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { // A subagent's session may use its thread's T3 MCP server. out("session.update", { sessionID: CHILD, - permissions: [...supervisedRules.slice(0, 3), ...mcpRules], + permissions: [...supervisedRules.slice(0, 5), ...mcpRules], }), reply("session.update", null), out("session.prompt", { sessionID: SESSION, text: "" }), @@ -2654,7 +2665,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { out("session.update", { sessionID: CHILD, permissions: [ - ...supervisedRules.slice(0, 3), + ...supervisedRules.slice(0, 5), { action: "shell", resource: "echo *", effect: "allow" }, // A subagent's session may use its thread's T3 MCP server. ...mcpRules, @@ -2832,7 +2843,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { // The subagent gets them too before its execution runs anything. out("session.update", { sessionID: CHILD, - permissions: [...supervisedRules.slice(0, 3), ...mcpRules], + permissions: [...supervisedRules.slice(0, 5), ...mcpRules], }), reply("session.update", null), event("session.execution.started", { sessionID: CHILD }), @@ -2892,9 +2903,9 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { out("session.update", { sessionID: SESSION, permissions: [ - ...supervisedRules.slice(0, 3), + ...supervisedRules.slice(0, 5), { action: "shell", resource: "echo *", effect: "allow" }, - ...supervisedRules.slice(3), + ...supervisedRules.slice(5), ], }), reply("session.update", null), @@ -3442,6 +3453,105 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + it.effect( + "adds the thread's shared MCP servers next to T3's, retries a refused one, and removes them with it", + () => + Effect.gen(function* () { + const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + yield* mcpSessions.set({ + environmentId: EnvironmentId.make("environment:opencode2-adapter"), + threadId, + providerSessionId: "mcp:opencode2-adapter", + providerInstanceId: instanceId, + endpoint: "http://127.0.0.1:3773/mcp", + authorizationHeader: "Bearer thread-credential", + browserToolsAvailable: false, + sharedServers: [ + { + name: "gateway", + url: "http://127.0.0.1:3050/mcp", + headers: { Authorization: "Bearer gateway-token" }, + }, + { name: "broken", url: "http://127.0.0.1:1/mcp", headers: {} }, + ], + }); + yield* Effect.addFinalizer(() => mcpSessions.clear(threadId)); + const server = "t3-code-thread_opencode2-adapter"; + const gateway = "t3-shared-thread_opencode2-adapter_gateway"; + const broken = "t3-shared-thread_opencode2-adapter_broken"; + const { runtime, thread } = yield* resumed([ + out("mcp.add", { server, "location[directory]": WORK, config: "" }), + reply("mcp.add", null), + out("mcp.add", { + server: gateway, + "location[directory]": WORK, + config: { + type: "remote", + url: "http://127.0.0.1:3050/mcp", + headers: { Authorization: "Bearer gateway-token" }, + oauth: false, + }, + }), + reply("mcp.add", null), + // A shared server OpenCode refuses is skipped; the turn still runs. + out("mcp.add", { + server: broken, + "location[directory]": WORK, + config: { type: "remote", url: "http://127.0.0.1:1/mcp", oauth: false }, + }), + reply("mcp.add", { status: 500, body: { _tag: "UnknownError", message: "add failed" } }), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + // The next turn retries the one OpenCode refused, as it would T3's own. + out("mcp.add", { + server: broken, + "location[directory]": WORK, + config: { type: "remote", url: "http://127.0.0.1:1/mcp", oauth: false }, + }), + reply("mcp.add", null), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + out("mcp.remove", { server, "location[directory]": WORK }), + reply("mcp.remove", null), + out("mcp.remove", { server: gateway, "location[directory]": WORK }), + reply("mcp.remove", null), + out("mcp.remove", { server: broken, "location[directory]": WORK }), + reply("mcp.remove", null), + ]); + const firstEnded = yield* Deferred.make(); + const ended = yield* runtime.events.pipe( + Stream.filter( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "turn.terminal" } + > => event.type === "turn.terminal", + ), + Stream.tap(() => Deferred.succeed(firstEnded, undefined)), + Stream.take(2), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + yield* Deferred.await(firstEnded); + yield* runtime.startTurn({ + ...turnInput(thread), + runId: RunId.make("run:opencode2-adapter:2"), + runOrdinal: 2, + providerTurnOrdinal: 2, + attemptId: RunAttemptId.make("attempt:opencode2-adapter:2"), + }); + assert.deepEqual( + [...(yield* Fiber.join(ended))].map((terminal) => terminal.status), + ["completed", "completed"], + ); + yield* runtime.unloadThread!({ providerThread: thread }); + }).pipe(Effect.scoped), + ); + it.effect("registers a long thread id's MCP server under a name OpenCode accepts", () => Effect.gen(function* () { // Spelled out, this delegated thread's server name would be 120 characters. @@ -3469,6 +3579,8 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { sessionInfo({ permissions: [ { action: "*", resource: "*", effect: "allow" }, + // Shared servers' prefix is shortened the same way as T3's name. + ...sharedRules("allow", "aa73fa1e03099934"), { action: "t3-code-*", resource: "*", effect: "deny" }, { action: `${server}_*`, resource: "*", effect: "allow" }, ], @@ -3522,6 +3634,12 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { assert.equal(names[0], "t3-code-63abb5df2b188bdd"); // A name that already fits stays readable. assert.equal(yield* t3McpServerName(threadId), "t3-code-thread_opencode2-adapter"); + // Shared servers' names fit too, with the longest allowed server name. + for (const id of ids) { + const name = `${yield* sharedMcpServerPrefix(id)}_${"x".repeat(24)}`; + assert.isAtMost(name.length, 64); + assert.match(name, /^t3-shared-[A-Za-z0-9_-]+$/); + } }).pipe(Effect.provide(NodeCrypto.layer)), ); @@ -4418,6 +4536,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { sessionID: FORK, permissions: [ { action: "*", resource: "*", effect: "allow" }, + ...sharedRules("allow", "thread_opencode2-adapter_fork"), { action: "t3-code-*", resource: "*", effect: "deny" }, { action: "t3-code-thread_opencode2-adapter_fork_*", resource: "*", effect: "allow" }, ], diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index ceb230b00894..3acd8191e12d 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -1913,6 +1913,64 @@ it.effect( }), ); +it.effect( + "ProviderSessionManagerV2 hands the user's enabled shared MCP servers to the session", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const mcpConfigs = yield* Ref.make< + ReadonlyArray + >([]); + const gateway = { + name: "gateway", + url: "http://127.0.0.1:3050/mcp", + enabled: true, + headers: {}, + }; + const sharedMcpServers = [gateway, { ...gateway, name: "paused", enabled: false }]; + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-shared-mcp"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + + // Switched-off servers stay out of the session, and agents reach the + // others through T3's proxy with the thread's own credential. + const config = (yield* Ref.get(mcpConfigs))[0]; + assert.isDefined(config); + assert.deepEqual(config.sharedServers, [ + { + name: "gateway", + url: `${config.endpoint}/shared/gateway`, + headers: { Authorization: config.authorizationHeader }, + }, + ]); + yield* manager.close(providerSessionId); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 1_000, + mcpConfigs, + serverSettingsLayer: ServerSettings.layerTest({ sharedMcpServers }).pipe(Layer.orDie), + }), + ), + ); + }), +); + it.effect("ProviderSessionManagerV2 honors a project browser-access opt-out", () => Effect.gen(function* () { const captured = yield* runBrowserAccessScenario({ diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index f0ec5282ef5b..5ce31d060623 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -41,6 +41,7 @@ import { } from "../observability/Metrics.ts"; import { ProviderWorkspaceMissingError } from "../provider/Errors.ts"; import * as ProjectService from "../project/ProjectService.ts"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as ServerSettings from "../serverSettings.ts"; import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; @@ -360,6 +361,18 @@ export const layerWithOptions = ( const providerEventIngestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const agentScope = yield* AgentScope; + // Read fresh on every prepare, so a server added in Settings → Integrations + // reaches the next session without restarting T3. + const sharedMcpServers = Option.isNone(serverSettings) + ? Effect.succeed([]) + : serverSettings.value.getSettings.pipe( + Effect.map((settings) => settings.sharedMcpServers.filter((server) => server.enabled)), + Effect.catch((cause) => + Effect.logWarning("Could not read shared MCP servers; sessions get none.", { + cause, + }).pipe(Effect.as([])), + ), + ); const agentAccessSettings = Effect.fn("ProviderSessionManagerV2.agentAccessSettings")( function* (threadId: ThreadId) { if (Option.isNone(serverSettings)) return { browser: true, device: false }; @@ -495,6 +508,7 @@ export const layerWithOptions = ( >(["orchestration", "worktree", "pull-requests"]); if (browserToolsAvailable) capabilities.add("preview"); if (deviceToolsAvailable) capabilities.add("device"); + const sharedServers = yield* sharedMcpServers; const existing = yield* mcpSessions.read(threadId); if (existing !== undefined) { // Reserve before the async resolve so a release cannot @@ -521,6 +535,13 @@ export const layerWithOptions = ( resolved.capabilities.has("preview") === browserToolsAvailable && resolved.capabilities.has("device") === deviceToolsAvailable ) { + yield* mcpSessions.set({ + ...existing, + sharedServers: McpProviderSession.sharedMcpSessionServers( + sharedServers, + existing, + ), + }); return { mcpCredentialId: existing.providerSessionId, issued: false }; } dropMcpCredentialReservation(threadId, existing.providerSessionId); @@ -532,7 +553,13 @@ export const layerWithOptions = ( browserToolsAvailable, capabilities, }); - yield* mcpSessions.set(credential.config); + yield* mcpSessions.set({ + ...credential.config, + sharedServers: McpProviderSession.sharedMcpSessionServers( + sharedServers, + credential.config, + ), + }); reserveMcpCredential(threadId, credential.config.providerSessionId); return { mcpCredentialId: credential.config.providerSessionId, issued: true }; }), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f1daa720016f..e57231abd682 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -72,6 +72,9 @@ import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; import * as Keybindings from "./keybindings.ts"; +import * as SharedMcpHttp from "./mcp/SharedMcpHttp.ts"; +import * as SharedMcpProxy from "./mcp/SharedMcpProxy.ts"; +import * as SharedMcpServerProbe from "./mcp/SharedMcpServerProbe.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; @@ -592,7 +595,17 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( // Both read a user-owned file out of the state directory and stream changes // to clients; neither depends on the other. Layer.provideMerge( - Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer, UsageLimitSources.layer), + Layer.mergeAll( + Keybindings.layer, + EnvironmentTheme.layer, + UsageLimitSources.layer, + // One proxy instance: Settings start sign-ins over RPC and the HTTP + // callback finishes them, so both must see the same pending sign-ins. + SharedMcpServerProbe.layer.pipe( + Layer.provideMerge(SharedMcpProxy.layer.pipe(Layer.provide(ServerSecretStore.layer))), + Layer.provide(layerServerSettings), + ), + ), ), Layer.provideMerge(ProviderRegistry.layer), // The instance registry is the new routing keystone — text generation, @@ -703,6 +716,7 @@ const layerMakeRoutes = Layer.mergeAll( ServerHttp.layerAssetRoute, ServerHttp.layerAttachmentUploadRoute, DeviceHubProxy.layer, + SharedMcpHttp.layer, ServerBrowserStream.routeLayer, ServerHttp.layerStaticAndDevRoute, Ws.layer, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 5c7466b472db..64cf7d486cd9 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -14,6 +14,7 @@ import { createModelSelection } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; @@ -34,6 +35,7 @@ import * as ServerSettingsModule from "./serverSettings.ts"; import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); +const decodePatch = Schema.decodeUnknownExit(ServerSettingsPatch); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)); @@ -1500,6 +1502,118 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect("keeps shared MCP header values in the secret store and redacts them for clients", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const gateway = { + name: "gateway", + url: "http://127.0.0.1:3050/mcp", + enabled: true, + headers: { Authorization: "Bearer gw-secret" }, + }; + + const saved = yield* serverSettings.updateSettings({ sharedMcpServers: [gateway] }); + assert.deepEqual(saved.sharedMcpServers, [gateway]); + assert.notInclude(yield* fileSystem.readFileString(serverConfig.settingsPath), "gw-secret"); + + const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).sharedMcpServers; + assert.notInclude(forClient[0]?.headers.Authorization ?? "", "gw-secret"); + + // Echoing the redacted value back (e.g. toggling the server off) keeps the stored one. + yield* serverSettings.updateSettings({ + sharedMcpServers: [{ ...forClient[0]!, enabled: false }], + }); + assert.deepEqual((yield* serverSettings.getSettings).sharedMcpServers, [ + { ...gateway, enabled: false }, + ]); + + // Removing the server drops its secret, so re-adding it starts clean. + yield* serverSettings.updateSettings({ sharedMcpServers: [] }); + const readded = yield* serverSettings.updateSettings({ + sharedMcpServers: [{ ...gateway, headers: {} }], + }); + assert.deepEqual(readded.sharedMcpServers[0]?.headers, {}); + }).pipe(Effect.provide(layerServerSettingsWithSecrets())), + ); + + it.effect("keeps a shared MCP server's header secret through a rename", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const saved = yield* serverSettings.updateSettings({ + sharedMcpServers: [ + { + id: "mcp_1", + name: "gateway", + url: "http://127.0.0.1:3050/mcp", + enabled: true, + headers: { Authorization: "Bearer gw-secret" }, + }, + ], + }); + const [forClient] = + ServerSettingsModule.redactServerSettingsForClient(saved).sharedMcpServers; + + yield* serverSettings.updateSettings({ sharedMcpServers: [{ ...forClient!, name: "gw" }] }); + + assert.deepEqual((yield* serverSettings.getSettings).sharedMcpServers[0]?.headers, { + Authorization: "Bearer gw-secret", + }); + }).pipe(Effect.provide(layerServerSettingsWithSecrets())), + ); + + it.effect("moves a hand-edited shared MCP header into the secret store", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"sharedMcpServers":[{"name":"gateway","url":"http://127.0.0.1:3050/mcp","headers":{"Authorization":"Bearer hand-edited"}}]}', + ); + const loaded = yield* serverSettings.getSettings; + const [forClient] = + ServerSettingsModule.redactServerSettingsForClient(loaded).sharedMcpServers; + + // A client toggling the server echoes the marker back. + yield* serverSettings.updateSettings({ + sharedMcpServers: [{ ...forClient!, enabled: false }], + }); + + assert.notInclude(yield* fileSystem.readFileString(serverConfig.settingsPath), "hand-edited"); + assert.deepEqual((yield* serverSettings.getSettings).sharedMcpServers[0]?.headers, { + Authorization: "Bearer hand-edited", + }); + }).pipe(Effect.provide(layerServerSettingsWithSecrets())), + ); + + it.effect("rejects shared MCP servers that share a name or a header name", () => + Effect.gen(function* () { + const server = { name: "gateway", url: "http://127.0.0.1:3050/mcp", enabled: true }; + assert.isTrue( + Exit.isFailure( + decodePatch({ + sharedMcpServers: [ + { ...server, headers: {} }, + { ...server, headers: {} }, + ], + }), + ), + ); + assert.isTrue( + Exit.isFailure( + decodePatch({ + sharedMcpServers: [{ ...server, headers: { Authorization: "a", authorization: "b" } }], + }), + ), + ); + assert.isTrue( + Exit.isSuccess(decodePatch({ sharedMcpServers: [{ ...server, headers: {} }] })), + ); + }), + ); + it.effect( "keeps source control host secrets in the secret store and tells clients only that one is set", () => diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 5a36f77e4220..487dde2a2cd1 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -32,6 +32,7 @@ import { ServerSettings, ServerSettingsError, type ServerSettingsPatch, + sharedMcpServerKey, } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; @@ -164,6 +165,17 @@ function usageLimitSourceSecretName(sourceId: string): string { return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; } +/** Secret holding a shared server's OAuth sign-in (see `SharedMcpProxy`); removed with the server. */ +export function sharedMcpOAuthSecretName(serverKey: string): string { + return `shared-mcp-oauth-${Buffer.from(serverKey, "utf8").toString("base64url")}`; +} + +/** Keyed by the server's stable id (see `sharedMcpServerKey`), so a rename keeps its secrets. */ +function sharedMcpHeaderSecretName(serverKey: string, headerName: string): string { + const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url"); + return `shared-mcp-header-${encode(serverKey)}-${encode(headerName.toLowerCase())}`; +} + const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : ""); /** Each host's settings with every secret slot's value replaced by `map(slot, value)`. */ @@ -245,7 +257,20 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS const sourceControlHosts = mapSlotValues(settings.sourceControlHosts, (_slot, value) => redactSecret(value), ); - return { ...settings, providerInstances, usageLimitSources, sourceControlHosts }; + // Shared MCP header values are usually bearer tokens. + const sharedMcpServers = settings.sharedMcpServers.map((server) => ({ + ...server, + headers: Object.fromEntries( + Object.entries(server.headers).map(([name, value]) => [name, redactSecret(value)]), + ), + })); + return { + ...settings, + providerInstances, + usageLimitSources, + sourceControlHosts, + sharedMcpServers, + }; } export function applyProviderInstanceMutation( @@ -1053,6 +1078,26 @@ const make = Effect.gen(function* () { Option.isSome(secret) ? textDecoder.decode(secret.value) : "", ); } + const sharedMcpServers: Array = []; + for (const server of settings.sharedMcpServers) { + const headers: Record = {}; + for (const [name, value] of Object.entries(server.headers)) { + if (value !== SECRET_REDACTED) { + headers[name] = value; + continue; + } + const secret = yield* secretStore + .get(sharedMcpHeaderSecretName(sharedMcpServerKey(server), name)) + .pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), + ), + ); + headers[name] = Option.isSome(secret) ? textDecoder.decode(secret.value) : ""; + } + sharedMcpServers.push({ ...server, headers }); + } return { ...settings, providerInstances: providerInstances as ServerSettings["providerInstances"], @@ -1062,6 +1107,7 @@ const make = Effect.gen(function* () { ? (storedSecrets.get(sourceControlHostSecretName(slot)) ?? "") : value, ), + sharedMcpServers, }; }); @@ -1203,6 +1249,51 @@ const make = Effect.gen(function* () { }); } + // Each shared MCP header value is its own secret. The marker keeps the + // stored value; a value hand-edited into settings.json is moved into the + // store instead of dropped, and a marker with nothing behind it is + // dropped rather than saved as an empty header. Headers or servers that + // disappear drop their secrets. + const currentHeaders = new Map( + current.sharedMcpServers.map((server) => [sharedMcpServerKey(server), server.headers]), + ); + const nextHeaderSecrets = new Set(); + const sharedMcpServers = next.sharedMcpServers.map((server) => { + const key = sharedMcpServerKey(server); + const headers: Record = {}; + for (const [name, value] of Object.entries(server.headers)) { + const secretName = sharedMcpHeaderSecretName(key, name); + const previous = currentHeaders.get(key)?.[name]; + const stored = value === SECRET_REDACTED ? previous : value; + if (stored === undefined || stored.length === 0) { + changes.push({ kind: "remove", secretName, operation: "remove-secret" }); + continue; + } + nextHeaderSecrets.add(secretName); + if (stored !== SECRET_REDACTED) { + changes.push({ kind: "write", secretName, value: textEncoder.encode(stored) }); + } + headers[name] = SECRET_REDACTED; + } + return { ...server, headers }; + }); + const nextServerKeys = new Set(next.sharedMcpServers.map(sharedMcpServerKey)); + for (const server of current.sharedMcpServers) { + for (const name of Object.keys(server.headers)) { + const secretName = sharedMcpHeaderSecretName(sharedMcpServerKey(server), name); + if (nextHeaderSecrets.has(secretName)) continue; + changes.push({ kind: "remove", secretName, operation: "remove-stale-secret" }); + } + // A removed server's OAuth sign-in goes with it. + if (!nextServerKeys.has(sharedMcpServerKey(server))) { + changes.push({ + kind: "remove", + secretName: sharedMcpOAuthSecretName(sharedMcpServerKey(server)), + operation: "remove-stale-secret", + }); + } + } + // Hand-edited plaintext not yet in the store, so the redaction marker can move it there. const inlineSecrets = new Map( sourceControlHostSecretSlots(current.sourceControlHosts).flatMap((slot) => @@ -1241,6 +1332,7 @@ const make = Effect.gen(function* () { providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], sourceControlHosts, + sharedMcpServers, }, changes, }; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 61bad3faf318..7cb3c79b5ff0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -176,6 +176,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; +import * as SharedMcpServerProbe from "./mcp/SharedMcpServerProbe.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; @@ -1282,6 +1283,7 @@ const layerWsRpc = ( const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const storageCleanup = yield* StorageCleanup.StorageCleanup; const serverSettings = yield* ServerSettings.ServerSettingsService; + const sharedMcpServerProbe = yield* SharedMcpServerProbe.SharedMcpServerProbe; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; @@ -2404,6 +2406,8 @@ const layerWsRpc = ( : serverSettings.updateProviderInstance(providerInstanceMutation, nextPatch); return ServerSettings.redactServerSettingsForClient(settings); }), + [WS_METHODS.serverTestSharedMcpServer]: ({ name }) => sharedMcpServerProbe.test({ name }), + [WS_METHODS.serverSignInSharedMcpServer]: (input) => sharedMcpServerProbe.signIn(input), [WS_METHODS.serverDiscoverSourceControl]: (_input) => sourceControlDiscovery.discover, [WS_METHODS.serverGetTraceDiagnostics]: (_input) => TraceDiagnostics.readTraceDiagnostics({ diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index a0c0a79bebc6..002dd4d4e91a 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -3,6 +3,7 @@ import { DeviceToolVersions } from "../device/DeviceToolVersions"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; import { ScopedSwitch } from "./ScopedSwitch"; import { DeviceHostsSettings } from "./DeviceHostsSettings"; +import { SharedMcpServersSettings } from "./SharedMcpServersSettings"; /** * Integrations settings - preferences for surfaces T3 Code embeds rather than * owns. Browser is the first section: the defaults a preview tab opens at, @@ -1537,6 +1538,7 @@ export function IntegrationsSettingsPanel() { {/* Server-authoritative agent access is scoped by the header selection; the preview defaults below are device-local and ignore it. */} + {previewDefaultsDisabled ? ( diff --git a/apps/web/src/components/settings/SharedMcpServersSettings.tsx b/apps/web/src/components/settings/SharedMcpServersSettings.tsx new file mode 100644 index 000000000000..3aa85661437f --- /dev/null +++ b/apps/web/src/components/settings/SharedMcpServersSettings.tsx @@ -0,0 +1,440 @@ +import { type SharedMcpServer, SharedMcpServerTestError } from "@t3tools/contracts"; +import { + parseSharedMcpServerDraft, + sharedMcpServerDraft, + type SharedMcpServerDraft, + upsertSharedMcpServer, +} from "@t3tools/client-runtime/state/shared-mcp-servers"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + LogInIcon, + MoreHorizontalIcon, + PencilIcon, + PlugZapIcon, + PlusIcon, + Trash2Icon, +} from "lucide-react"; +import * as Schema from "effect/Schema"; +import { type Dispatch, type SetStateAction, useState } from "react"; + +import { useOpenLink } from "../../browser/useOpenLink"; +import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { type EnvironmentPresentation, useEnvironmentHttpBaseUrl } from "../../state/environments"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; +import { Switch } from "../ui/switch"; +import { Textarea } from "../ui/textarea"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +const isTestError = Schema.is(SharedMcpServerTestError); + +type FormState = { + readonly environmentId: EnvironmentPresentation["environmentId"]; + readonly editing?: SharedMcpServer; +} | null; + +/** MCP servers added once here reach every agent session on the selected environment. */ +export function SharedMcpServersSettings() { + const scope = useSettingsScope(); + // "All environments" with a single connected one still has an obvious target. + const environment = + scope.environment ?? + (scope.connectedEnvironments.length === 1 ? scope.connectedEnvironments[0] : undefined); + const { environments } = scope; + const connected = + environment?.connection.phase === "connected" && environment.serverConfig !== null; + const [openForm, setForm] = useState(null); + // A form opened for another environment doesn't follow a scope change. + const form = openForm?.environmentId === environment?.environmentId ? openForm : null; + + return ( + environment && setForm({ environmentId: environment.environmentId })} + > + + Add server + + ) : null + } + > + {environment && connected ? ( + + ) : ( + 1 + ? "Shared MCP servers belong to one environment. Select a single environment above." + : "Connect an environment to manage its shared MCP servers." + } + /> + )} + + ); +} + +function SharedMcpServerRows({ + environment, + form, + onFormChange, +}: { + readonly environment: EnvironmentPresentation; + readonly form: FormState; + readonly onFormChange: Dispatch>; +}) { + const servers = useEnvironmentSettings( + environment.environmentId, + (settings) => settings.sharedMcpServers, + ); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "shared MCP servers update", + }); + // Each write replaces the whole list, so one runs at a time: a second built + // from the same snapshot would undo the first. + const [saving, setSaving] = useState(false); + const save = async (sharedMcpServers: ReadonlyArray) => { + setSaving(true); + const result = await updateSettings({ + environmentId: environment.environmentId, + input: { patch: { sharedMcpServers: [...sharedMcpServers] } }, + }); + setSaving(false); + return result._tag === "Success"; + }; + // A failed save keeps the form open, so nothing typed is lost; a late one + // closes only the form that started it, not one opened since. + const saveAndClose = (sharedMcpServers: ReadonlyArray) => { + const started = form; + void save(sharedMcpServers).then((saved) => { + if (saved) onFormChange((current) => (current === started ? null : current)); + }); + }; + + return ( + <> + {servers.length === 0 && form === null ? ( + + ) : null} + {servers.map((server) => + form?.editing?.name === server.name ? ( + onFormChange(null)} + saving={saving} + onSave={(next) => saveAndClose(upsertSharedMcpServer(servers, next, server))} + /> + ) : ( + + void save(servers.map((entry) => (entry === server ? { ...entry, enabled } : entry))) + } + onEdit={() => + onFormChange({ environmentId: environment.environmentId, editing: server }) + } + onRemove={() => void save(servers.filter((entry) => entry !== server))} + /> + ), + )} + {form !== null && form.editing === undefined ? ( + onFormChange(null)} + onSave={(next) => saveAndClose(upsertSharedMcpServer(servers, next))} + /> + ) : null} + + ); +} + +function SharedMcpServerRow({ + environment, + server, + saving, + onToggle, + onEdit, + onRemove, +}: { + readonly environment: EnvironmentPresentation; + readonly server: SharedMcpServer; + readonly saving: boolean; + readonly onToggle: (enabled: boolean) => void; + readonly onEdit: () => void; + readonly onRemove: () => void; +}) { + const testServer = useAtomCommand(serverEnvironment.testSharedMcpServer, { + reportFailure: false, + }); + const signInServer = useAtomCommand(serverEnvironment.signInSharedMcpServer, { + reportFailure: false, + }); + const openLink = useOpenLink(null); + // The provider sends the browser back to this environment's server, which + // keeps the tokens; agents never see them. + const redirectBaseUrl = useEnvironmentHttpBaseUrl(environment.environmentId); + const [test, setTest] = useState< + | { readonly state: "running"; readonly message: string } + | { + readonly state: "done"; + readonly message: string; + readonly ok: boolean; + readonly needsSignIn?: boolean; + } + | null + >(null); + const headerCount = Object.keys(server.headers).length; + const failed = (result: Parameters[0]) => { + const failure = squashAtomCommandFailure(result); + setTest({ + state: "done", + ok: false, + needsSignIn: isTestError(failure) && failure.needsSignIn, + message: failure instanceof Error ? failure.message : String(failure), + }); + }; + + const runTest = async () => { + setTest({ state: "running", message: "Testing…" }); + const result = await testServer({ + environmentId: environment.environmentId, + input: { name: server.name }, + }); + if (result._tag === "Success") { + const { serverName, toolCount } = result.value; + setTest({ + state: "done", + ok: true, + message: `Connected${serverName ? ` to ${serverName}` : ""} · ${toolCount} ${toolCount === 1 ? "tool" : "tools"}`, + }); + } else if (isAtomCommandInterrupted(result)) { + setTest(null); + } else { + failed(result); + } + }; + + const signIn = async () => { + if (redirectBaseUrl === null) return; + setTest({ state: "running", message: "Starting sign-in…" }); + const result = await signInServer({ + environmentId: environment.environmentId, + input: { name: server.name, redirectBaseUrl }, + }); + if (result._tag === "Success") { + const { authorizationUrl } = result.value; + if (authorizationUrl === null) { + setTest({ state: "done", ok: true, message: "Connected · no sign-in needed" }); + return; + } + setTest({ + state: "done", + ok: true, + message: "Finish signing in in your browser, then test the connection.", + }); + await openLink(authorizationUrl).catch((error: unknown) => + setTest({ state: "done", ok: false, message: String(error) }), + ); + } else if (isAtomCommandInterrupted(result)) { + setTest(null); + } else { + failed(result); + } + }; + + return ( + + {server.name} + {headerCount > 0 ? ( + + {headerCount} {headerCount === 1 ? "header" : "headers"} + + ) : null} + + } + description={{server.url}} + status={ + test === null ? undefined : test.state === "running" ? ( + test.message + ) : test.needsSignIn ? ( + + Needs sign-in + + + ) : ( + + {test.message} + + ) + } + control={ +
+ + + + } + > + + + + void runTest()}> + + Test connection + + void signIn()}> + + Sign in + + + + Edit + + + + + Remove + + + +
+ } + /> + ); +} + +function SharedMcpServerForm({ + servers, + editing, + saving, + onCancel, + onSave, +}: { + readonly servers: ReadonlyArray; + readonly editing?: SharedMcpServer; + readonly saving: boolean; + readonly onCancel: () => void; + readonly onSave: (server: SharedMcpServer) => void; +}) { + const [draft, setDraft] = useState(() => + editing ? sharedMcpServerDraft(editing) : { name: "", url: "", headers: "" }, + ); + const [error, setError] = useState(null); + const submit = () => { + if (saving) return; + const parsed = parseSharedMcpServerDraft(servers, draft, editing); + if ("error" in parsed) { + setError(parsed.error); + return; + } + onSave(parsed.server); + }; + const field = (key: keyof SharedMcpServerDraft) => ({ + value: draft[key], + onChange: (event: { currentTarget: { value: string } }) => { + // Read now: React clears `currentTarget` before a deferred updater runs. + const value = event.currentTarget.value; + setDraft((current) => ({ ...current, [key]: value })); + }, + }); + + return ( + + + + + } + > +
{ + event.preventDefault(); + submit(); + }} + > +
+ + +
+