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..3309b5bfefe21 --- /dev/null +++ b/apps/server/src/mcp/ProviderMcpServers.ts @@ -0,0 +1,394 @@ +/** + * 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 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 { 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"; +/** 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)), + }; +}); + +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. + */ +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. + */ +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 }; +}); + +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 }; +}); + +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..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)} + /> +
+
+ +