From 29b9a9182d9bc04cdc5e69aeff16fec39c0a62c3 Mon Sep 17 00:00:00 2001 From: pseudo Date: Fri, 4 Sep 2026 18:58:12 -0600 Subject: [PATCH 1/2] feat(settings): manage MCP servers per Claude account Every signed-in Claude account keeps its own CLAUDE_CONFIG_DIR, so a server added from a shell only ever reaches whichever account owns ~/.claude. With several accounts that meant repeating the same setup by hand, once per account, and there was no way to do it at all from the phone. Adds Settings, MCP on web and desktop and Settings, MCP Servers on mobile. Servers are grouped by name with a chip per account, so a server missing from some accounts is visible at a glance and "Copy to N more" replicates it, credentials included, without the definition ever leaving the machine. Adding takes the same JSON claude mcp add-json accepts and applies it to the accounts you pick. Reads lift the mcpServers object off each account's .claude.json and stop there; writes shell out to claude mcp with that account's config directory and environment. T3 Code reimplements none of Claude Code's configuration precedence, so the CLI stays the only thing that decides what a valid entry is. Env and header values are dropped before the inventory crosses the websocket; only their key names travel. /mcp typed into a conversation still does nothing and cannot be made to work: it is handled by the Claude terminal UI, and conversations run the CLI headless. Each account instead gets a copyable command for the interactive CLI, labelled with the environment variable its sign-in depends on. Written by Claude Opus 5 in T3 Code. --- apps/mobile/src/Stack.tsx | 8 + .../settings/SettingsMcpRouteScreen.tsx | 342 +++++++++++++++++ .../features/settings/SettingsRouteScreen.tsx | 1 + .../components/settings-sheet-targets.ts | 1 + apps/mobile/src/state/mcp.ts | 6 + apps/server/src/auth/RpcAuthorization.ts | 5 + .../server/src/mcp/ProviderMcpServers.test.ts | 40 ++ apps/server/src/mcp/ProviderMcpServers.ts | 332 +++++++++++++++++ apps/server/src/ws.ts | 19 + .../src/components/settings/McpSettings.tsx | 343 ++++++++++++++++++ .../settings/SettingsSidebarNav.tsx | 3 + .../src/components/settings/settingsSearch.ts | 3 + apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/settings.mcp.tsx | 7 + apps/web/src/state/mcp.ts | 6 + docs/user/mcp-servers.md | 63 ++++ packages/client-runtime/package.json | 4 + packages/client-runtime/src/state/mcp.ts | 36 ++ packages/contracts/src/index.ts | 3 + packages/contracts/src/mcpServers.ts | 149 ++++++++ packages/contracts/src/rpc.ts | 45 +++ 21 files changed, 1437 insertions(+) create mode 100644 apps/mobile/src/features/settings/SettingsMcpRouteScreen.tsx create mode 100644 apps/mobile/src/state/mcp.ts create mode 100644 apps/server/src/mcp/ProviderMcpServers.test.ts create mode 100644 apps/server/src/mcp/ProviderMcpServers.ts create mode 100644 apps/web/src/components/settings/McpSettings.tsx create mode 100644 apps/web/src/routes/settings.mcp.tsx create mode 100644 apps/web/src/state/mcp.ts create mode 100644 docs/user/mcp-servers.md create mode 100644 packages/client-runtime/src/state/mcp.ts create mode 100644 packages/contracts/src/mcpServers.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index daf06bc41a1d3..87fe37d44bf23 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -52,6 +52,7 @@ import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; +import { SettingsMcpRouteScreen } from "./features/settings/SettingsMcpRouteScreen"; import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; @@ -208,6 +209,13 @@ const SettingsContentStack = createNativeStackNavigator({ title: "Client Storage", }, }), + SettingsMcp: createNativeStackScreen({ + screen: SettingsMcpRouteScreen, + linking: "mcp", + options: { + title: "MCP Servers", + }, + }), SettingsUsage: createNativeStackScreen({ screen: UsageRouteScreen, linking: "usage", diff --git a/apps/mobile/src/features/settings/SettingsMcpRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsMcpRouteScreen.tsx new file mode 100644 index 0000000000000..80e2e0f27ff05 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsMcpRouteScreen.tsx @@ -0,0 +1,342 @@ +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import type { + EnvironmentId, + McpInstanceInventory, + McpMutationResult, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { useState } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { cn } from "../../lib/cn"; +import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { useEnvironments } from "../../state/environments"; +import { mcpEnvironment } from "../../state/mcp"; +import { useEnvironmentQuery } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { SettingsSection } from "./components/SettingsSection"; + +const PRIVATE_COMMAND_OPTIONS = { reportFailure: false, reportDefect: false } as const; +const ADD_JSON_PLACEHOLDER = '{"type":"http","url":"https://mcp.example.com/mcp"}'; + +/** + * One server, plus which accounts on this machine have it. Grouping this way + * is the point of the screen: with several signed-in accounts the question is + * never "what does this account have" but "which accounts are missing this". + */ +interface ServerRow { + readonly name: string; + readonly transport: string; + readonly target: string; + readonly presentIn: ReadonlyArray; +} + +function buildServerRows(instances: ReadonlyArray): ServerRow[] { + const rows = new Map(); + for (const instance of instances) { + for (const server of instance.servers) { + const existing = rows.get(server.name); + rows.set( + server.name, + existing + ? { ...existing, presentIn: [...existing.presentIn, instance.instanceId] } + : { + name: server.name, + transport: server.transport, + target: server.target, + presentIn: [instance.instanceId], + }, + ); + } + } + return [...rows.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +function outcomeSummary(outcomes: ReadonlyArray<{ ok: boolean; message: string }>): string { + const failures = outcomes.filter((outcome) => !outcome.ok); + if (failures.length === 0) return `Applied to ${outcomes.length} account(s).`; + return failures.map((failure) => failure.message || "Failed").join(" · "); +} + +function ActionButton(props: { + readonly label: string; + readonly disabled?: boolean; + readonly destructive?: boolean; + readonly onPress: () => void; +}) { + return ( + + + {props.label} + + + ); +} + +function AccountChips(props: { + readonly instances: ReadonlyArray; + readonly presentIn: ReadonlyArray; +}) { + return ( + + {props.instances.map((instance) => { + const present = props.presentIn.includes(instance.instanceId); + return ( + + + {instance.displayName} + + + ); + })} + + ); +} + +/** + * Mobile has no primary environment, so each connected machine gets its own + * block. Accounts and their config directories belong to one machine and never + * span them. + */ +function EnvironmentMcpSection(props: { + readonly environmentId: EnvironmentId; + readonly label: string; +}) { + const { data, error, isPending, refresh } = useEnvironmentQuery( + mcpEnvironment.inventory({ environmentId: props.environmentId, input: {} }), + ); + const addServer = useAtomCommand(mcpEnvironment.add, PRIVATE_COMMAND_OPTIONS); + const copyServer = useAtomCommand(mcpEnvironment.copy, PRIVATE_COMMAND_OPTIONS); + const removeServer = useAtomCommand(mcpEnvironment.remove, PRIVATE_COMMAND_OPTIONS); + + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + const [name, setName] = useState(""); + const [json, setJson] = useState(""); + + const instances = data?.instances ?? []; + const rows = buildServerRows(instances); + + // Every mutation ends with a refresh: the write goes through the Claude CLI, + // so the config on disk is the only thing that knows what actually landed. + const runMutation = async ( + operation: () => Promise>, + ) => { + setBusy(true); + setStatus(null); + try { + const result = await operation(); + setStatus( + result._tag === "Success" + ? outcomeSummary(result.value.outcomes) + : "Failed. Check that this account's Claude binary path is correct.", + ); + refresh(); + } finally { + setBusy(false); + } + }; + + if (!isPending && instances.length === 0 && error === null) { + return ( + + + + No Claude accounts are configured on this machine. + + + + ); + } + + return ( + + + {error ? ( + + {error} + + ) : null} + {status ? ( + + {status} + + ) : null} + {rows.map((row) => { + const missing = instances.filter( + (instance) => !row.presentIn.includes(instance.instanceId), + ); + return ( + + {row.name} + + {row.transport} · {row.target} + + + + {missing.length > 0 ? ( + + void runMutation(() => + copyServer({ + environmentId: props.environmentId, + input: { + fromInstanceId: row.presentIn[0]!, + toInstanceIds: missing.map((instance) => instance.instanceId), + name: row.name, + }, + }), + ) + } + /> + ) : null} + + void runMutation(() => + removeServer({ + environmentId: props.environmentId, + input: { instanceIds: row.presentIn, name: row.name }, + }), + ) + } + /> + + + ); + })} + + + + + + + + Applied to every Claude account on this machine. + + + void runMutation(async () => { + const result = await addServer({ + environmentId: props.environmentId, + input: { + instanceIds: instances.map((instance) => instance.instanceId), + name: name.trim(), + json: json.trim(), + }, + }); + setName(""); + setJson(""); + return result; + }) + } + /> + + + + + {instances.map((instance) => ( + void tryCopyTextWithHaptic(instance.cliPrefix, { target: "command" })} + className="flex-row items-center gap-3 p-4 active:opacity-70" + > + + {instance.displayName} + + {instance.cliPrefix} + + {instance.requiredEnvNames.length > 0 ? ( + + Also needs {instance.requiredEnvNames.join(", ")} in your shell. + + ) : null} + + + + ))} + + + ); +} + +export function SettingsMcpRouteScreen() { + const insets = useSafeAreaInsets(); + const { environments } = useEnvironments(); + + return ( + + + {environments.length === 0 ? ( + + Connect to a machine to manage its MCP servers. + + ) : null} + {environments.map((environment) => ( + + ))} + + Typing /mcp in a conversation does nothing. It is a command the Claude terminal handles + itself, and conversations run Claude without a terminal attached. + + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 1812f4072a28c..e3447bab2be1c 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -572,6 +572,7 @@ function GeneralSettingsSection() { return ( + 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 6dd6a93678916..fad5cedd351f2 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -4,6 +4,7 @@ export type SettingsSheetTarget = | "SettingsAppearance" | "SettingsProjectGrouping" | "SettingsClientStorage" + | "SettingsMcp" | "SettingsVoice" | "SettingsUsage"; diff --git a/apps/mobile/src/state/mcp.ts b/apps/mobile/src/state/mcp.ts new file mode 100644 index 0000000000000..b518a66224d6c --- /dev/null +++ b/apps/mobile/src/state/mcp.ts @@ -0,0 +1,6 @@ +import { createMcpEnvironmentAtoms } from "@t3tools/client-runtime/state/mcp"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +/** MCP server management, scoped to the environment that owns the CLI configs. */ +export const mcpEnvironment = createMcpEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 79b56bbdaf0f4..94e2683faac2b 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -147,6 +147,11 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope, [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope, [WS_METHODS.subscribeBackgroundPolicy]: AuthOrchestrationReadScope, + // PseudoCode fork additions. Kept last so upstream merges land above them. + [WS_METHODS.mcpList]: AuthOrchestrationReadScope, + [WS_METHODS.mcpAdd]: AuthOrchestrationOperateScope, + [WS_METHODS.mcpRemove]: AuthOrchestrationOperateScope, + [WS_METHODS.mcpCopy]: AuthOrchestrationOperateScope, } as const satisfies Readonly>; export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope { diff --git a/apps/server/src/mcp/ProviderMcpServers.test.ts b/apps/server/src/mcp/ProviderMcpServers.test.ts new file mode 100644 index 0000000000000..ad9e3cee6416b --- /dev/null +++ b/apps/server/src/mcp/ProviderMcpServers.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { toServerEntry } from "./ProviderMcpServers.ts"; + +describe("toServerEntry", () => { + it("keeps credential key names and drops their values", () => { + const entry = toServerEntry("uxpilot", { + command: "npx", + args: ["-y", "mcp-remote", "https://mcp.example.com/mcp"], + env: { AUTH_HEADER: "Bearer ep_super_secret" }, + }); + + expect(entry.envKeys).toEqual(["AUTH_HEADER"]); + expect(JSON.stringify(entry)).not.toContain("ep_super_secret"); + }); + + it("drops header values on remote servers", () => { + const entry = toServerEntry("sentry", { + type: "http", + url: "https://mcp.sentry.dev/mcp", + headers: { Authorization: "Bearer token-abc" }, + }); + + expect(entry.headerKeys).toEqual(["Authorization"]); + expect(entry.target).toBe("https://mcp.sentry.dev/mcp"); + expect(JSON.stringify(entry)).not.toContain("token-abc"); + }); + + it("infers stdio from a command and http from a url when type is absent", () => { + expect(toServerEntry("a", { command: "uvx", args: ["thing"] }).transport).toBe("stdio"); + expect(toServerEntry("b", { url: "https://example.com" }).transport).toBe("http"); + expect(toServerEntry("c", {}).transport).toBe("unknown"); + }); + + it("renders a stdio command line as the target", () => { + expect(toServerEntry("a", { command: "uvx", args: ["--from", "pkg", "run"] }).target).toBe( + "uvx --from pkg run", + ); + }); +}); diff --git a/apps/server/src/mcp/ProviderMcpServers.ts b/apps/server/src/mcp/ProviderMcpServers.ts new file mode 100644 index 0000000000000..b30e9286449ed --- /dev/null +++ b/apps/server/src/mcp/ProviderMcpServers.ts @@ -0,0 +1,332 @@ +/** + * MCP server management, per Claude provider instance. + * + * Every instance keeps its own `CLAUDE_CONFIG_DIR`, so a server added from a + * plain shell only ever reaches whichever account owns `~/.claude`. This + * module resolves each instance's directory and runs `claude mcp` against it, + * which is why a user with five signed-in accounts can configure all five from + * one screen. + * + * T3 Code owns none of Claude Code's configuration semantics. Reads lift the + * `mcpServers` object off `/.claude.json` and stop there; writes shell + * out to `claude mcp add-json` and `claude mcp remove`, so the CLI keeps + * deciding what a valid entry is and nothing here has to track its rules. + * + * @module mcp/ProviderMcpServers + */ +import { + ClaudeSettings, + McpCliUnavailableError, + McpInstanceNotFoundError, + McpServerNotFoundError, + type McpInstanceInventory, + type McpMutationOutcome, + type McpServerEntry, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { ServerConfig } from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; +import { resolveProviderCredentialHome } from "../provider/providerCredentialHome.ts"; +import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; + +// The Claude driver registers itself as `claudeAgent`; `claude` is the CLI +// name, not the driver kind. See Drivers/ClaudeDriver.ts. +const CLAUDE_DRIVER_KIND = "claudeAgent"; +/** Decoder for the JSON blobs this module reads out of `.claude.json`. */ +const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); +const decodeJson = Schema.decodeUnknownEffect(UnknownFromJsonString); +const encodeJson = Schema.encodeUnknownEffect(UnknownFromJsonString); +const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); + +/** + * `claude mcp` writes and reads a small JSON file. Two seconds is generous for + * that and short enough that one wedged instance cannot stall the whole page. + */ +const CLI_TIMEOUT_MS = 20_000; + +interface ResolvedInstance { + readonly instanceId: ProviderInstanceId; + readonly displayName: string; + readonly binaryPath: string; + /** Empty when the instance uses Claude's own default home. */ + readonly configDir: string; + /** Absolute directory holding `.claude.json`, default home included. */ + readonly resolvedHome: string; + /** + * The instance's own environment merged over the server's. Accounts signed + * in through `CLAUDE_CODE_OAUTH_TOKEN` keep their credential here rather + * than on disk, so a CLI run without it is a different, signed-out account. + */ + readonly environment: NodeJS.ProcessEnv; + /** Names of the variables this instance adds, for the UI to explain. */ + readonly requiredEnvNames: ReadonlyArray; +} + +/** + * Shell prefix a user can paste to reach this instance from their own + * terminal. Shown in the UI so the OAuth flows that only the interactive CLI + * can run stay reachable without guessing at paths. + */ +function cliPrefixFor(instance: ResolvedInstance): string { + const binary = instance.binaryPath.trim() || "claude"; + if (instance.configDir.length === 0) return binary; + return `CLAUDE_CONFIG_DIR=${instance.configDir} ${binary}`; +} + +const resolveClaudeInstances = Effect.fn("ProviderMcpServers.resolveClaudeInstances")(function* () { + const settings = yield* ServerSettingsService; + const { providerHomesDir } = yield* ServerConfig; + const current = yield* settings.getSettings; + + const resolved: Array = []; + for (const [rawInstanceId, entry] of Object.entries(current.providerInstances)) { + if (entry.driver !== CLAUDE_DRIVER_KIND) continue; + const instanceId = rawInstanceId as ProviderInstanceId; + const config = decodeClaudeSettings(entry.config ?? {}); + const configDir = yield* resolveProviderCredentialHome({ + driverKind: entry.driver, + instanceId, + configuredPath: config.homePath, + providerHomesDir, + }); + const resolvedHome = yield* resolveClaudeHomePath({ homePath: configDir }); + resolved.push({ + instanceId, + displayName: entry.displayName?.trim() || rawInstanceId, + binaryPath: config.binaryPath, + configDir, + resolvedHome, + environment: mergeProviderInstanceEnvironment(entry.environment), + requiredEnvNames: (entry.environment ?? []).map((variable) => variable.name), + }); + } + + return resolved.sort((left, right) => left.displayName.localeCompare(right.displayName)); +}); + +const findInstance = Effect.fn("ProviderMcpServers.findInstance")(function* ( + instanceId: ProviderInstanceId, +) { + const instances = yield* resolveClaudeInstances(); + const match = instances.find((instance) => instance.instanceId === instanceId); + if (!match) return yield* new McpInstanceNotFoundError({ instanceId }); + return match; +}); + +/** + * Reduce a raw config entry to what the UI needs. Env and header *values* are + * dropped here rather than in the UI: `.claude.json` routinely holds bearer + * tokens, and this payload crosses the websocket to every paired browser and + * phone. + */ +export function toServerEntry(name: string, raw: unknown): McpServerEntry { + const record = typeof raw === "object" && raw !== null ? (raw as Record) : {}; + const declaredType = typeof record.type === "string" ? record.type : undefined; + const url = typeof record.url === "string" ? record.url : undefined; + const command = typeof record.command === "string" ? record.command : undefined; + const args = Array.isArray(record.args) ? record.args.filter((a) => typeof a === "string") : []; + const env = + typeof record.env === "object" && record.env !== null ? (record.env as object) : undefined; + const headers = + typeof record.headers === "object" && record.headers !== null + ? (record.headers as object) + : undefined; + + return { + name, + transport: declaredType ?? (url ? "http" : command ? "stdio" : "unknown"), + target: url ?? [command ?? "", ...args].join(" ").trim(), + envKeys: env ? Object.keys(env) : [], + headerKeys: headers ? Object.keys(headers) : [], + }; +} + +const readInstanceInventory = Effect.fn("ProviderMcpServers.readInstanceInventory")(function* ( + instance: ResolvedInstance, +): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configPath = path.join(instance.resolvedHome, ".claude.json"); + + const base = { + instanceId: instance.instanceId, + displayName: instance.displayName, + configDir: instance.configDir, + cliPrefix: cliPrefixFor(instance), + requiredEnvNames: instance.requiredEnvNames, + } as const; + + const contents = yield* fileSystem.readFileString(configPath).pipe(Effect.option); + // A missing file is the normal state for a freshly provisioned instance, not + // an error worth surfacing: it simply has no servers yet. + if (contents._tag === "None") return { ...base, servers: [] }; + + const parsed = yield* decodeJson(contents.value).pipe(Effect.option); + if (parsed._tag === "None") { + return { ...base, servers: [], readError: `Could not parse ${configPath}` }; + } + + const root = + typeof parsed.value === "object" && parsed.value !== null + ? (parsed.value as Record) + : {}; + const servers = + typeof root.mcpServers === "object" && root.mcpServers !== null + ? (root.mcpServers as Record) + : {}; + + return { + ...base, + servers: Object.entries(servers) + .map(([name, raw]) => toServerEntry(name, raw)) + .sort((left, right) => left.name.localeCompare(right.name)), + }; +}); + +export const listMcpServers = Effect.fn("ProviderMcpServers.list")(function* () { + const instances = yield* resolveClaudeInstances(); + const inventories: Array = []; + for (const instance of instances) { + inventories.push(yield* readInstanceInventory(instance)); + } + return { instances: inventories }; +}); + +const runClaudeMcp = Effect.fn("ProviderMcpServers.runClaudeMcp")(function* (input: { + readonly instance: ResolvedInstance; + readonly args: ReadonlyArray; +}) { + const runner = yield* ProcessRunner.ProcessRunner; + const binaryPath = input.instance.binaryPath.trim() || "claude"; + const result = yield* runner + .run({ + command: binaryPath, + args: ["mcp", ...input.args], + timeout: CLI_TIMEOUT_MS, + env: { + ...input.instance.environment, + ...(input.instance.configDir.length > 0 + ? { CLAUDE_CONFIG_DIR: input.instance.configDir } + : {}), + }, + outputMode: "truncate", + }) + .pipe( + Effect.mapError( + () => new McpCliUnavailableError({ instanceId: input.instance.instanceId, binaryPath }), + ), + ); + + return { + instanceId: input.instance.instanceId, + ok: result.code === 0, + // Claude reports "already exists" and validation failures on stderr; the + // user needs the real text or the row just says "failed". + message: + result.code === 0 ? "" : (result.stderr.trim() || result.stdout.trim()).slice(0, 2_000), + } satisfies McpMutationOutcome; +}); + +/** + * Add one server definition to several instances. Instances are applied in + * sequence rather than in parallel: `claude mcp` rewrites a whole JSON file, + * and two instances that share a home would race each other. + */ +export const addMcpServer = Effect.fn("ProviderMcpServers.add")(function* (input: { + readonly instanceIds: ReadonlyArray; + readonly name: string; + readonly json: string; +}) { + const outcomes: Array = []; + for (const instanceId of input.instanceIds) { + const instance = yield* findInstance(instanceId); + outcomes.push( + yield* runClaudeMcp({ + instance, + args: ["add-json", "--scope", "user", input.name, input.json], + }), + ); + } + return { outcomes }; +}); + +/** + * Read one server's full definition, secrets included. Only ever consumed by + * `copyMcpServer`, which hands it straight back to the CLI on another + * instance, so the values never reach a client. + */ +const readRawServerDefinition = Effect.fn("ProviderMcpServers.readRawServerDefinition")(function* ( + instance: ResolvedInstance, + name: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configPath = path.join(instance.resolvedHome, ".claude.json"); + const contents = yield* fileSystem.readFileString(configPath).pipe(Effect.option); + if (contents._tag === "None") { + return yield* new McpServerNotFoundError({ instanceId: instance.instanceId, name }); + } + const parsed = yield* decodeJson(contents.value).pipe(Effect.option); + const root = + parsed._tag === "Some" && typeof parsed.value === "object" && parsed.value !== null + ? (parsed.value as Record) + : {}; + const servers = + typeof root.mcpServers === "object" && root.mcpServers !== null + ? (root.mcpServers as Record) + : {}; + const definition = servers[name]; + if (definition === undefined) { + return yield* new McpServerNotFoundError({ instanceId: instance.instanceId, name }); + } + return yield* encodeJson(definition).pipe(Effect.orElseSucceed(() => "")); +}); + +/** + * Replicate a server that one instance already has onto others. This is the + * path that actually matters for a user with several signed-in accounts: they + * configured a server once, in a terminal or in Claude Desktop, and want the + * other accounts to match without retyping credentials. + */ +export const copyMcpServer = Effect.fn("ProviderMcpServers.copy")(function* (input: { + readonly fromInstanceId: ProviderInstanceId; + readonly toInstanceIds: ReadonlyArray; + readonly name: string; +}) { + const source = yield* findInstance(input.fromInstanceId); + const json = yield* readRawServerDefinition(source, input.name); + + const outcomes: Array = []; + for (const instanceId of input.toInstanceIds) { + if (instanceId === input.fromInstanceId) continue; + const instance = yield* findInstance(instanceId); + outcomes.push( + yield* runClaudeMcp({ + instance, + args: ["add-json", "--scope", "user", input.name, json], + }), + ); + } + return { outcomes }; +}); + +export const removeMcpServer = Effect.fn("ProviderMcpServers.remove")(function* (input: { + readonly instanceIds: ReadonlyArray; + readonly name: string; +}) { + const outcomes: Array = []; + for (const instanceId of input.instanceIds) { + const instance = yield* findInstance(instanceId); + outcomes.push( + yield* runClaudeMcp({ instance, args: ["remove", "--scope", "user", input.name] }), + ); + } + return { outcomes }; +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e3792de0a59eb..015f922a7e151 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -105,6 +105,8 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; +// PseudoCode fork addition. +import * as ProviderMcpServers from "./mcp/ProviderMcpServers.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; @@ -2467,6 +2469,23 @@ const makeWsRpcLayer = ( review.getDiffFileContents(input), { "rpc.aggregate": "review" }, ), + // PseudoCode fork addition: per-instance MCP server management. + [WS_METHODS.mcpList]: (_input) => + observeRpcEffect(WS_METHODS.mcpList, ProviderMcpServers.listMcpServers(), { + "rpc.aggregate": "mcp", + }), + [WS_METHODS.mcpAdd]: (input) => + observeRpcEffect(WS_METHODS.mcpAdd, ProviderMcpServers.addMcpServer(input), { + "rpc.aggregate": "mcp", + }), + [WS_METHODS.mcpRemove]: (input) => + observeRpcEffect(WS_METHODS.mcpRemove, ProviderMcpServers.removeMcpServer(input), { + "rpc.aggregate": "mcp", + }), + [WS_METHODS.mcpCopy]: (input) => + observeRpcEffect(WS_METHODS.mcpCopy, ProviderMcpServers.copyMcpServer(input), { + "rpc.aggregate": "mcp", + }), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { "rpc.aggregate": "terminal", diff --git a/apps/web/src/components/settings/McpSettings.tsx b/apps/web/src/components/settings/McpSettings.tsx new file mode 100644 index 0000000000000..214255242b87e --- /dev/null +++ b/apps/web/src/components/settings/McpSettings.tsx @@ -0,0 +1,343 @@ +import { CopyIcon, PlusIcon, RefreshCwIcon, TrashIcon } from "lucide-react"; +import type { + McpInstanceInventory, + McpMutationResult, + ProviderInstanceId, +} from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { useCallback, useMemo, useState } from "react"; + +import { cn } from "../../lib/utils"; +import { mcpEnvironment } from "../../state/mcp"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useEnvironmentQuery } from "../../state/query"; +import { usePrimaryEnvironment } from "../../state/environments"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Textarea } from "../ui/textarea"; +import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; + +const ADD_JSON_PLACEHOLDER = `{"type":"http","url":"https://mcp.example.com/mcp"}`; + +/** + * One server, plus which instances currently have it. Grouping this way is the + * point of the page: with several signed-in accounts the useful question is + * never "what does this account have" but "which accounts are missing this". + */ +interface ServerRow { + readonly name: string; + readonly transport: string; + readonly target: string; + readonly envKeys: ReadonlyArray; + readonly headerKeys: ReadonlyArray; + readonly presentIn: ReadonlyArray; +} + +function buildServerRows(instances: ReadonlyArray): ServerRow[] { + const rows = new Map(); + for (const instance of instances) { + for (const server of instance.servers) { + const existing = rows.get(server.name); + if (existing) { + rows.set(server.name, { + ...existing, + presentIn: [...existing.presentIn, instance.instanceId], + }); + continue; + } + rows.set(server.name, { + name: server.name, + transport: server.transport, + target: server.target, + envKeys: server.envKeys, + headerKeys: server.headerKeys, + presentIn: [instance.instanceId], + }); + } + } + return [...rows.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +function outcomeSummary(outcomes: ReadonlyArray<{ ok: boolean; message: string }>): string { + const failures = outcomes.filter((outcome) => !outcome.ok); + if (failures.length === 0) return `Applied to ${outcomes.length} instance(s).`; + return failures.map((failure) => failure.message || "Failed").join(" · "); +} + +function InstanceChips({ + instances, + presentIn, +}: { + instances: ReadonlyArray; + presentIn: ReadonlyArray; +}) { + return ( +
+ {instances.map((instance) => { + const present = presentIn.includes(instance.instanceId); + return ( + + {instance.displayName} + + ); + })} +
+ ); +} + +export function McpSettingsPanel() { + const primaryEnvironment = usePrimaryEnvironment(); + const environmentId = primaryEnvironment?.environmentId ?? null; + const { data, error, isPending, refresh } = useEnvironmentQuery( + environmentId === null ? null : mcpEnvironment.inventory({ environmentId, input: {} }), + ); + const addServer = useAtomCommand(mcpEnvironment.add, { reportFailure: false }); + const copyServer = useAtomCommand(mcpEnvironment.copy, { reportFailure: false }); + const removeServer = useAtomCommand(mcpEnvironment.remove, { reportFailure: false }); + const { copyToClipboard } = useCopyToClipboard(); + + const instances = useMemo(() => data?.instances ?? [], [data]); + const rows = useMemo(() => buildServerRows(instances), [instances]); + + const [name, setName] = useState(""); + const [json, setJson] = useState(""); + const [excluded, setExcluded] = useState>(new Set()); + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + + const selectedInstanceIds = useMemo( + () => + instances + .map((instance) => instance.instanceId) + .filter((instanceId) => !excluded.has(instanceId)), + [excluded, instances], + ); + + const toggleInstance = useCallback((instanceId: string) => { + setExcluded((current) => { + const next = new Set(current); + if (next.has(instanceId)) next.delete(instanceId); + else next.add(instanceId); + return next; + }); + }, []); + + // Every mutation ends with a refresh: the write goes through the Claude CLI, + // so the config on disk is the only thing that knows what actually landed. + const runMutation = async ( + operation: () => Promise>, + ) => { + setBusy(true); + setStatus(null); + try { + const result = await operation(); + setStatus( + result._tag === "Success" + ? outcomeSummary(result.value.outcomes) + : "Failed. Check that this instance's Claude binary path is correct.", + ); + refresh(); + } finally { + setBusy(false); + } + }; + + const canSubmit = + environmentId !== null && + name.trim().length > 0 && + json.trim().length > 0 && + selectedInstanceIds.length > 0 && + !busy; + + return ( + + + + Refresh + + } + > + {error ?

{error}

: null} + {status ?

{status}

: null} + {instances.length === 0 && !isPending ? ( +

+ No Claude provider instances are configured on this environment. +

+ ) : null} + +
+ {rows.map((row) => { + const missing = instances.filter( + (instance) => !row.presentIn.includes(instance.instanceId), + ); + return ( +
+
+
+

{row.name}

+

+ {row.transport} · {row.target} +

+
+
+ {missing.length > 0 ? ( + + ) : null} + +
+
+ + {row.envKeys.length > 0 || row.headerKeys.length > 0 ? ( +

+ Carries credentials: {[...row.envKeys, ...row.headerKeys].join(", ")} +

+ ) : null} +
+ ); + })} +
+
+ + +
+
+ + setName(event.target.value)} + /> +
+
+ +