diff --git a/apps/server/src/mcpServers/ClaudeMcpConfig.test.ts b/apps/server/src/mcpServers/ClaudeMcpConfig.test.ts new file mode 100644 index 00000000000..584da4b856a --- /dev/null +++ b/apps/server/src/mcpServers/ClaudeMcpConfig.test.ts @@ -0,0 +1,259 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { readClaudeMcpServers, resolveClaudeMcpConfigFilePath } from "./ClaudeMcpConfig.ts"; + +const writeClaudeConfig = Effect.fn(function* (homeDir: string, contents: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(homeDir, { recursive: true }); + yield* fs.writeFileString(path.join(homeDir, ".claude.json"), contents); +}); + +it.layer(NodeServices.layer)("readClaudeMcpServers", (it) => { + it.effect("reads declared servers from the instance config directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-mcp-" }); + yield* writeClaudeConfig( + tempDir, + `{ + "mcpServers": { + "codegraph": { "type": "stdio", "command": "codegraph", "args": ["serve", "--mcp"] }, + "remote": { "type": "http", "url": "https://example.com/mcp" } + } + }`, + ); + + const { definitions: servers } = yield* readClaudeMcpServers({ homePath: tempDir }, {}); + + assert.deepEqual( + servers.map((server) => server.name), + ["codegraph", "remote"], + ); + assert.deepEqual(servers[0]?.definition, { + type: "stdio", + command: "codegraph", + args: ["serve", "--mcp"], + }); + }).pipe(Effect.scoped), + ); + + it.effect("reports an incomplete read for a malformed config", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-mcp-" }); + const workspace = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspace, { recursive: true }); + + // An absent config is a complete read of "no servers"... + const missing = yield* readClaudeMcpServers( + { homePath: path.join(tempDir, "absent") }, + {}, + workspace, + ); + assert.deepEqual(missing.definitions, []); + assert.equal(missing.complete, true); + + // ...but a file that exists and cannot be parsed leaves the list unknown, + // which is what stops a caller from taking over MCP resolution. + yield* writeClaudeConfig(tempDir, "{ not json"); + const malformed = yield* readClaudeMcpServers({ homePath: tempDir }, {}, workspace); + assert.deepEqual(malformed.definitions, []); + assert.equal(malformed.complete, false); + assert.deepEqual(malformed.unreadablePaths, [path.join(tempDir, ".claude.json")]); + }).pipe(Effect.scoped), + ); + + it.effect("treats a config that is not a regular file as unreadable", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-mcp-" }); + const workspace = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspace, { recursive: true }); + // A directory where the config should be: Claude cannot read it either, + // so the server list is unknown rather than empty. + yield* fs.makeDirectory(path.join(tempDir, ".claude.json"), { recursive: true }); + + const read = yield* readClaudeMcpServers({ homePath: tempDir }, {}, workspace); + assert.equal(read.complete, false); + assert.deepEqual(read.unreadablePaths, [path.join(tempDir, ".claude.json")]); + }).pipe(Effect.scoped), + ); + + it.effect("names the workspace config when only it is malformed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-mcp-" }); + const workspace = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspace, { recursive: true }); + yield* writeClaudeConfig(tempDir, '{ "mcpServers": { "codegraph": { "command": "cg" } } }'); + yield* fs.writeFileString(path.join(workspace, ".mcp.json"), "{ not json"); + + const read = yield* readClaudeMcpServers({ homePath: tempDir }, {}, workspace); + assert.equal(read.complete, false); + // Blaming `.claude.json` here would send the reader to a file that parsed. + const resolvedWorkspace = yield* fs.realPath(workspace); + assert.deepEqual(read.unreadablePaths, [path.join(resolvedWorkspace, ".mcp.json")]); + }).pipe(Effect.scoped), + ); + + it.effect("reports an incomplete read when no workspace is supplied", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-mcp-" }); + yield* writeClaudeConfig(tempDir, '{ "mcpServers": { "codegraph": { "command": "cg" } } }'); + + // Without a cwd the `local` and `project` scopes cannot be read at all, + // so the user-scope list on its own is knowingly partial. + const read = yield* readClaudeMcpServers({ homePath: tempDir }, {}); + assert.deepEqual( + read.definitions.map((definition) => definition.name), + ["codegraph"], + ); + assert.equal(read.complete, false); + }).pipe(Effect.scoped), + ); + + it.effect("finds the local scope when the workspace is reached through a symlink", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-mcp-" }); + const realWorkspace = path.join(tempDir, "real-workspace"); + const linkedWorkspace = path.join(tempDir, "linked-workspace"); + yield* fs.makeDirectory(realWorkspace, { recursive: true }); + yield* fs.symlink(realWorkspace, linkedWorkspace); + + // Claude Code keys `projects` by the resolved real path, so a cwd that + // arrives through a symlink must still match. + const resolvedWorkspace = yield* fs.realPath(realWorkspace); + yield* writeClaudeConfig( + tempDir, + [ + "{", + ' "projects": {', + ` "${resolvedWorkspace.replaceAll("\\", "\\\\")}": {`, + ' "mcpServers": { "scratch": { "command": "scratch-server" } }', + " }", + " }", + "}", + ].join("\n"), + ); + + const { definitions } = yield* readClaudeMcpServers( + { homePath: tempDir }, + {}, + linkedWorkspace, + ); + + assert.deepEqual( + definitions.map((definition) => `${definition.name}:${definition.scope}`), + ["scratch:local"], + ); + }).pipe(Effect.scoped), + ); + + it.effect("falls back to CLAUDE_CONFIG_DIR when the instance sets no home", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-mcp-" }); + yield* writeClaudeConfig(tempDir, '{ "mcpServers": { "alpaca": { "command": "uvx" } } }'); + + const configPath = yield* resolveClaudeMcpConfigFilePath( + { homePath: "" }, + { CLAUDE_CONFIG_DIR: tempDir }, + ); + assert.equal(configPath, path.join(tempDir, ".claude.json")); + + const { definitions: servers } = yield* readClaudeMcpServers( + { homePath: "" }, + { CLAUDE_CONFIG_DIR: tempDir }, + ); + assert.deepEqual( + servers.map((server) => server.name), + ["alpaca"], + ); + }).pipe(Effect.scoped), + ); + + it.effect("resolves a relative CLAUDE_CONFIG_DIR against the workspace cwd", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-mcp-" }); + const workspace = path.join(tempDir, "workspace"); + yield* writeClaudeConfig( + path.join(workspace, "nested-home"), + '{ "mcpServers": { "codegraph": { "command": "codegraph" } } }', + ); + + const { definitions: servers } = yield* readClaudeMcpServers( + { homePath: "" }, + { CLAUDE_CONFIG_DIR: "nested-home" }, + workspace, + ); + + assert.deepEqual( + servers.map((server) => server.name), + ["codegraph"], + ); + }).pipe(Effect.scoped), + ); + + it.effect("adds local servers and only approved project servers", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-mcp-" }); + const workspace = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspace, { recursive: true }); + yield* fs.writeFileString( + path.join(workspace, ".mcp.json"), + [ + "{", + ' "mcpServers": {', + ' "approved": { "command": "approved-server" },', + ' "unapproved": { "command": "unapproved-server" },', + ' "rejected": { "command": "rejected-server" }', + " }", + "}", + ].join("\n"), + ); + yield* writeClaudeConfig( + tempDir, + [ + "{", + ' "mcpServers": { "codegraph": { "command": "codegraph" } },', + ' "projects": {', + ` "${workspace.replaceAll("\\", "\\\\")}": {`, + ' "mcpServers": { "scratch": { "command": "scratch-server" } },', + ' "enabledMcpjsonServers": ["approved"],', + ' "disabledMcpjsonServers": ["rejected"]', + " }", + " }", + "}", + ].join("\n"), + ); + + const { definitions: servers } = yield* readClaudeMcpServers( + { homePath: tempDir }, + {}, + workspace, + ); + + assert.deepEqual(servers.map((server) => `${server.name}:${server.scope}`).sort(), [ + "approved:project", + "codegraph:user", + "scratch:local", + ]); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/mcpServers/ClaudeMcpConfig.ts b/apps/server/src/mcpServers/ClaudeMcpConfig.ts new file mode 100644 index 00000000000..0795b703247 --- /dev/null +++ b/apps/server/src/mcpServers/ClaudeMcpConfig.ts @@ -0,0 +1,237 @@ +/** + * ClaudeMcpConfig — read the MCP servers Claude Code loads for a session: + * user scope and workspace `local` scope from `.claude.json`, plus approved + * `.mcp.json` project servers. + * + * Entries are already in the shape the Agent SDK's `mcpServers` option accepts. + * Reading the files directly (rather than shelling out to `claude mcp list`, + * which health-checks every server over the network) keeps both the settings + * inventory and session launch cheap. + * + * @module mcpServers/ClaudeMcpConfig + */ +import * as NodeOS from "node:os"; + +import type { ClaudeSettings } 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 { expandHomePath } from "../pathExpansion.ts"; + +const decodeJsonOption = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); + +/** + * Where Claude Code found the server: + * - `user` — `mcpServers` in `.claude.json`, available everywhere + * - `local` — `projects..mcpServers` in `.claude.json`, private to the + * workspace + * - `project` — `.mcp.json` in the workspace, shared with the repo and only + * loaded once approved + */ +export type ClaudeMcpServerScope = "user" | "project" | "local"; + +export interface ClaudeMcpServerDefinition { + readonly name: string; + readonly scope: ClaudeMcpServerScope; + /** Config file the entry was declared in. */ + readonly sourcePath: string; + /** Verbatim config entry, already SDK-shaped. */ + readonly definition: Record; +} + +export interface ClaudeMcpServerRead { + /** + * False when a config file exists but could not be read or parsed, so the + * list below may be missing servers the CLI would load. Callers that replace + * the CLI's own resolution (`--strict-mcp-config`) must not act on an + * incomplete list. + */ + readonly complete: boolean; + readonly definitions: ReadonlyArray; + /** The `.claude.json` this read resolved to, whether or not it parsed. */ + readonly configPath: string; + /** + * The config files that exist but could not be read — `.claude.json`, + * `.mcp.json`, or both. Naming them separately is what lets a caller blame + * the file that actually failed instead of the one it resolved first. + */ + readonly unreadablePaths: ReadonlyArray; +} + +function readServerMap(value: unknown): ReadonlyArray]> { + if (typeof value !== "object" || value === null) return []; + const entries: Array]> = []; + for (const [rawName, rawEntry] of Object.entries(value as Record)) { + const name = rawName.trim(); + if (!name || typeof rawEntry !== "object" || rawEntry === null) continue; + entries.push([name, rawEntry as Record]); + } + return entries; +} + +function readStringArray(value: unknown): ReadonlyArray { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +/** + * A config file is either absent (no servers, and that is the truth), readable + * (its parsed contents), or unusable — unreadable or malformed, meaning the + * server list this file would contribute is unknown. + */ +type JsonFileRead = + | { readonly kind: "absent" } + | { readonly kind: "object"; readonly value: Record } + | { readonly kind: "unusable" }; + +/** + * `.claude.json` also stores `projects..history`, one entry per prompt + * the user has ever typed, so on an active machine it reaches tens of + * megabytes. Reading and parsing that on every settings request would block the + * event loop; past this size the file is treated as unreadable instead. + */ +const MAX_CONFIG_FILE_BYTES = 8 * 1024 * 1024; + +const readJsonObject = Effect.fn("ClaudeMcpConfig.readJsonObject")(function* ( + filePath: string, +): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + // A failed stat (permissions, transient FS error) is *not* the same as a + // missing file: the contents stay unknown, so report `unusable`. + const info = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); + if (info === null) { + const exists = yield* fileSystem.exists(filePath).pipe(Effect.orElseSucceed(() => true)); + return exists ? { kind: "unusable" } : { kind: "absent" }; + } + // Something is there but it is not a regular file (a directory, a socket). + // The CLI cannot read it either, so its servers are unknown, not absent. + if (info.type !== "File") return { kind: "unusable" }; + if (Number(info.size) > MAX_CONFIG_FILE_BYTES) return { kind: "unusable" }; + + const contents = yield* fileSystem + .readFileString(filePath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (contents === undefined) return { kind: "unusable" }; + const decoded = decodeJsonOption(contents); + if (decoded._tag === "None") return { kind: "unusable" }; + const parsed = decoded.value; + return typeof parsed === "object" && parsed !== null + ? { kind: "object", value: parsed as Record } + : { kind: "unusable" }; +}); + +/** + * Resolve the file Claude Code reads user-scoped MCP servers from, matching the + * precedence the spawned CLI sees: the instance's `homePath` (exported as + * `CLAUDE_CONFIG_DIR` by `makeClaudeEnvironment`), then a `CLAUDE_CONFIG_DIR` + * already present in the process environment, then `~`. With a config dir set, + * `.claude.json` lives inside it; otherwise it sits at `~/.claude.json`. + */ +export const resolveClaudeMcpConfigFilePath = Effect.fn( + "ClaudeMcpConfig.resolveClaudeMcpConfigFilePath", +)(function* ( + config: Pick, + environment: NodeJS.ProcessEnv, + cwd?: string, +): Effect.fn.Return { + const path = yield* Path.Path; + const homePath = config.homePath.trim(); + if (homePath.length > 0) { + return path.join(path.resolve(expandHomePath(homePath)), ".claude.json"); + } + // No tilde expansion here, and relative values resolve against the + // workspace cwd: the spawned CLI receives this env var verbatim (env vars + // are never shell-expanded) and resolves it from its own cwd, so discovery + // has to read the same directory the runtime would. Mirrors + // `resolveClaudeConfigDirPath` in `ClaudeSkills`. + const environmentConfigDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? ""; + if (environmentConfigDir.length > 0) { + const configDir = cwd + ? path.resolve(cwd, environmentConfigDir) + : path.resolve(environmentConfigDir); + return path.join(configDir, ".claude.json"); + } + return path.join(NodeOS.homedir(), ".claude.json"); +}); + +/** + * Enumerate every server the session would load: user scope, the workspace's + * `local` scope, and approved `.mcp.json` project servers. Callers pair this + * with `--strict-mcp-config`, so missing a scope here would silently drop + * servers from the session — hence all three are read, not just user scope. + * + * Unapproved `.mcp.json` servers are skipped: the CLI would prompt for trust + * before loading them, and passing them through explicitly would grant that + * trust on the user's behalf. Approval state lives in the same + * `projects.` block the CLI writes it to. + * + * Best effort: a missing, unreadable, or malformed config contributes nothing + * rather than failing the caller. On name collisions the narrower scope wins, + * matching Claude Code's local > project > user resolution. + */ +export const readClaudeMcpServers = Effect.fn("ClaudeMcpConfig.readClaudeMcpServers")(function* ( + config: Pick, + environment: NodeJS.ProcessEnv, + cwd?: string, +): Effect.fn.Return { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const configPath = yield* resolveClaudeMcpConfigFilePath(config, environment, cwd); + const claudeRead = yield* readJsonObject(configPath); + const claudeJson = claudeRead.kind === "object" ? claudeRead.value : undefined; + let complete = claudeRead.kind !== "unusable"; + const unreadablePaths: Array = claudeRead.kind === "unusable" ? [configPath] : []; + + const byName = new Map(); + for (const [name, definition] of readServerMap(claudeJson?.mcpServers)) { + byName.set(name, { name, scope: "user", sourcePath: configPath, definition }); + } + + if (cwd === undefined) { + // Without a workspace the `local` and `project` scopes cannot be read at + // all, so the list is knowingly partial. Callers that replace the CLI's own + // resolution must not treat it as the full set. + return { complete: false, definitions: [...byName.values()], configPath, unreadablePaths }; + } + + // Claude Code keys `projects` by the resolved real path. Matching on the raw + // cwd misses whenever it reaches us through a symlink — `/tmp` on macOS, or a + // linked worktree — and a silent miss drops the whole `local` scope. + const resolvedCwd = yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd)); + + const projects = claudeJson?.projects; + const projectEntry = + typeof projects === "object" && projects !== null + ? ((projects as Record)[resolvedCwd] ?? + (projects as Record)[path.resolve(cwd)] ?? + (projects as Record)[cwd]) + : undefined; + const projectConfig = + typeof projectEntry === "object" && projectEntry !== null + ? (projectEntry as Record) + : undefined; + + const mcpJsonPath = path.join(resolvedCwd, ".mcp.json"); + const mcpJsonRead = yield* readJsonObject(mcpJsonPath); + const mcpJson = mcpJsonRead.kind === "object" ? mcpJsonRead.value : undefined; + if (mcpJsonRead.kind === "unusable") { + complete = false; + unreadablePaths.push(mcpJsonPath); + } + const approveAll = projectConfig?.enableAllProjectMcpServers === true; + const approved = new Set(readStringArray(projectConfig?.enabledMcpjsonServers)); + const rejected = new Set(readStringArray(projectConfig?.disabledMcpjsonServers)); + for (const [name, definition] of readServerMap(mcpJson?.mcpServers)) { + if (rejected.has(name) || (!approveAll && !approved.has(name))) continue; + byName.set(name, { name, scope: "project", sourcePath: mcpJsonPath, definition }); + } + + for (const [name, definition] of readServerMap(projectConfig?.mcpServers)) { + byName.set(name, { name, scope: "local", sourcePath: configPath, definition }); + } + + return { complete, definitions: [...byName.values()], configPath, unreadablePaths }; +}); diff --git a/apps/server/src/mcpServers/McpServerInventory.test.ts b/apps/server/src/mcpServers/McpServerInventory.test.ts new file mode 100644 index 00000000000..5faf9000fcb --- /dev/null +++ b/apps/server/src/mcpServers/McpServerInventory.test.ts @@ -0,0 +1,230 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverClaudeMcpServerEntries, remoteDetail, stdioDetail } from "./McpServerInventory.ts"; + +describe("stdioDetail", () => { + it("keeps ordinary arguments so servers stay identifiable", () => { + assert.equal( + stdioDetail("npx", ["--yes", "xcodebuildmcp@2.6.2", "mcp"]), + "npx --yes xcodebuildmcp@2.6.2 mcp", + ); + assert.equal(stdioDetail("codegraph", undefined), "codegraph"); + assert.equal( + stdioDetail("uvx", ["mcp-server-git", "--repository", "/srv/repo"]), + "uvx mcp-server-git --repository /srv/repo", + ); + }); + + it("redacts secrets passed as flag values", () => { + assert.equal( + stdioDetail("server", ["--token", "abc123", "--port", "8080"]), + "server --token … --port 8080", + ); + assert.equal(stdioDetail("server", ["--api-key=abc123"]), "server --api-key=…"); + }); + + it("redacts secrets a flag-name blocklist would miss", () => { + // Positional token: nothing about the previous argument marks it as secret. + assert.equal(stdioDetail("npx", ["some-mcp", "sk-ant-api03-XYZ$/+"]), "npx some-mcp …"); + // The flag is innocuous; the value carries the credential. + assert.equal( + stdioDetail("mcp-remote", ["--header", "Authorization: Bearer ghp_XYZ"]), + "mcp-remote --header …", + ); + // Docker-style env injection. + assert.equal(stdioDetail("docker", ["run", "-e", "GH_PAT=ghp_XYZ"]), "docker run -e GH_PAT=…"); + // Connection string as a positional argument. + assert.equal( + stdioDetail("postgres-mcp", ["postgres://user:hunter2@db.example.com/app"]), + "postgres-mcp …", + ); + }); + + it("redacts a flag value that itself looks like a flag", () => { + // A credential can start with `-`, so "it parses as a flag" is not evidence + // that it is one. + assert.equal(stdioDetail("server", ["--token", "-secret"]), "server --token …"); + // Redacting the middle argument must not disarm the next one: whichever of + // the two flags owns it, `hunter2` is still a credential value. + assert.equal(stdioDetail("server", ["--token", "--api-key", "hunter2"]), "server --token … …"); + }); + + it("redacts a command that carries a credential", () => { + assert.equal(stdioDetail("postgres://user:hunter2@db.example.com/app", []), "…"); + assert.equal( + stdioDetail("/opt/mcp/bin/serve", ["--port", "8080"]), + "/opt/mcp/bin/serve --port 8080", + ); + }); + + it("ignores non-string arguments", () => { + assert.equal(stdioDetail("server", ["--flag", 42, null]), "server --flag"); + }); +}); + +describe("remoteDetail", () => { + it("keeps the origin, which is what identifies the server", () => { + assert.equal(remoteDetail("https://mcp.example.com/sse"), "https://mcp.example.com"); + }); + + it("drops paths, query strings, fragments, and userinfo", () => { + assert.equal( + remoteDetail("https://mcp.example.com/sse?api_key=sk-secret"), + "https://mcp.example.com", + ); + // A token in the path is just as much a credential as one in the query. + assert.equal(remoteDetail("https://mcp.example.com/mcp/sk-secret"), "https://mcp.example.com"); + assert.equal(remoteDetail("https://token@mcp.example.com/mcp"), "https://…@mcp.example.com"); + }); + + it("redacts anything it cannot parse", () => { + assert.equal(remoteDetail("not a url"), "…"); + assert.equal(remoteDetail(undefined), undefined); + }); +}); + +it.layer(NodeServices.layer)("claude inventory entries", (it) => { + it.effect("blames the config that actually failed, not the one it resolved first", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-inventory-" }); + const workspace = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspace, { recursive: true }); + yield* fs.writeFileString( + path.join(tempDir, ".claude.json"), + '{ "mcpServers": { "codegraph": { "command": "codegraph" } } }', + ); + // A malformed workspace `.mcp.json` leaves the project scope unknown. + yield* fs.writeFileString(path.join(workspace, ".mcp.json"), "{ not json"); + + const { entries, unreadable } = yield* discoverClaudeMcpServerEntries( + "claudeAgent", + { driver: ProviderDriverKind.make("claudeAgent"), config: { homePath: tempDir } }, + {}, + workspace, + ); + + // The user-scope row came out of a `.claude.json` that parsed, so it is + // not suspect; only the project scope is unknown. + assert.deepEqual( + entries.map((entry) => entry.name), + ["codegraph"], + ); + assert.equal(entries[0]?.status, undefined); + const resolvedWorkspace = yield* fs.realPath(workspace); + assert.deepEqual( + unreadable.map((item) => item.configPath), + [path.join(resolvedWorkspace, ".mcp.json")], + ); + }).pipe(Effect.scoped), + ); + + it.effect("reports an unreadable config even when it yields no rows at all", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-inventory-" }); + const workspace = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspace, { recursive: true }); + // The whole file is garbage, so not a single server can be recovered. + yield* fs.writeFileString(path.join(tempDir, ".claude.json"), "{ not json"); + + const { entries, unreadable } = yield* discoverClaudeMcpServerEntries( + "claudeAgent", + { driver: ProviderDriverKind.make("claudeAgent"), config: { homePath: tempDir } }, + {}, + workspace, + ); + + // Without the separate channel this is indistinguishable from "nothing + // configured", and the empty state would claim there are no servers. + assert.deepEqual(entries, []); + assert.deepEqual( + unreadable.map((item) => item.harnessDisplayName), + ["Claude"], + ); + assert.equal(unreadable[0]?.configPath, path.join(tempDir, ".claude.json")); + }).pipe(Effect.scoped), + ); + + it.effect("strips control characters and bounds absurd server names", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-inventory-" }); + const workspace = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspace, { recursive: true }); + // Written as raw text so the control character survives verbatim. + const rtl = `rtl‮evil`; + const long = "a".repeat(5000); + yield* fs.writeFileString( + path.join(tempDir, ".claude.json"), + `{"mcpServers":{"${rtl}":{"command":"x"},"${long}":{"command":"y"}}}`, + ); + + const { entries } = yield* discoverClaudeMcpServerEntries( + "claudeAgent", + { driver: ProviderDriverKind.make("claudeAgent"), config: { homePath: tempDir } }, + {}, + workspace, + ); + + const names = entries.map((entry) => entry.name).sort(); + // The RTL override would otherwise reorder how the row reads on screen. + assert.equal( + names.some((name) => /[\p{Cc}\p{Cf}]/u.test(name)), + false, + ); + assert.equal( + names.every((name) => name.length <= 121), + true, + ); + }).pipe(Effect.scoped), + ); + + it.effect("reports a clean read without a status", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-inventory-" }); + const workspace = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspace, { recursive: true }); + yield* fs.writeFileString( + path.join(tempDir, ".claude.json"), + '{ "mcpServers": { "codegraph": { "command": "codegraph", "args": ["--token", "s3cret"] } } }', + ); + + const { entries } = yield* discoverClaudeMcpServerEntries( + "claudeAgent", + { driver: ProviderDriverKind.make("claudeAgent"), config: { homePath: tempDir } }, + {}, + workspace, + ); + + assert.deepEqual( + entries.map((entry) => ({ + name: entry.name, + status: entry.status, + enabled: entry.enabled, + scope: entry.scope, + detail: entry.detail, + })), + [ + { + name: "codegraph", + status: undefined, + enabled: true, + scope: "user", + detail: "codegraph --token …", + }, + ], + ); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/mcpServers/McpServerInventory.ts b/apps/server/src/mcpServers/McpServerInventory.ts new file mode 100644 index 00000000000..557cbe829ce --- /dev/null +++ b/apps/server/src/mcpServers/McpServerInventory.ts @@ -0,0 +1,301 @@ +/** + * McpServerInventory — cross-harness discovery of the MCP servers each provider + * instance loads. + * + * This is about the *user's own* MCP servers, the ones the underlying CLIs load + * from their own config. Not to be confused with `../mcp/`, which is T3 Code + * acting as an MCP server and exposing the `preview_*` toolkit to agents. + * + * Discovery is read-only and best effort: an unreadable config yields an empty + * list for that instance rather than failing the request, the same contract + * `discoverClaudeSkills` follows for skills. No harness config file is ever + * written. + * + * Claude is read by parsing `.claude.json` directly: the CLI's own + * `claude mcp list` health-checks every server over the network, which is far + * too slow for a settings page. + * + * Only Claude is covered for now. Codex needs a `codex mcp list --json` + * subprocess, and Cursor, Grok, and OpenCode run over ACP, which never reports + * the servers the agent loads from its own config. + * + * @module mcpServers/McpServerInventory + */ +import type { + McpServerInventory, + McpServerInventoryEntry, + McpServerTransport, + McpServerUnreadableConfig, + ProviderInstanceConfig, + ServerSettings, +} from "@t3tools/contracts"; +import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +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 * as ServerConfig from "../config.ts"; +import { readClaudeMcpServers } from "./ClaudeMcpConfig.ts"; +import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; + +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); + +/** One instance per configured harness is the norm; keep the fan-out bounded. */ +const DISCOVERY_CONCURRENCY = 4; + +export type McpInventoryEnv = FileSystem.FileSystem | Path.Path; + +interface InstanceDiscovery { + readonly entries: ReadonlyArray; + readonly unreadable: ReadonlyArray; +} + +const EMPTY_DISCOVERY: InstanceDiscovery = { entries: [], unreadable: [] }; + +function harnessDisplayName(instance: ProviderInstanceConfig, fallback: string): string { + return instance.displayName?.trim() || fallback; +} + +function trimmedOrUndefined(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +const REDACTED = "…"; +/** + * Shape of an argument safe to echo verbatim: a flag, a package specifier, a + * subcommand, or a filesystem path (POSIX, home-relative, or Windows drive). + * Anything with other punctuation — a quoted header, a connection string, a + * token containing `+`, `$`, or `:` — fails and is redacted. + */ +const SAFE_ARGUMENT_PATTERN = + /^(?:-{0,2}[A-Za-z0-9]|[.~]?[\\/]|[A-Za-z]:[\\/])[A-Za-z0-9._/\\@_-]*$/; +/** Flag names whose value is a credential. Over-matching here is harmless. */ +const SECRET_HINT_PATTERN = + /(token|key|secret|password|passwd|credential|auth|bearer|pat|dsn|cookie|session|header)/i; + +/** A word that looks structural and does not name a credential. */ +function isSafeToShow(value: string): boolean { + return SAFE_ARGUMENT_PATTERN.test(value) && !SECRET_HINT_PATTERN.test(value); +} + +/** + * Render a stdio server as its command line for display. Environment values are + * dropped entirely, and an argument is shown only if it both looks structural + * and is not the value of a credential-carrying flag. + * + * Two signals are needed because neither is sufficient. Shape alone catches + * `postgres://u:p@host/db` and `Authorization: Bearer …` but would happily + * print a bare `hunter2`; a flag-name blocklist alone catches `--token secret` + * but misses everything positional. Together they cover every form seen in real + * MCP configs except one: a bare positional secret that also looks like a word. + * That residue is accepted — this is a display string, and the alternative is + * showing nothing useful at all. + */ +export function stdioDetail(command: string, args: unknown): string { + const parts = Array.isArray(args) + ? args.filter((arg): arg is string => typeof arg === "string") + : []; + const rendered: Array = []; + let redactNext = false; + for (const part of parts) { + if (redactNext) { + // Redact whatever it looks like: a credential can start with `-`, so + // "it parses as a flag" is not evidence that it is one. It may still be + // one though — in `--token --api-key hunter2` either reading of the + // middle argument leaves `hunter2` a secret — so re-arm rather than + // letting the next value through. + redactNext = part.startsWith("-") && SECRET_HINT_PATTERN.test(part); + rendered.push(REDACTED); + continue; + } + // `--flag=value` keeps the flag so the shape stays readable; the value goes + // whenever the flag hints at a credential or the value itself looks unsafe. + const inlineSeparator = part.indexOf("="); + if (inlineSeparator > 0) { + const flag = part.slice(0, inlineSeparator); + const value = part.slice(inlineSeparator + 1); + if (!SAFE_ARGUMENT_PATTERN.test(flag)) { + rendered.push(REDACTED); + continue; + } + const unsafeValue = SECRET_HINT_PATTERN.test(flag) || !SAFE_ARGUMENT_PATTERN.test(value); + rendered.push(unsafeValue ? `${flag}=${REDACTED}` : part); + continue; + } + if (!SAFE_ARGUMENT_PATTERN.test(part)) { + rendered.push(REDACTED); + continue; + } + if (part.startsWith("-") && SECRET_HINT_PATTERN.test(part)) redactNext = true; + rendered.push(part); + } + // The command is config data too — it can be a credential-bearing URL, or the + // secret itself — so it gets the same treatment as a positional argument. + return [isSafeToShow(command) ? command : REDACTED, ...rendered].join(" "); +} + +/** + * Render a remote server's URL without its credentials. Only the origin is + * shown: the query, the fragment, the userinfo, *and* the path all routinely + * carry API keys (`?api_key=…`, `https://token@host/mcp`, `/mcp/`), and + * the host on its own already identifies the server. + */ +export function remoteDetail(rawUrl: string | undefined): string | undefined { + if (rawUrl === undefined) return undefined; + try { + const url = new URL(rawUrl); + const credentialed = url.username.length > 0 || url.password.length > 0; + return `${url.protocol}//${credentialed ? `${REDACTED}@` : ""}${url.host}`; + } catch { + // Not a parseable URL, so nothing can be said about which part is a secret. + return REDACTED; + } +} + +/** + * Server names come from a config file we do not control. Control characters — + * notably the RTL override U+202E — would reorder how a row reads on screen, + * and an unbounded name is a wire and layout hazard. React escapes markup, so + * this is about what a name can *look* like, not injection. + */ +const MAX_DISPLAY_NAME_LENGTH = 120; + +function sanitizeDisplayName(name: string): string { + const stripped = name.replace(/[\p{Cc}\p{Cf}]/gu, "").trim(); + // A name made only of control characters has nothing left to show; falling + // back to the raw name would put the override right back on screen. + const safe = stripped.length > 0 ? stripped : "?"; + return safe.length > MAX_DISPLAY_NAME_LENGTH + ? `${safe.slice(0, MAX_DISPLAY_NAME_LENGTH)}…` + : safe; +} + +function claudeTransport(entry: Record): McpServerTransport { + const declared = trimmedOrUndefined(entry.type); + if (declared === "http" || declared === "sse") return declared; + if (declared === "stdio") return "stdio"; + // Older entries omit `type`; a `url` means a remote server. + return trimmedOrUndefined(entry.url) ? "http" : "stdio"; +} + +export const discoverClaudeMcpServerEntries = Effect.fn( + "McpServerInventory.discoverClaudeMcpServerEntries", +)(function* ( + instanceId: string, + instance: ProviderInstanceConfig, + environment: NodeJS.ProcessEnv, + cwd: string | undefined, +): Effect.fn.Return { + const decoded = decodeClaudeSettings(instance.config ?? {}); + if (decoded._tag === "None") return EMPTY_DISCOVERY; + const settings = decoded.value; + + const displayName = harnessDisplayName(instance, "Claude"); + const { complete, definitions, unreadablePaths } = yield* readClaudeMcpServers( + settings, + environment, + cwd, + ); + + const entries: McpServerInventoryEntry[] = []; + for (const { name, scope, sourcePath, definition: entry } of definitions) { + const transport = claudeTransport(entry); + const command = trimmedOrUndefined(entry.command); + const detail = + transport === "stdio" && command + ? stdioDetail(command, entry.args) + : remoteDetail(trimmedOrUndefined(entry.url)); + + entries.push({ + providerInstanceId: ProviderInstanceId.make(instanceId), + harness: instance.driver, + harnessDisplayName: displayName, + name: sanitizeDisplayName(name), + transport, + ...(detail ? { detail } : {}), + configPath: sourcePath, + scope, + // Claude Code has no per-server enable flag: everything it resolves is + // loaded. + enabled: true, + }); + } + + // Every row here came out of a file that parsed, so no row is itself + // suspect — what an unreadable config costs is the rows it would have + // added. That is reported against the failing file instead, one entry per + // file, because a broken `.mcp.json` says nothing about `.claude.json`. + const unreadable = unreadablePaths.map((path) => ({ + providerInstanceId: ProviderInstanceId.make(instanceId), + harnessDisplayName: displayName, + configPath: path, + })); + + return { + entries, + unreadable: + complete || unreadable.length > 0 + ? unreadable + : // Incomplete with no file to blame: the scopes that need a workspace + // were never read at all. + [ + { + providerInstanceId: ProviderInstanceId.make(instanceId), + harnessDisplayName: displayName, + }, + ], + }; +}); + +const discoverInstanceMcpServers = Effect.fn("McpServerInventory.discoverInstanceMcpServers")( + function* ( + instanceId: string, + instance: ProviderInstanceConfig, + environment: NodeJS.ProcessEnv, + cwd: string | undefined, + ): Effect.fn.Return { + // A disabled instance never starts a session, so listing its servers would + // describe work that cannot happen. + if (instance.enabled === false) return EMPTY_DISCOVERY; + if (instance.driver === "claudeAgent") { + return yield* discoverClaudeMcpServerEntries(instanceId, instance, environment, cwd); + } + return EMPTY_DISCOVERY; + }, +); + +export const discoverGlobalMcpInventory = Effect.fn( + "McpServerInventory.discoverGlobalMcpInventory", +)(function* ( + settings: ServerSettings, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return { + const providerInstances = deriveProviderInstanceConfigMap(settings); + const resolvedEnvironment = environment ?? process.env; + // Sessions resolve a relative `CLAUDE_CONFIG_DIR` against the workspace cwd, + // so the inventory has to read the same file the runtime will. + const { cwd } = yield* ServerConfig.ServerConfig; + + const discovered = yield* Effect.forEach( + Object.entries(providerInstances), + ([instanceId, instance]) => + discoverInstanceMcpServers(instanceId, instance, resolvedEnvironment, cwd), + { concurrency: DISCOVERY_CONCURRENCY }, + ); + + return { + scannedAt: DateTime.formatIso(yield* DateTime.now), + servers: discovered + .flatMap((result) => result.entries) + .sort( + (left, right) => + left.harnessDisplayName.localeCompare(right.harnessDisplayName) || + left.name.localeCompare(right.name), + ), + unreadable: discovered.flatMap((result) => result.unreadable), + }; +}); diff --git a/apps/server/src/mcpServers/http.ts b/apps/server/src/mcpServers/http.ts new file mode 100644 index 00000000000..1a6cd428f78 --- /dev/null +++ b/apps/server/src/mcpServers/http.ts @@ -0,0 +1,41 @@ +/** + * HTTP surface for the MCP server inventory. + * + * Read-only: T3 Code reports what each harness will load, it does not write to + * any harness config file. + * + * @module mcpServers/http + */ +import { AuthOrchestrationReadScope, EnvironmentHttpApi } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +import { + annotateEnvironmentRequest, + failEnvironmentInternal, + requireEnvironmentScope, +} from "../auth/http.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { discoverGlobalMcpInventory } from "./McpServerInventory.ts"; + +export const mcpServersHttpApiLayer = HttpApiBuilder.group( + EnvironmentHttpApi, + "mcpServers", + Effect.fnUntraced(function* (handlers) { + const settingsService = yield* ServerSettings.ServerSettingsService; + + return handlers.handle( + "inventory", + Effect.fn("environment.mcpServers.inventory")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthOrchestrationReadScope); + // Read settings here rather than inside discovery so a settings failure + // surfaces as the declared error instead of a defect. + const settings = yield* settingsService.getSettings.pipe( + Effect.catch((cause) => failEnvironmentInternal("internal_error", cause)), + ); + return yield* discoverGlobalMcpInventory(settings); + }), + ); + }), +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 05657af6d48..ded6a40d5ec 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -21,6 +21,7 @@ import { } from "./http.ts"; import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; +import { mcpServersHttpApiLayer } from "./mcpServers/http.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -413,6 +414,7 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(authHttpApiLayer), Layer.provide(connectHttpApiLayer), Layer.provide(orchestrationHttpApiLayer), + Layer.provide(mcpServersHttpApiLayer), Layer.provide(serverEnvironmentHttpApiLayer), Layer.provide(environmentAuthenticatedAuthLayer), ), diff --git a/apps/web/src/components/settings/McpServersSettings.logic.test.ts b/apps/web/src/components/settings/McpServersSettings.logic.test.ts new file mode 100644 index 00000000000..ee3f0df8800 --- /dev/null +++ b/apps/web/src/components/settings/McpServersSettings.logic.test.ts @@ -0,0 +1,74 @@ +import type { McpServerInventory, McpServerInventoryEntry } from "@t3tools/contracts"; +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + filterMcpInventory, + formatMcpConfigPath, + groupMcpServersByHarness, +} from "./McpServersSettings.logic"; + +const makeServer = ( + overrides: Partial & Pick, +): McpServerInventoryEntry => ({ + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + harness: ProviderDriverKind.make("claudeAgent"), + harnessDisplayName: "Claude", + transport: "stdio", + enabled: true, + ...overrides, +}); + +const inventoryOf = (servers: ReadonlyArray): McpServerInventory => ({ + scannedAt: "2026-07-27T00:00:00.000Z", + servers, + unreadable: [], +}); + +describe("filterMcpInventory", () => { + it("matches name, detail, harness, and transport", () => { + const inventory = inventoryOf([ + makeServer({ name: "codegraph", detail: "codegraph serve --mcp" }), + makeServer({ + name: "remote", + transport: "http", + detail: "https://example.com/mcp", + harnessDisplayName: "Codex", + harness: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + }), + ]); + + expect(filterMcpInventory(inventory, "serve").servers.map((s) => s.name)).toEqual([ + "codegraph", + ]); + expect(filterMcpInventory(inventory, "codex").servers.map((s) => s.name)).toEqual(["remote"]); + expect(filterMcpInventory(inventory, "http").servers.map((s) => s.name)).toEqual(["remote"]); + expect(filterMcpInventory(inventory, " ").servers).toHaveLength(2); + }); +}); + +describe("groupMcpServersByHarness", () => { + it("buckets by instance and preserves inventory order", () => { + const groups = groupMcpServersByHarness([ + makeServer({ name: "b" }), + makeServer({ + name: "a", + harnessDisplayName: "Codex", + harness: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + }), + makeServer({ name: "c" }), + ]); + + expect(groups.map((group) => group.harnessDisplayName)).toEqual(["Claude", "Codex"]); + expect(groups[0]?.servers.map((server) => server.name)).toEqual(["b", "c"]); + }); +}); + +describe("formatMcpConfigPath", () => { + it("shortens home-relative and deep paths", () => { + expect(formatMcpConfigPath("/Users/mark/.claude.json")).toBe("~/.claude.json"); + expect(formatMcpConfigPath("/Users/mark/a/b/c/d/.claude.json")).toBe("…/c/d/.claude.json"); + }); +}); diff --git a/apps/web/src/components/settings/McpServersSettings.logic.ts b/apps/web/src/components/settings/McpServersSettings.logic.ts new file mode 100644 index 00000000000..482e3215046 --- /dev/null +++ b/apps/web/src/components/settings/McpServersSettings.logic.ts @@ -0,0 +1,96 @@ +import type { + McpServerInventory, + McpServerInventoryEntry, + McpServerUnreadableConfig, + ProviderDriverKind, +} from "@t3tools/contracts"; + +/** Matches a POSIX or Windows home directory so paths can read as `~/...`. */ +const HOME_DIRECTORY_PREFIX = /^(?:\/(?:home|Users)\/[^/]+|[A-Za-z]:\\Users\\[^\\]+)(?=[/\\]|$)/; + +/** + * Shortens a config path for display: home-relative, and elided to its last few + * segments when deep. The full path stays available in the tooltip. + */ +export function formatMcpConfigPath(path: string): string { + const homeRelative = path.replace(HOME_DIRECTORY_PREFIX, "~"); + const segments = homeRelative.split(/[/\\]/).filter(Boolean); + if (segments.length <= 4) return homeRelative; + return `…/${segments.slice(-3).join("/")}`; +} + +/** + * Opening clause naming the configs that could not be read. A file is named + * whenever discovery could pin the failure on one — blaming `.claude.json` for + * a broken `.mcp.json` would send the reader to the wrong file. + */ +export function unreadableConfigMessage( + unreadable: ReadonlyArray, +): string { + const paths = unreadable.flatMap((item) => (item.configPath ? [item.configPath] : [])); + return paths.length > 0 + ? `Could not read ${paths.map(formatMcpConfigPath).join(", ")}` + : "Could not fully read the Claude Code config on this computer"; +} + +export function filterMcpInventory( + inventory: McpServerInventory, + query: string, +): McpServerInventory { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (!normalizedQuery) return inventory; + return { + ...inventory, + servers: inventory.servers.filter((server) => + [ + server.name, + server.detail ?? "", + server.configPath ?? "", + server.harnessDisplayName, + server.transport, + ].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)), + ), + }; +} + +export interface McpHarnessGroup { + readonly key: string; + readonly harness: ProviderDriverKind; + readonly harnessDisplayName: string; + readonly servers: ReadonlyArray; +} + +/** Stable identity for a single server row, unique within an inventory. */ +export function mcpServerKey(server: McpServerInventoryEntry): string { + return `${server.providerInstanceId}:${server.name}`; +} + +/** + * Buckets servers by the harness that loads them, preserving inventory order so + * the list renders deterministically across refreshes. + */ +export function groupMcpServersByHarness( + servers: ReadonlyArray, +): ReadonlyArray { + const groups = new Map(); + for (const server of servers) { + const key = `${server.providerInstanceId}\0${server.harnessDisplayName}`; + const existing = groups.get(key); + if (existing) existing.push(server); + else groups.set(key, [server]); + } + // Every bucket is created from a server, so `grouped[0]` always exists. + return [...groups.entries()].flatMap(([key, grouped]) => { + const first = grouped[0]; + return first + ? [ + { + key, + harness: first.harness, + harnessDisplayName: first.harnessDisplayName, + servers: grouped, + }, + ] + : []; + }); +} diff --git a/apps/web/src/components/settings/McpServersSettings.tsx b/apps/web/src/components/settings/McpServersSettings.tsx new file mode 100644 index 00000000000..f225173e9aa --- /dev/null +++ b/apps/web/src/components/settings/McpServersSettings.tsx @@ -0,0 +1,307 @@ +import { ChevronRightIcon, CopyIcon, PlugIcon, RefreshCwIcon } from "lucide-react"; +import type { McpServerInventory, McpServerInventoryEntry } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useCallback, useEffect, useId, useMemo, useState } from "react"; + +import { fetchEnvironmentMcpInventory } from "@t3tools/client-runtime/state/mcp"; +import { cn } from "../../lib/utils"; +import { runtime } from "../../lib/runtime"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { type EnvironmentPresentation, useEnvironments } from "../../state/environments"; +import { usePreparedConnection } from "../../state/session"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { toastManager } from "../ui/toast"; +import { searchableSetting } from "./settingsSearch"; +import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; +import { + filterMcpInventory, + formatMcpConfigPath, + groupMcpServersByHarness, + mcpServerKey, + unreadableConfigMessage, +} from "./McpServersSettings.logic"; + +type InventoryState = + | { readonly status: "loading" } + | { readonly status: "loaded"; readonly inventory: McpServerInventory } + | { readonly status: "error"; readonly message: string }; + +function errorMessage(cause: unknown): string { + return cause instanceof Error && cause.message.trim() + ? cause.message + : "Could not load MCP servers."; +} + +function connectionDotClassName(phase: string): string { + if (phase === "connected") return "bg-success"; + if (phase === "connecting" || phase === "reconnecting") return "bg-warning"; + if (phase === "error") return "bg-destructive"; + return "bg-muted-foreground/40"; +} + +function DisclosureChevron({ open, className }: { open: boolean; className?: string }) { + return ( + + ); +} + +function StatusLine({ children, tone }: { children: string; tone?: "error" }) { + return ( +

+ {children} +

+ ); +} + +export function McpServersSettings() { + const { environments } = useEnvironments(); + const [query, setQuery] = useState(""); + const [refreshKey, setRefreshKey] = useState(0); + + return ( + + } + headerAction={ + + } + > +
+

+ MCP servers Claude Code loads on every connected computer, read from its own config. T3 + Code never writes to those files. Other harnesses are not listed yet. +

+ setQuery(event.target.value)} + placeholder="Search servers, harnesses, or paths" + aria-label="Search MCP servers" + className="max-w-sm" + /> +
+ +
+ {environments.map((environment) => ( + + ))} +
+
+
+ ); +} + +function EnvironmentMcpInventory({ + environment, + query, + refreshKey, +}: { + environment: EnvironmentPresentation; + query: string; + refreshKey: number; +}) { + const prepared = usePreparedConnection(environment.environmentId); + const [state, setState] = useState({ status: "loading" }); + const [collapsed, setCollapsed] = useState(false); + const panelId = useId(); + + // One clipboard hook for the whole environment: a hook per row would mean one + // timer per server, and a settings page can list dozens. + const { copyToClipboard } = useCopyToClipboard<{ value: string }>({ + target: "path", + onCopy: ({ value }) => { + toastManager.add({ type: "success", title: "Path copied", description: value }); + }, + }); + const copyConfigPath = useCallback( + (configPath: string) => copyToClipboard(configPath, { value: configPath }), + [copyToClipboard], + ); + + useEffect(() => { + if (Option.isNone(prepared)) return; + const connection = prepared.value; + // `cancelled` covers a reconnect too: the effect re-runs on a new + // connection, so a response from the old one lands after its own cleanup. + let cancelled = false; + setState({ status: "loading" }); + void runtime + .runPromise(fetchEnvironmentMcpInventory({ prepared: connection })) + .then((inventory) => { + if (!cancelled) setState({ status: "loaded", inventory }); + }) + .catch((cause: unknown) => { + if (!cancelled) setState({ status: "error", message: errorMessage(cause) }); + }); + return () => { + cancelled = true; + }; + }, [prepared, refreshKey]); + + const groups = useMemo(() => { + if (state.status !== "loaded") return []; + return groupMcpServersByHarness(filterMcpInventory(state.inventory, query).servers); + }, [state, query]); + + const serverCount = state.status === "loaded" ? state.inventory.servers.length : null; + const unreadable = state.status === "loaded" ? state.inventory.unreadable : []; + const unreadableCount = unreadable.length; + const emptyMessage = + groups.length > 0 + ? null + : query.trim() + ? "No MCP servers match this search." + : unreadableCount > 0 + ? // The unreadable-config warning already explains the empty list; + // "nothing configured" would claim more than we know. + null + : "No MCP servers configured for Claude Code on this computer."; + + return ( +
+ + + {collapsed ? null : ( +
+ {Option.isNone(prepared) ? ( + Not connected. + ) : state.status === "loading" ? ( + Loading MCP servers… + ) : state.status === "error" ? ( + {state.message} + ) : ( + <> + {/* The rows are accurate; what an unreadable config costs is the + rows it would have added. Shown while searching too — a config + the search could not scan is exactly what "no match" hides. */} + {unreadableCount > 0 ? ( + + {`${unreadableConfigMessage(unreadable)}, so this list may be missing servers.`} + + ) : null} + {emptyMessage ? {emptyMessage} : null} + {groups.map((group) => ( +
+

+ {group.harnessDisplayName} +

+ {group.servers.map((server) => ( + + ))} +
+ ))} + + )} +
+ )} +
+ ); +} + +function RowBadge({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function McpServerRow({ + server, + onCopyConfigPath, +}: { + server: McpServerInventoryEntry; + onCopyConfigPath: (configPath: string) => void; +}) { + const configPath = server.configPath; + + return ( +
+
+
+ + {server.name} + + {server.transport} + {server.scope && server.scope !== "user" ? {server.scope} : null} + {server.enabled ? null : off} + {server.status ? ( + {server.status} + ) : null} +
+ {server.detail ? ( +

+ {server.detail} +

+ ) : null} +
+ + {configPath ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index b7ca9afcf83..f66c7416b0d 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -16,6 +16,7 @@ import { KeyboardIcon, Link2Icon, PaletteIcon, + PlugIcon, SearchIcon, Settings2Icon, XIcon, @@ -50,6 +51,7 @@ const SETTINGS_SECTION_ICONS: Readonly< "/settings/appearance": PaletteIcon, "/settings/keybindings": KeyboardIcon, "/settings/providers": BotIcon, + "/settings/mcp": PlugIcon, "/settings/source-control": GitBranchIcon, "/settings/connections": Link2Icon, "/settings/beta": FlaskConicalIcon, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4ead6eff4d7..dc532af8b60 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -3,6 +3,7 @@ export type SettingsPath = | "/settings/appearance" | "/settings/keybindings" | "/settings/providers" + | "/settings/mcp" | "/settings/source-control" | "/settings/connections" | "/settings/beta" @@ -24,6 +25,7 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/appearance": "Appearance", "/settings/keybindings": "Keybindings", "/settings/providers": "Providers", + "/settings/mcp": "MCP", "/settings/source-control": "Source Control", "/settings/connections": "Connections", "/settings/beta": "Beta", @@ -136,6 +138,11 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Providers", to: "/settings/providers", }, + { + id: "mcp", + title: "MCP servers", + to: "/settings/mcp", + }, { id: "source-control", title: "Source control", diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 58ab4c3a714..25cbd9a3b89 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' +import { Route as SettingsMcpRouteImport } from './routes/settings.mcp' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' @@ -61,6 +62,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ path: '/providers', getParentRoute: () => SettingsRoute, } as any) +const SettingsMcpRoute = SettingsMcpRouteImport.update({ + id: '/mcp', + path: '/mcp', + getParentRoute: () => SettingsRoute, +} as any) const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ id: '/keybindings', path: '/keybindings', @@ -126,6 +132,7 @@ export interface FileRoutesByFullPath { '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/mcp': typeof SettingsMcpRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute @@ -143,6 +150,7 @@ export interface FileRoutesByTo { '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/mcp': typeof SettingsMcpRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/': typeof ChatIndexRoute @@ -163,6 +171,7 @@ export interface FileRoutesById { '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/mcp': typeof SettingsMcpRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/_chat/': typeof ChatIndexRoute @@ -184,6 +193,7 @@ export interface FileRouteTypes { | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/mcp' | '/settings/providers' | '/settings/source-control' | '/$environmentId/$threadId' @@ -201,6 +211,7 @@ export interface FileRouteTypes { | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/mcp' | '/settings/providers' | '/settings/source-control' | '/' @@ -220,6 +231,7 @@ export interface FileRouteTypes { | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/mcp' | '/settings/providers' | '/settings/source-control' | '/_chat/' @@ -286,6 +298,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProvidersRouteImport parentRoute: typeof SettingsRoute } + '/settings/mcp': { + id: '/settings/mcp' + path: '/mcp' + fullPath: '/settings/mcp' + preLoaderRoute: typeof SettingsMcpRouteImport + parentRoute: typeof SettingsRoute + } '/settings/keybindings': { id: '/settings/keybindings' path: '/keybindings' @@ -381,6 +400,7 @@ interface SettingsRouteChildren { SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute + SettingsMcpRoute: typeof SettingsMcpRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute } @@ -393,6 +413,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, + SettingsMcpRoute: SettingsMcpRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, } diff --git a/apps/web/src/routes/settings.mcp.tsx b/apps/web/src/routes/settings.mcp.tsx new file mode 100644 index 00000000000..739989c8683 --- /dev/null +++ b/apps/web/src/routes/settings.mcp.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { McpServersSettings } from "../components/settings/McpServersSettings"; + +export const Route = createFileRoute("/settings/mcp")({ + component: McpServersSettings, +}); diff --git a/docs/README.md b/docs/README.md index bc359826a04..c62f4066fb2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ - [Keyboard shortcuts](./user/keybindings.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) +- [MCP servers](./user/mcp.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) - Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index da16f74d339..9eb6fdbd02c 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -116,6 +116,17 @@ Controls how assistant text reaches the thread timeline. In [the contracts][1], A point-in-time view of state. The word is used in multiple layers, including orchestration, provider, and checkpointing. See [ProjectionSnapshotQuery.ts][10], [ProviderAdapter.ts][15], and [CheckpointStore.ts][19]. +#### MCP server + +A Model Context Protocol server: a process or endpoint that advertises tools an agent can call. Two unrelated senses in this codebase, and they are easy to confuse: + +1. **T3 Code as an MCP server.** The server hosts one at `/mcp` and injects it into every session under the name `t3-code`, which is how an agent reaches the `preview_*` browser tools. See [McpHttpServer.ts][25]. +2. **The user's MCP servers.** The ones Claude Code and Codex load from their own config. T3 Code neither spawns nor configures these — the harness CLI does. See [MCP inventory](#mcp-inventory). + +#### MCP inventory + +A read-only listing of the user's MCP servers across every provider instance on one environment, surfaced at Settings → MCP. Claude entries are read from `.claude.json` directly, because the CLI's own `claude mcp list` health-checks every server over the network. Discovery is best effort: an unreadable config marks the read incomplete rather than failing the request. Only Claude is covered today. See [McpServerInventory.ts][26] and [the user guide][27]. + ### Checkpointing Checkpointing captures workspace state over time so the app can diff turns and restore earlier points. The main pieces are [CheckpointStore.ts][19], [CheckpointDiffQuery.ts][20], and [CheckpointReactor.ts][6]. @@ -179,3 +190,6 @@ The file patch and changed-file summary for one turn. It is usually computed in [22]: ../../apps/server/src/checkpointing/Utils.ts [23]: ../../apps/server/src/checkpointing/Diffs.ts [24]: ./overview.md +[25]: ../../apps/server/src/mcp/McpHttpServer.ts +[26]: ../../apps/server/src/mcpServers/McpServerInventory.ts +[27]: ../user/mcp.md diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03d..563cf0004a5 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -75,6 +75,39 @@ spills the whole accumulated text as one delta. The buffer also flushes at inter when a request opens (approval) or user input is requested, via `flushBufferedAssistantMessagesForTurn`. +## MCP servers + +Two unrelated things share the name. `apps/server/src/mcp/` is T3 Code _hosting_ an MCP server: it +serves `/mcp`, mints a bearer credential per thread, and injects itself into every session as +`t3-code` so agents can reach the `preview_*` browser tools. `apps/server/src/mcpServers/` is the +inventory of the _user's_ MCP servers — the ones the harness CLIs load from their own config. + +The inventory is read-only and best effort. Nothing writes to `.claude.json`, `.mcp.json`, or +`config.toml`, and no MCP process is ever spawned by us — the harness CLI owns those. + +Reads differ per driver because the harnesses differ: + +| Driver | Source | Why | +| ---------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Claude | `.claude.json` parsed directly, plus approved `.mcp.json` project servers | `claude mcp list` health-checks every server over the network, far too slow for a settings page | +| Codex | not read yet | needs a `codex mcp list --json` subprocess; that CLI already reports the `enabled` flag Codex honours, so no TOML parser will be required | +| Cursor, Grok, OpenCode | not read | ACP can add a server to a session but never reports the ones the agent loads from its own config | + +Two invariants worth preserving: + +- **`complete` is not decoration.** [`ClaudeMcpConfig.ts`][claudemcp] returns it `false` whenever a + config file exists but could not be parsed, and whenever no workspace `cwd` was supplied (which + makes the `local` and `project` scopes unreadable). A future caller that replaces the CLI's own + resolution — `--strict-mcp-config` — must refuse to act on an incomplete list, or it will silently + drop servers the session would otherwise load. +- **Nothing secret crosses the wire.** `env` values are dropped, and command arguments and URLs are + rendered through an allowlist: anything that is not clearly a flag, package name, or path becomes + `…`. This endpoint is reachable remotely, and MCP configs routinely carry API keys. + +See [the user guide][usermcp] for the shipped behaviour. + +[claudemcp]: ../../apps/server/src/mcpServers/ClaudeMcpConfig.ts +[usermcp]: ../user/mcp.md [drivers]: ../../apps/server/src/provider/builtInDrivers.ts [codex]: ../../apps/server/src/provider/Drivers/CodexDriver.ts [claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts diff --git a/docs/user/mcp.md b/docs/user/mcp.md new file mode 100644 index 00000000000..8bcac9719aa --- /dev/null +++ b/docs/user/mcp.md @@ -0,0 +1,66 @@ +# MCP servers + +**Settings → MCP** lists the MCP servers Claude Code loads, on every computer connected to T3 Code, +so you can see them without opening a terminal or a config file. + +MCP (Model Context Protocol) servers give an agent extra tools: a database it can query, a browser +it can drive, an issue tracker it can read. You configure them in Claude Code itself, and T3 Code +reports what it finds. + +## What the page shows + +One collapsible group per connected computer, and inside it one group per Claude provider. Each row +is a server: + +- **Name** — the name Claude Code knows it by. +- **Transport** — `stdio` for a local command, `http` or `sse` for a remote server. +- **Scope** — `project` for a server from the repo's `.mcp.json`, `local` for one private to that + workspace. Servers available everywhere show no scope badge. +- **Detail** — the command line or the server address, with secrets removed. Environment values are + never shown, a remote server is shown as its address only, and anything in a command line that + looks like a credential is replaced with `…`. The one thing that slips through is a bare argument + that looks like an ordinary word — `hunter2` is indistinguishable from a subcommand — so a secret + passed with no flag in front of it can still be shown. Everything else stays in your config; T3 + Code does not carry it across the network. +- **Config file** — where the entry is declared. Click it to copy the full path. + +"Refresh all" re-reads every computer, so a config you edited outside T3 Code shows up. + +## Which harnesses are covered + +Claude Code only, for now. + +Cursor, Grok, and OpenCode run over ACP, which lets T3 Code _add_ a server to a session but never +reports the ones the agent loads from its own config — there is nothing to read. Codex keeps its +servers in `config.toml` and needs a separate lookup, which is not wired up yet. + +Servers you connected through claude.ai rather than a config file are also not listed. Claude Code +resolves those outside the filesystem, where T3 Code cannot see them. + +## When a config cannot be read + +When a config exists but T3 Code cannot parse it — malformed JSON, a permissions problem, or a +`.claude.json` grown too large to read on demand — the page says so and names the file that failed. +The servers that file would have contributed are missing from the list; the rows you do see are +still accurate, because each one came from a file that parsed. + +This never affects your sessions. T3 Code only reads these files; it does not write to +`.claude.json` or `.mcp.json`, and Claude Code resolves its own servers at launch exactly as it +would without T3 Code. + +## Turning a server off + +Not from here — this page is read-only. It tells you what is loaded, not what to load. + +To remove a server, use Claude Code: `claude mcp remove `, or edit the `mcpServers` block in +`.claude.json`. The change shows up here after "Refresh all". + +## The built-in T3 Code server + +T3 Code adds one MCP server of its own, named `t3-code`, to every session. It is what lets an agent +drive the built-in browser preview. It is not part of your configuration and does not appear on this +page. + +--- + +Mobile: this page is web and desktop only. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 0b7b078a522..411cc3b6ce2 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -63,6 +63,10 @@ "types": "./src/state/git.ts", "default": "./src/state/git.ts" }, + "./state/mcp": { + "types": "./src/state/mcp.ts", + "default": "./src/state/mcp.ts" + }, "./state/models": { "types": "./src/state/models.ts", "default": "./src/state/models.ts" diff --git a/packages/client-runtime/src/state/mcp.ts b/packages/client-runtime/src/state/mcp.ts new file mode 100644 index 00000000000..b89801b2d98 --- /dev/null +++ b/packages/client-runtime/src/state/mcp.ts @@ -0,0 +1,33 @@ +import * as Effect from "effect/Effect"; + +import type { PreparedConnection } from "../connection/model.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; +import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; + +// Codex is spawned to read its config, so allow more than a pure disk read. +const DEFAULT_MCP_INVENTORY_TIMEOUT_MS = 15_000; + +/** MCP servers configured for every provider instance on one environment. */ +export const fetchEnvironmentMcpInventory = Effect.fn( + "clientRuntime.state.fetchEnvironmentMcpInventory", +)(function* (input: { readonly prepared: PreparedConnection; readonly timeoutMs?: number }) { + const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/mcp-servers"); + const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); + const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); + const headers = yield* buildEnvironmentAuthHeaders( + input.prepared.httpAuthorization, + "GET", + requestUrl, + signer, + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + input.timeoutMs ?? DEFAULT_MCP_INVENTORY_TIMEOUT_MS, + withEnvironmentCredentials( + input.prepared.httpAuthorization, + client.mcpServers.inventory({ headers }), + ), + ); +}); diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 2d40dad60cc..ad44fcad2c9 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -26,6 +26,7 @@ import { } from "./auth.ts"; import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { McpServerInventory } from "./mcpInventory.ts"; import { ClientOrchestrationCommand, DispatchResult, @@ -489,6 +490,14 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr }).middleware(EnvironmentAuthenticatedAuth), ) {} +export class EnvironmentMcpServersHttpApi extends HttpApiGroup.make("mcpServers").add( + HttpApiEndpoint.get("inventory", "/api/mcp-servers", { + headers: OptionalBearerHeaders, + success: McpServerInventory, + error: EnvironmentOrchestrationSnapshotErrors, + }).middleware(EnvironmentAuthenticatedAuth), +) {} + export class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") .add( HttpApiEndpoint.post("linkProof", "/api/connect/link-proof", { @@ -554,4 +563,5 @@ export class EnvironmentHttpApi extends HttpApi.make("environment") .add(EnvironmentMetadataHttpApi) .add(EnvironmentAuthHttpApi) .add(EnvironmentOrchestrationHttpApi) + .add(EnvironmentMcpServersHttpApi) .add(EnvironmentConnectHttpApi) {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f0ee1889177..d179ed3ff6a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -13,6 +13,7 @@ export * from "./providerInstance.ts"; export * from "./providerRuntime.ts"; export * from "./model.ts"; export * from "./keybindings.ts"; +export * from "./mcpInventory.ts"; export * from "./server.ts"; export * from "./settings.ts"; export * from "./git.ts"; diff --git a/packages/contracts/src/mcpInventory.ts b/packages/contracts/src/mcpInventory.ts new file mode 100644 index 00000000000..eed8e1d3785 --- /dev/null +++ b/packages/contracts/src/mcpInventory.ts @@ -0,0 +1,59 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { ForwardCompatibleArray, IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; + +export const McpServerTransport = Schema.Literals(["stdio", "http", "sse"]); +export type McpServerTransport = typeof McpServerTransport.Type; + +export const McpServerInventoryEntry = Schema.Struct({ + providerInstanceId: ProviderInstanceId, + harness: ProviderDriverKind, + harnessDisplayName: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + transport: McpServerTransport, + /** Command line for stdio servers, URL for http/sse servers. Never carries env values. */ + detail: Schema.optional(TrimmedNonEmptyString), + /** Config file the entry was declared in, when the harness reports one. */ + configPath: Schema.optional(TrimmedNonEmptyString), + /** Claude scopes servers as user, project (`.mcp.json`), or local. */ + scope: Schema.optional(Schema.Literals(["user", "project", "local"])), + /** Harness-reported runtime state, e.g. Codex auth status. */ + status: Schema.optional(TrimmedNonEmptyString), + /** + * Whether the harness will actually load this server. Codex reports its own + * `enabled` flag; Claude has no per-server switch, so its entries are always + * enabled. + */ + enabled: Schema.Boolean, +}); +export type McpServerInventoryEntry = typeof McpServerInventoryEntry.Type; + +export const McpServerUnreadableConfig = Schema.Struct({ + providerInstanceId: ProviderInstanceId, + harnessDisplayName: TrimmedNonEmptyString, + /** Config file that exists but could not be parsed, when one can be named. */ + configPath: Schema.optional(TrimmedNonEmptyString), +}); +export type McpServerUnreadableConfig = typeof McpServerUnreadableConfig.Type; + +export const McpServerInventory = Schema.Struct({ + scannedAt: IsoDateTime, + /** + * Forward compatible: `transport` and `scope` are closed literal unions that + * will grow (MCP keeps adding transports). A client one release behind drops + * the rows it cannot decode instead of failing the whole page. + */ + servers: ForwardCompatibleArray(McpServerInventoryEntry), + /** + * Instances whose config exists but could not be fully read. Without this an + * unparseable config is indistinguishable from "nothing configured": the + * listing is empty, and the empty state would claim there are no servers when + * the truth is that we could not tell. + */ + unreadable: ForwardCompatibleArray(McpServerUnreadableConfig).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), +}); +export type McpServerInventory = typeof McpServerInventory.Type;