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..fd71e769ac2c6 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsMcpRouteScreen.tsx @@ -0,0 +1,354 @@ +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"}'; + +const DRIVER_LABELS: Readonly> = { + claudeAgent: "Claude", + codex: "Codex", +}; + +function driverLabel(driver: string): string { + return DRIVER_LABELS[driver] ?? driver; +} + +/** + * 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 ( + + + {driverLabel(instance.driver)} · {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 or Codex 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 and Codex account on this machine. Codex gets it translated into + its own format. + + + 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" + > + + + {driverLabel(instance.driver)} · {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..4e34b7d1d640a --- /dev/null +++ b/apps/server/src/mcp/ProviderMcpServers.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; + +import { + addArgsFor, + canonicalToEntry, + codexEntryToCanonical, + toCanonicalServer, +} from "./ProviderMcpServers.ts"; + +const claudeInstance = { + instanceId: ProviderInstanceId.make("claude_aws"), + driver: "claudeAgent", + displayName: "Claude", + binaryPath: "claude", + configDir: "/homes/claude_aws", + resolvedHome: "/homes/claude_aws", + environment: {}, + requiredEnvNames: [], +} as const; + +const codexInstance = { ...claudeInstance, driver: "codex", displayName: "Codex" } as const; + +describe("inventory entries", () => { + it("keeps credential key names and drops their values", () => { + const entry = canonicalToEntry( + "uxpilot", + toCanonicalServer({ + command: "npx", + args: ["-y", "mcp-remote"], + 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 = canonicalToEntry( + "sentry", + toCanonicalServer({ + 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(canonicalToEntry("a", toCanonicalServer({ command: "uvx" })).transport).toBe("stdio"); + expect(canonicalToEntry("b", toCanonicalServer({ url: "https://x" })).transport).toBe("http"); + expect(canonicalToEntry("c", toCanonicalServer({})).transport).toBe("unknown"); + }); +}); + +describe("codex entries", () => { + it("reads a streamable http server", () => { + const server = codexEntryToCanonical({ + name: "posthog", + transport: { type: "streamable_http", url: "https://mcp.posthog.com/mcp" }, + }); + + expect(server.url).toBe("https://mcp.posthog.com/mcp"); + expect(canonicalToEntry("posthog", server).transport).toBe("http"); + }); + + it("reads a stdio server with its env", () => { + const server = codexEntryToCanonical({ + name: "xcodebuildmcp", + transport: { + type: "stdio", + command: "npx", + args: ["--yes", "xcodebuildmcp@2.6.2", "mcp"], + env: { XCODEBUILDMCP_ENABLED_WORKFLOWS: "simulator" }, + }, + }); + + expect(canonicalToEntry("xcodebuildmcp", server).target).toBe( + "npx --yes xcodebuildmcp@2.6.2 mcp", + ); + expect(server.env.XCODEBUILDMCP_ENABLED_WORKFLOWS).toBe("simulator"); + }); +}); + +describe("addArgsFor", () => { + const raw = '{"type":"http","url":"https://mcp.example.com/mcp"}'; + + it("hands Claude the definition untouched", () => { + const plan = addArgsFor(claudeInstance, "example", toCanonicalServer(JSON.parse(raw)), raw); + expect(plan).toEqual({ args: ["add-json", "--scope", "user", "example", raw] }); + }); + + it("translates a remote server into codex flags", () => { + const plan = addArgsFor(codexInstance, "example", toCanonicalServer(JSON.parse(raw)), raw); + expect(plan).toEqual({ + args: ["add", "example", "--url", "https://mcp.example.com/mcp"], + }); + }); + + it("translates a stdio server, carrying its env through", () => { + const server = toCanonicalServer({ + command: "npx", + args: ["-y", "thing"], + env: { KEY: "value" }, + }); + const plan = addArgsFor(codexInstance, "thing", server, "{}"); + expect(plan).toEqual({ + args: ["add", "thing", "--env", "KEY=value", "--", "npx", "-y", "thing"], + }); + }); + + it("refuses a remote server whose headers codex cannot store", () => { + const server = toCanonicalServer({ + type: "http", + url: "https://mcp.example.com/mcp", + headers: { Authorization: "Bearer secret" }, + }); + const plan = addArgsFor(codexInstance, "example", server, "{}"); + expect(plan).toHaveProperty("reason"); + expect(JSON.stringify(plan)).not.toContain("secret"); + }); + + it("refuses a definition with neither a url nor a command", () => { + expect(addArgsFor(codexInstance, "broken", toCanonicalServer({}), "{}")).toHaveProperty( + "reason", + ); + }); +}); diff --git a/apps/server/src/mcp/ProviderMcpServers.ts b/apps/server/src/mcp/ProviderMcpServers.ts new file mode 100644 index 0000000000000..01845e5a69945 --- /dev/null +++ b/apps/server/src/mcp/ProviderMcpServers.ts @@ -0,0 +1,602 @@ +/** + * 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, + CodexSettings, + McpCliUnavailableError, + McpInstanceNotFoundError, + McpServerNotFoundError, + type McpInstanceInventory, + type McpInventory, + type McpMutationResult, + type McpMutationOutcome, + type McpServerEntry, + type ProviderInstanceId, + type ServerSettingsError, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +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 { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.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. +export class ProviderMcpServers extends Context.Service< + ProviderMcpServers, + { + readonly list: () => Effect.Effect; + readonly add: (input: { + readonly instanceIds: ReadonlyArray; + readonly name: string; + readonly json: string; + }) => Effect.Effect< + McpMutationResult, + McpCliUnavailableError | McpInstanceNotFoundError | ServerSettingsError + >; + readonly remove: (input: { + readonly instanceIds: ReadonlyArray; + readonly name: string; + }) => Effect.Effect< + McpMutationResult, + McpCliUnavailableError | McpInstanceNotFoundError | ServerSettingsError + >; + readonly copy: (input: { + readonly fromInstanceId: ProviderInstanceId; + readonly toInstanceIds: ReadonlyArray; + readonly name: string; + }) => Effect.Effect< + McpMutationResult, + | McpCliUnavailableError + | McpInstanceNotFoundError + | McpServerNotFoundError + | ServerSettingsError + >; + } +>()("t3/mcp/ProviderMcpServers") {} + +const CLAUDE_DRIVER_KIND = "claudeAgent"; +const CODEX_DRIVER_KIND = "codex"; +/** 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); +const decodeCodexSettings = Schema.decodeSync(CodexSettings); + +/** + * `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 driver: typeof CLAUDE_DRIVER_KIND | typeof CODEX_DRIVER_KIND; + readonly displayName: string; + readonly binaryPath: string; + /** Empty when the instance uses the CLI's own default home. */ + readonly configDir: string; + /** Absolute config directory, the CLI's 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 isClaude = instance.driver === CLAUDE_DRIVER_KIND; + const binary = instance.binaryPath.trim() || (isClaude ? "claude" : "codex"); + if (instance.configDir.length === 0) return binary; + const variable = isClaude ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; + return `${variable}=${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, + driver: CLAUDE_DRIVER_KIND, + displayName: entry.displayName?.trim() || rawInstanceId, + binaryPath: config.binaryPath, + configDir, + resolvedHome, + environment: mergeProviderInstanceEnvironment(entry.environment), + requiredEnvNames: (entry.environment ?? []).map((variable) => variable.name), + }); + } + + return resolved; +}); + +const resolveCodexInstances = Effect.fn("ProviderMcpServers.resolveCodexInstances")(function* () { + const settings = yield* ServerSettingsService; + const current = yield* settings.getSettings; + + const resolved: Array = []; + for (const [rawInstanceId, entry] of Object.entries(current.providerInstances)) { + if (entry.driver !== CODEX_DRIVER_KIND) continue; + const config = decodeCodexSettings(entry.config ?? {}); + const layout = yield* resolveCodexHomeLayout(config); + // Codex keeps auth.json private to a shadow home but symlinks config.toml + // back to the shared one, so a write through the shadow lands in the + // shared server list. That is the intent: sign-ins are per account, + // servers are not. + resolved.push({ + instanceId: rawInstanceId as ProviderInstanceId, + driver: CODEX_DRIVER_KIND, + displayName: entry.displayName?.trim() || rawInstanceId, + binaryPath: config.binaryPath, + configDir: layout.effectiveHomePath ?? "", + resolvedHome: layout.sharedHomePath, + environment: mergeProviderInstanceEnvironment(entry.environment), + requiredEnvNames: (entry.environment ?? []).map((variable) => variable.name), + }); + } + + return resolved; +}); + +const resolveInstances = Effect.fn("ProviderMcpServers.resolveInstances")(function* () { + const claude = yield* resolveClaudeInstances(); + const codex = yield* resolveCodexInstances(); + return [...claude, ...codex].sort( + (left, right) => + left.driver.localeCompare(right.driver) || left.displayName.localeCompare(right.displayName), + ); +}); + +const findInstance = Effect.fn("ProviderMcpServers.findInstance")(function* ( + instanceId: ProviderInstanceId, +) { + const instances = yield* resolveInstances(); + const match = instances.find((instance) => instance.instanceId === instanceId); + if (!match) return yield* new McpInstanceNotFoundError({ instanceId }); + return match; +}); + +/** + * The shape both CLIs are translated through. It is Claude Code's `.claude.json` + * entry, because that is the richest of the two and the form the add box takes, + * so a definition never has to be typed twice. + */ +interface CanonicalServer { + readonly type: string | undefined; + readonly url: string | undefined; + readonly command: string | undefined; + readonly args: ReadonlyArray; + readonly env: Readonly>; + readonly headers: Readonly>; +} + +function readStringRecord(value: unknown): Record { + if (typeof value !== "object" || value === null) return {}; + const result: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + if (typeof entry === "string") result[key] = entry; + } + return result; +} + +export function toCanonicalServer(raw: unknown): CanonicalServer { + const record = typeof raw === "object" && raw !== null ? (raw as Record) : {}; + return { + type: typeof record.type === "string" ? record.type : undefined, + url: typeof record.url === "string" ? record.url : undefined, + command: typeof record.command === "string" ? record.command : undefined, + args: Array.isArray(record.args) + ? record.args.filter((entry): entry is string => typeof entry === "string") + : [], + env: readStringRecord(record.env), + headers: readStringRecord(record.headers), + }; +} + +/** One entry of `codex mcp list --json`, rewritten into the canonical shape. */ +export function codexEntryToCanonical(raw: unknown): CanonicalServer { + const record = typeof raw === "object" && raw !== null ? (raw as Record) : {}; + const transport = + typeof record.transport === "object" && record.transport !== null + ? (record.transport as Record) + : {}; + const isStdio = transport.type === "stdio"; + return { + type: isStdio ? "stdio" : "http", + url: typeof transport.url === "string" ? transport.url : undefined, + command: typeof transport.command === "string" ? transport.command : undefined, + args: Array.isArray(transport.args) + ? transport.args.filter((entry): entry is string => typeof entry === "string") + : [], + env: readStringRecord(transport.env), + headers: readStringRecord(transport.http_headers), + }; +} + +export function canonicalToEntry(name: string, server: CanonicalServer): McpServerEntry { + return { + name, + transport: server.type ?? (server.url ? "http" : server.command ? "stdio" : "unknown"), + target: server.url ?? [server.command ?? "", ...server.args].join(" ").trim(), + envKeys: Object.keys(server.env), + headerKeys: Object.keys(server.headers), + }; +} + +/** + * Build the CLI arguments that install `server` under `name` on one account. + * Returns a reason string when the provider cannot express the definition, + * which is reported per account rather than silently dropping the part that + * does not fit. + */ +export function addArgsFor( + instance: ResolvedInstance, + name: string, + server: CanonicalServer, + rawJson: string, +): { readonly args: ReadonlyArray } | { readonly reason: string } { + if (instance.driver === CLAUDE_DRIVER_KIND) { + return { args: ["add-json", "--scope", "user", name, rawJson] }; + } + + if (server.command) { + const env = Object.entries(server.env).flatMap(([key, value]) => ["--env", `${key}=${value}`]); + return { args: ["add", name, ...env, "--", server.command, ...server.args] }; + } + + if (!server.url) { + return { reason: "Needs a url or a command." }; + } + + // `codex mcp add` exposes only --bearer-token-env-var for remote auth, so a + // definition carrying literal headers would install without its credentials. + // Refusing is better than a server that silently fails to authenticate. + if (Object.keys(server.headers).length > 0) { + return { reason: "Codex cannot store request headers; add it with a bearer token env var." }; + } + + return { args: ["add", name, "--url", server.url] }; +} + +function removeArgsFor(instance: ResolvedInstance, name: string): ReadonlyArray { + return instance.driver === CLAUDE_DRIVER_KIND + ? ["remove", "--scope", "user", name] + : ["remove", name]; +} + +const readClaudeInventory = Effect.fn("ProviderMcpServers.readClaudeInventory")(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, + driver: instance.driver, + 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]) => canonicalToEntry(name, toCanonicalServer(raw))) + .sort((left, right) => left.name.localeCompare(right.name)), + }; +}); + +/** + * Codex ships `mcp list --json`, so its inventory comes from the CLI rather + * than from parsing `config.toml`. That keeps TOML out of this module and + * means Codex decides what its own config means. + */ +const readCodexInventory = Effect.fn("ProviderMcpServers.readCodexInventory")(function* ( + instance: ResolvedInstance, +): Effect.fn.Return { + const base = { + instanceId: instance.instanceId, + driver: instance.driver, + displayName: instance.displayName, + configDir: instance.configDir, + cliPrefix: cliPrefixFor(instance), + requiredEnvNames: instance.requiredEnvNames, + } as const; + + const result = yield* runProviderMcp({ instance, args: ["list", "--json"] }).pipe(Effect.option); + if (result._tag === "None" || !result.value.outcome.ok) { + return { + ...base, + servers: [], + readError: + result._tag === "None" ? "Could not run the Codex CLI." : result.value.outcome.message, + }; + } + + const parsed = yield* decodeJson(result.value.stdout).pipe(Effect.option); + if (parsed._tag === "None" || !Array.isArray(parsed.value)) { + return { ...base, servers: [], readError: "Could not read the Codex server list." }; + } + + return { + ...base, + servers: parsed.value + .map((raw) => { + const record = + typeof raw === "object" && raw !== null ? (raw as Record) : {}; + const name = typeof record.name === "string" ? record.name : ""; + return canonicalToEntry(name, codexEntryToCanonical(record)); + }) + .filter((entry) => entry.name.length > 0) + .sort((left, right) => left.name.localeCompare(right.name)), + }; +}); + +const listMcpServers = Effect.fn("ProviderMcpServers.list")(function* () { + const instances = yield* resolveInstances(); + const inventories: Array = []; + for (const instance of instances) { + inventories.push( + yield* instance.driver === CLAUDE_DRIVER_KIND + ? readClaudeInventory(instance) + : readCodexInventory(instance), + ); + } + return { instances: inventories }; +}); + +const runProviderMcp = Effect.fn("ProviderMcpServers.runProviderMcp")(function* (input: { + readonly instance: ResolvedInstance; + readonly args: ReadonlyArray; +}) { + const runner = yield* ProcessRunner.ProcessRunner; + const isClaude = input.instance.driver === CLAUDE_DRIVER_KIND; + const binaryPath = input.instance.binaryPath.trim() || (isClaude ? "claude" : "codex"); + const homeVariable = isClaude ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; + const result = yield* runner + .run({ + command: binaryPath, + args: ["mcp", ...input.args], + timeout: CLI_TIMEOUT_MS, + env: { + ...input.instance.environment, + ...(input.instance.configDir.length > 0 + ? { [homeVariable]: input.instance.configDir } + : {}), + }, + outputMode: "truncate", + }) + .pipe( + Effect.mapError( + () => new McpCliUnavailableError({ instanceId: input.instance.instanceId, binaryPath }), + ), + ); + + return { + outcome: { + instanceId: input.instance.instanceId, + ok: result.code === 0, + // Both CLIs report "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, + /** Read commands (`mcp list --json`, `mcp get --json`) answer on stdout. */ + stdout: result.stdout, + }; +}); + +/** + * Install one definition across several accounts, translating it per provider. + * Accounts are applied in sequence rather than in parallel: each CLI rewrites + * a whole config file, and two accounts that share a home would race. + */ +const applyServerToInstances = Effect.fn("ProviderMcpServers.applyServerToInstances")( + function* (input: { + readonly instanceIds: ReadonlyArray; + readonly name: string; + readonly json: string; + }) { + const server = toCanonicalServer( + yield* decodeJson(input.json).pipe(Effect.orElseSucceed(() => ({}))), + ); + + const outcomes: Array = []; + for (const instanceId of input.instanceIds) { + const instance = yield* findInstance(instanceId); + const plan = addArgsFor(instance, input.name, server, input.json); + if ("reason" in plan) { + outcomes.push({ instanceId, ok: false, message: plan.reason }); + continue; + } + outcomes.push((yield* runProviderMcp({ instance, args: plan.args })).outcome); + } + return { outcomes }; + }, +); + +const addMcpServer = Effect.fn("ProviderMcpServers.add")(function* (input: { + readonly instanceIds: ReadonlyArray; + readonly name: string; + readonly json: string; +}) { + return yield* applyServerToInstances(input); +}); + +/** + * 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, +): Effect.fn.Return< + string, + McpServerNotFoundError | McpCliUnavailableError, + FileSystem.FileSystem | Path.Path | ProcessRunner.ProcessRunner +> { + if (instance.driver === CODEX_DRIVER_KIND) { + const result = yield* runProviderMcp({ instance, args: ["get", name, "--json"] }).pipe( + Effect.option, + ); + const parsed = + result._tag === "Some" && result.value.outcome.ok + ? yield* decodeJson(result.value.stdout).pipe(Effect.option) + : { _tag: "None" as const }; + if (parsed._tag === "None") { + return yield* new McpServerNotFoundError({ instanceId: instance.instanceId, name }); + } + return yield* encodeJson(codexEntryToCanonical(parsed.value)).pipe( + Effect.orElseSucceed(() => ""), + ); + } + + 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. + */ +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); + return yield* applyServerToInstances({ + instanceIds: input.toInstanceIds.filter((id) => id !== input.fromInstanceId), + name: input.name, + json, + }); +}); + +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* runProviderMcp({ instance, args: removeArgsFor(instance, input.name) })).outcome, + ); + } + return { outcomes }; +}); + +export const make = Effect.fn("ProviderMcpServers.make")(function* () { + // Captured once so the service's operations carry no requirements of their + // own. `ProcessRunner` in particular must stop here: leaking it upward puts + // it in the context of every RPC handler and, from there, the whole server. + const context = yield* Effect.context< + | FileSystem.FileSystem + | Path.Path + | ProcessRunner.ProcessRunner + | ServerConfig + | ServerSettingsService + >(); + + // Every operation re-reads settings. Accounts are added and signed out while + // the app runs, and this backs a settings page, not a hot path. + return ProviderMcpServers.of({ + list: () => listMcpServers().pipe(Effect.provide(context)), + add: (input) => addMcpServer(input).pipe(Effect.provide(context)), + remove: (input) => removeMcpServer(input).pipe(Effect.provide(context)), + copy: (input) => copyMcpServer(input).pipe(Effect.provide(context)), + }); +}); + +export const layer = Layer.effect(ProviderMcpServers, make()); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ac9c7d978266b..e0cb5fcf51ff9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -59,6 +59,8 @@ import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.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 * as PortScanner from "./preview/PortScanner.ts"; import * as ProcessRunner from "./processRunner.ts"; @@ -569,6 +571,8 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(PullRequestServiceLive), Layer.provide(IssueServiceLive), Layer.provide(PreviewAutomationBroker.layer), + // PseudoCode fork addition: shells out to `claude mcp`, so it owns its runner. + Layer.provide(ProviderMcpServers.layer.pipe(Layer.provide(ProcessRunner.layer))), Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(DesktopAppUpdateLayerLive))), Layer.provide(commandReadinessLayer), Layer.provide(browserApiCorsLayer), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e3792de0a59eb..9f2ae159d0dc1 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"; @@ -465,6 +467,7 @@ const makeWsRpcLayer = ( clientOrigin: OrchestrationClientOrigin, clientAnalyticsProps: Readonly>, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], + providerMcpServers: ProviderMcpServers.ProviderMcpServers["Service"], ) => WsRpcGroup.toLayer( Effect.gen(function* () { @@ -2467,6 +2470,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.list(), { + "rpc.aggregate": "mcp", + }), + [WS_METHODS.mcpAdd]: (input) => + observeRpcEffect(WS_METHODS.mcpAdd, providerMcpServers.add(input), { + "rpc.aggregate": "mcp", + }), + [WS_METHODS.mcpRemove]: (input) => + observeRpcEffect(WS_METHODS.mcpRemove, providerMcpServers.remove(input), { + "rpc.aggregate": "mcp", + }), + [WS_METHODS.mcpCopy]: (input) => + observeRpcEffect(WS_METHODS.mcpCopy, providerMcpServers.copy(input), { + "rpc.aggregate": "mcp", + }), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { "rpc.aggregate": "terminal", @@ -2763,6 +2783,7 @@ const makeWsRpcLayer = ( export const websocketRpcRouteLayer = Layer.unwrap( Effect.gen(function* () { const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const providerMcpServers = yield* ProviderMcpServers.ProviderMcpServers; const baseServerSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; @@ -2823,6 +2844,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( clientOrigin, clientAnalyticsProps, previewAutomationBroker, + providerMcpServers, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), diff --git a/apps/web/src/components/settings/McpSettings.tsx b/apps/web/src/components/settings/McpSettings.tsx new file mode 100644 index 0000000000000..6cbad5f9dc560 --- /dev/null +++ b/apps/web/src/components/settings/McpSettings.tsx @@ -0,0 +1,354 @@ +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"}`; + +const DRIVER_LABELS: Readonly> = { + claudeAgent: "Claude", + codex: "Codex", +}; + +function driverLabel(driver: string): string { + return DRIVER_LABELS[driver] ?? driver; +} + +/** + * 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 ( + + {driverLabel(instance.driver)} · {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 or Codex accounts 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)} + /> +
+
+ +