From eb7d64bcc97cffbf4cfd0d433076b8ec609f481e Mon Sep 17 00:00:00 2001 From: Bartlomiej Zimny Date: Sun, 2 Aug 2026 12:33:53 +0200 Subject: [PATCH 1/7] feat(contracts): add an MCP server inventory contract T3 Code injects itself into every session as an MCP server but has no vocabulary for the MCP servers the harness CLIs load, so nothing can report them. Adds `McpServerInventory` plus a `GET /api/mcp-servers` endpoint on the environment HTTP API, and the client-runtime accessor for it. The server list is a `ForwardCompatibleArray` because `transport` and `scope` are closed literal unions that will grow: a client one release behind should drop the rows it cannot decode, not fail the whole page. Co-authored-by: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S2pbgjTL7nueRLDrgKhp9T --- packages/client-runtime/package.json | 4 +++ packages/client-runtime/src/state/mcp.ts | 33 ++++++++++++++++++ packages/contracts/src/environmentHttp.ts | 10 ++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/mcpInventory.ts | 41 +++++++++++++++++++++++ 5 files changed, 89 insertions(+) create mode 100644 packages/client-runtime/src/state/mcp.ts create mode 100644 packages/contracts/src/mcpInventory.ts 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..40a0764e2d6 --- /dev/null +++ b/packages/contracts/src/mcpInventory.ts @@ -0,0 +1,41 @@ +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 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), +}); +export type McpServerInventory = typeof McpServerInventory.Type; From 5c63f1ce4de980db7043790b87a71061c47c0573 Mon Sep 17 00:00:00 2001 From: Bartlomiej Zimny Date: Sun, 2 Aug 2026 12:34:08 +0200 Subject: [PATCH 2/7] feat(server): discover the MCP servers Claude Code loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no way to see which MCP servers an agent will actually load without opening a terminal on the machine running it — awkward, since that machine is often being driven remotely through this GUI. Adds `apps/server/src/mcpServers/`, which reads Claude Code's own config: user scope and workspace `local` scope from `.claude.json`, plus approved `.mcp.json` project servers. Reading the files directly avoids `claude mcp list`, which health-checks every server over the network and is far too slow for a settings page. Nothing is ever written back. Two things the read is careful about: - `complete` is false whenever a config exists but cannot be parsed, and whenever no workspace cwd was supplied — the `local` and `project` scopes are then unreadable, so the list is knowingly partial. A future caller that replaces the CLI's own resolution with `--strict-mcp-config` must refuse to act on an incomplete list or it will silently drop working servers. - The `projects` lookup resolves the real path first. Claude Code keys that map by resolved path, so a cwd arriving through a symlink (`/tmp` on macOS, a linked worktree) would otherwise miss in silence. Secrets stop at the boundary: `env` is never serialised, and arguments and URLs are rendered through a shape allowlist plus a credential-flag check. Neither signal is sufficient alone — shape catches `postgres://u:p@host/db` but would print a bare `hunter2`; a flag blocklist catches `--token secret` but misses everything positional. Only Claude is covered. 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. Co-authored-by: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S2pbgjTL7nueRLDrgKhp9T --- .../src/mcpServers/ClaudeMcpConfig.test.ts | 223 ++++++++++++++++ apps/server/src/mcpServers/ClaudeMcpConfig.ts | 223 ++++++++++++++++ .../src/mcpServers/McpServerInventory.test.ts | 143 +++++++++++ .../src/mcpServers/McpServerInventory.ts | 238 ++++++++++++++++++ apps/server/src/mcpServers/http.ts | 41 +++ apps/server/src/server.ts | 2 + 6 files changed, 870 insertions(+) create mode 100644 apps/server/src/mcpServers/ClaudeMcpConfig.test.ts create mode 100644 apps/server/src/mcpServers/ClaudeMcpConfig.ts create mode 100644 apps/server/src/mcpServers/McpServerInventory.test.ts create mode 100644 apps/server/src/mcpServers/McpServerInventory.ts create mode 100644 apps/server/src/mcpServers/http.ts diff --git a/apps/server/src/mcpServers/ClaudeMcpConfig.test.ts b/apps/server/src/mcpServers/ClaudeMcpConfig.test.ts new file mode 100644 index 00000000000..cbb7cfc2bce --- /dev/null +++ b/apps/server/src/mcpServers/ClaudeMcpConfig.test.ts @@ -0,0 +1,223 @@ +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); + }).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..5de43617c2e --- /dev/null +++ b/apps/server/src/mcpServers/ClaudeMcpConfig.ts @@ -0,0 +1,223 @@ +/** + * 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; +} + +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" }; + } + if (info.type !== "File") return { kind: "absent" }; + 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 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()] }; + } + + // 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; + 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()] }; +}); diff --git a/apps/server/src/mcpServers/McpServerInventory.test.ts b/apps/server/src/mcpServers/McpServerInventory.test.ts new file mode 100644 index 00000000000..4a39227c728 --- /dev/null +++ b/apps/server/src/mcpServers/McpServerInventory.test.ts @@ -0,0 +1,143 @@ +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("ignores non-string arguments", () => { + assert.equal(stdioDetail("server", ["--flag", 42, null]), "server --flag"); + }); +}); + +describe("remoteDetail", () => { + it("keeps the addressable part of a URL", () => { + assert.equal(remoteDetail("https://mcp.example.com/sse"), "https://mcp.example.com/sse"); + }); + + it("drops query strings, fragments, and userinfo", () => { + assert.equal( + remoteDetail("https://mcp.example.com/sse?api_key=sk-secret"), + "https://mcp.example.com/sse", + ); + assert.equal( + remoteDetail("https://token@mcp.example.com/mcp"), + "https://…@mcp.example.com/mcp", + ); + }); + + 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("flags rows when a config file cannot be read", () => + 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 = yield* discoverClaudeMcpServerEntries( + "claudeAgent", + { driver: ProviderDriverKind.make("claudeAgent"), config: { homePath: tempDir } }, + {}, + workspace, + ); + + assert.deepEqual( + entries.map((entry) => ({ name: entry.name, status: entry.status })), + [{ name: "codegraph", status: "config unreadable" }], + ); + }).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..387ac171ed0 --- /dev/null +++ b/apps/server/src/mcpServers/McpServerInventory.ts @@ -0,0 +1,238 @@ +/** + * 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, + 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; + +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; + +/** + * 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) { + redactNext = false; + // A flag cannot be the value of the previous flag, so it is not redacted. + if (!part.startsWith("-")) { + 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); + } + return [command, ...rendered].join(" "); +} + +/** + * Render a remote server's URL without its credentials. Query strings, + * fragments, and userinfo routinely carry API keys (`?api_key=…`, + * `https://token@host/mcp`), so only the addressable part is shown. + */ +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}${url.pathname}`; + } catch { + // Not a parseable URL, so nothing can be said about which part is a secret. + return REDACTED; + } +} + +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, never, McpInventoryEnv> { + const decoded = decodeClaudeSettings(instance.config ?? {}); + if (decoded._tag === "None") return []; + const settings = decoded.value; + + const { complete, definitions } = 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: harnessDisplayName(instance, "Claude"), + name, + transport, + ...(detail ? { detail } : {}), + configPath: sourcePath, + scope, + // The listing may be missing servers the CLI would load, so say so rather + // than presenting a partial list as the whole truth. + ...(complete ? {} : { status: "config unreadable" }), + // Claude Code has no per-server enable flag: everything it resolves is + // loaded. + enabled: true, + }); + } + return entries; +}); + +const discoverInstanceMcpServers = Effect.fn("McpServerInventory.discoverInstanceMcpServers")( + function* ( + instanceId: string, + instance: ProviderInstanceConfig, + environment: NodeJS.ProcessEnv, + cwd: string | undefined, + ): Effect.fn.Return, never, McpInventoryEnv> { + // A disabled instance never starts a session, so listing its servers would + // describe work that cannot happen. + if (instance.enabled === false) return []; + if (instance.driver === "claudeAgent") { + return yield* discoverClaudeMcpServerEntries(instanceId, instance, environment, cwd); + } + return []; + }, +); + +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 + .flat() + .sort( + (left, right) => + left.harnessDisplayName.localeCompare(right.harnessDisplayName) || + left.name.localeCompare(right.name), + ), + }; +}); 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), ), From 6f51c66df798d28ed2afde48c8e84cb81285fd9e Mon Sep 17 00:00:00 2001 From: Bartlomiej Zimny Date: Sun, 2 Aug 2026 12:34:18 +0200 Subject: [PATCH 3/7] =?UTF-8?q?feat(web):=20add=20a=20Settings=20=E2=86=92?= =?UTF-8?q?=20MCP=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the inventory: one collapsible group per connected computer, one per Claude provider inside it, with transport and scope badges, the redacted command line or address, and a click-to-copy config path. Search filters by name, detail, harness, or path. The page is read-only. It reports what Claude Code will load; changing that still belongs to the harness. Registered in `settingsSearch.ts` rather than the nav list directly, since the sidebar derives its items from `SETTINGS_SECTION_LABELS` — so the section is reachable from both the sidebar and the settings search, and `SETTINGS_SECTION_ICONS` being a total record keeps the two from drifting apart. Co-authored-by: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S2pbgjTL7nueRLDrgKhp9T --- .../settings/McpServersSettings.logic.test.ts | 73 +++++ .../settings/McpServersSettings.logic.ts | 81 +++++ .../settings/McpServersSettings.tsx | 289 ++++++++++++++++++ .../settings/SettingsSidebarNav.tsx | 2 + .../src/components/settings/settingsSearch.ts | 7 + apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/settings.mcp.tsx | 7 + 7 files changed, 480 insertions(+) create mode 100644 apps/web/src/components/settings/McpServersSettings.logic.test.ts create mode 100644 apps/web/src/components/settings/McpServersSettings.logic.ts create mode 100644 apps/web/src/components/settings/McpServersSettings.tsx create mode 100644 apps/web/src/routes/settings.mcp.tsx 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..b4e90280c22 --- /dev/null +++ b/apps/web/src/components/settings/McpServersSettings.logic.test.ts @@ -0,0 +1,73 @@ +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, +}); + +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..4fbca4c55b7 --- /dev/null +++ b/apps/web/src/components/settings/McpServersSettings.logic.ts @@ -0,0 +1,81 @@ +import type { + McpServerInventory, + McpServerInventoryEntry, + 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("/")}`; +} + +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..069bbdf2e05 --- /dev/null +++ b/apps/web/src/components/settings/McpServersSettings.tsx @@ -0,0 +1,289 @@ +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, +} 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; + + return ( +
+ + + {collapsed ? null : ( +
+ {Option.isNone(prepared) ? ( + Not connected. + ) : state.status === "loading" ? ( + Loading MCP servers… + ) : state.status === "error" ? ( + {state.message} + ) : groups.length === 0 ? ( + + {query.trim() + ? "No MCP servers match this search." + : "No MCP servers configured for Claude Code on this computer."} + + ) : ( + 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 d9d381c02ab..8cee1026fdc 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, +}); From 7919000952a482b5a5d1dce0a2cbf0b6d8f570e1 Mon Sep 17 00:00:00 2001 From: Bartlomiej Zimny Date: Sun, 2 Aug 2026 12:34:33 +0200 Subject: [PATCH 4/7] docs: document MCP servers `docs/` had no mention of MCP at all, and this adds a user-visible concept, so both audiences need an entry point. `docs/user/mcp.md` covers the page, which harnesses are and are not listed and why, what "config unreadable" means, and how to actually remove a server (through Claude Code). `docs/internals/providers.md` gets the maintainer view, including the two invariants that are easy to break later: `complete` is load-bearing, and nothing secret crosses the wire. Two glossary entries disambiguate the collision between `apps/server/src/mcp/` (T3 Code hosting an MCP server) and `apps/server/src/mcpServers/` (the user's own). Co-authored-by: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S2pbgjTL7nueRLDrgKhp9T --- docs/README.md | 1 + docs/internals/glossary.md | 14 +++++++++ docs/internals/providers.md | 33 ++++++++++++++++++++ docs/user/mcp.md | 62 +++++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 docs/user/mcp.md 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..37743b2fae2 --- /dev/null +++ b/docs/user/mcp.md @@ -0,0 +1,62 @@ +# 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, and anything in an argument or URL that could be a credential is replaced with `…`. + The full value stays in your config; T3 Code just 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 + +A row marked **config unreadable** means the config exists but T3 Code could not parse it — +malformed JSON, a permissions problem, or a `.claude.json` grown too large to read on demand. The +list may be missing servers. + +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. From 6e959f834e358240797307e690ff1f04efdba405 Mon Sep 17 00:00:00 2001 From: Bartlomiej Zimny Date: Sun, 2 Aug 2026 13:10:48 +0200 Subject: [PATCH 5/7] fix(server): stop an unreadable Claude config from reading as "no servers" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial testing turned up a lying empty state. When `.claude.json` exists but cannot be parsed — malformed JSON, or larger than the read guard — discovery recovers zero entries, so the per-row "config unreadable" marker has nothing to attach to and the page says "No MCP servers configured for Claude Code on this computer." The config is right there; we just could not read it. Reports unreadable configs on their own channel in the inventory rather than as a property of rows that may not exist, and the empty state now says the servers are unknown instead of claiming there are none. Also bounds and sanitises server names. They come from a file we do not control, and the same pass produced entries carrying a U+202E override (which reorders how a row reads on screen) and a 5000-character name. React escapes markup, so this is about what a name can look like, not injection. Checked while I was in there, all clean: `__proto__` / `constructor` / `prototype` as server names do not pollute anything (`Object.entries` plus a `Map`, never assignment); a `command` that is an array or number, a null entry, and an unknown transport all degrade instead of throwing; 5000 levels of nesting parse in 2ms; the 8 MB guard catches a 12 MB config. Co-authored-by: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S2pbgjTL7nueRLDrgKhp9T --- apps/server/src/mcpServers/ClaudeMcpConfig.ts | 6 +- .../src/mcpServers/McpServerInventory.test.ts | 68 ++++++++++++++++++- .../src/mcpServers/McpServerInventory.ts | 64 ++++++++++++++--- .../settings/McpServersSettings.logic.test.ts | 1 + .../settings/McpServersSettings.tsx | 7 +- packages/contracts/src/mcpInventory.ts | 18 +++++ 6 files changed, 148 insertions(+), 16 deletions(-) diff --git a/apps/server/src/mcpServers/ClaudeMcpConfig.ts b/apps/server/src/mcpServers/ClaudeMcpConfig.ts index 5de43617c2e..db9162b7a64 100644 --- a/apps/server/src/mcpServers/ClaudeMcpConfig.ts +++ b/apps/server/src/mcpServers/ClaudeMcpConfig.ts @@ -50,6 +50,8 @@ export interface ClaudeMcpServerRead { */ readonly complete: boolean; readonly definitions: ReadonlyArray; + /** The `.claude.json` this read resolved to, whether or not it parsed. */ + readonly configPath: string; } function readServerMap(value: unknown): ReadonlyArray]> { @@ -183,7 +185,7 @@ export const readClaudeMcpServers = Effect.fn("ClaudeMcpConfig.readClaudeMcpServ // 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()] }; + return { complete: false, definitions: [...byName.values()], configPath }; } // Claude Code keys `projects` by the resolved real path. Matching on the raw @@ -219,5 +221,5 @@ export const readClaudeMcpServers = Effect.fn("ClaudeMcpConfig.readClaudeMcpServ byName.set(name, { name, scope: "local", sourcePath: configPath, definition }); } - return { complete, definitions: [...byName.values()] }; + return { complete, definitions: [...byName.values()], configPath }; }); diff --git a/apps/server/src/mcpServers/McpServerInventory.test.ts b/apps/server/src/mcpServers/McpServerInventory.test.ts index 4a39227c728..3be8d294aa5 100644 --- a/apps/server/src/mcpServers/McpServerInventory.test.ts +++ b/apps/server/src/mcpServers/McpServerInventory.test.ts @@ -87,7 +87,7 @@ it.layer(NodeServices.layer)("claude inventory entries", (it) => { // A malformed workspace `.mcp.json` leaves the project scope unknown. yield* fs.writeFileString(path.join(workspace, ".mcp.json"), "{ not json"); - const entries = yield* discoverClaudeMcpServerEntries( + const { entries, unreadable } = yield* discoverClaudeMcpServerEntries( "claudeAgent", { driver: ProviderDriverKind.make("claudeAgent"), config: { homePath: tempDir } }, {}, @@ -98,6 +98,70 @@ it.layer(NodeServices.layer)("claude inventory entries", (it) => { entries.map((entry) => ({ name: entry.name, status: entry.status })), [{ name: "codegraph", status: "config unreadable" }], ); + assert.equal(unreadable.length, 1); + }).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), ); @@ -113,7 +177,7 @@ it.layer(NodeServices.layer)("claude inventory entries", (it) => { '{ "mcpServers": { "codegraph": { "command": "codegraph", "args": ["--token", "s3cret"] } } }', ); - const entries = yield* discoverClaudeMcpServerEntries( + const { entries } = yield* discoverClaudeMcpServerEntries( "claudeAgent", { driver: ProviderDriverKind.make("claudeAgent"), config: { homePath: tempDir } }, {}, diff --git a/apps/server/src/mcpServers/McpServerInventory.ts b/apps/server/src/mcpServers/McpServerInventory.ts index 387ac171ed0..e638013d17c 100644 --- a/apps/server/src/mcpServers/McpServerInventory.ts +++ b/apps/server/src/mcpServers/McpServerInventory.ts @@ -25,6 +25,7 @@ import type { McpServerInventory, McpServerInventoryEntry, McpServerTransport, + McpServerUnreadableConfig, ProviderInstanceConfig, ServerSettings, } from "@t3tools/contracts"; @@ -46,6 +47,13 @@ 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; } @@ -138,6 +146,22 @@ export function remoteDetail(rawUrl: string | undefined): string | undefined { } } +/** + * 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(); + const safe = stripped.length > 0 ? stripped : name.trim(); + 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; @@ -153,12 +177,17 @@ export const discoverClaudeMcpServerEntries = Effect.fn( instance: ProviderInstanceConfig, environment: NodeJS.ProcessEnv, cwd: string | undefined, -): Effect.fn.Return, never, McpInventoryEnv> { +): Effect.fn.Return { const decoded = decodeClaudeSettings(instance.config ?? {}); - if (decoded._tag === "None") return []; + if (decoded._tag === "None") return EMPTY_DISCOVERY; const settings = decoded.value; - const { complete, definitions } = yield* readClaudeMcpServers(settings, environment, cwd); + const displayName = harnessDisplayName(instance, "Claude"); + const { complete, definitions, configPath } = yield* readClaudeMcpServers( + settings, + environment, + cwd, + ); const entries: McpServerInventoryEntry[] = []; for (const { name, scope, sourcePath, definition: entry } of definitions) { @@ -172,8 +201,8 @@ export const discoverClaudeMcpServerEntries = Effect.fn( entries.push({ providerInstanceId: ProviderInstanceId.make(instanceId), harness: instance.driver, - harnessDisplayName: harnessDisplayName(instance, "Claude"), - name, + harnessDisplayName: displayName, + name: sanitizeDisplayName(name), transport, ...(detail ? { detail } : {}), configPath: sourcePath, @@ -186,7 +215,21 @@ export const discoverClaudeMcpServerEntries = Effect.fn( enabled: true, }); } - return entries; + + return { + entries, + // Reported separately from the rows because an unreadable config often + // yields *no* rows at all, and then there is nothing to hang a status on. + unreadable: complete + ? [] + : [ + { + providerInstanceId: ProviderInstanceId.make(instanceId), + harnessDisplayName: displayName, + configPath, + }, + ], + }; }); const discoverInstanceMcpServers = Effect.fn("McpServerInventory.discoverInstanceMcpServers")( @@ -195,14 +238,14 @@ const discoverInstanceMcpServers = Effect.fn("McpServerInventory.discoverInstanc instance: ProviderInstanceConfig, environment: NodeJS.ProcessEnv, cwd: string | undefined, - ): Effect.fn.Return, never, McpInventoryEnv> { + ): 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 []; + if (instance.enabled === false) return EMPTY_DISCOVERY; if (instance.driver === "claudeAgent") { return yield* discoverClaudeMcpServerEntries(instanceId, instance, environment, cwd); } - return []; + return EMPTY_DISCOVERY; }, ); @@ -228,11 +271,12 @@ export const discoverGlobalMcpInventory = Effect.fn( return { scannedAt: DateTime.formatIso(yield* DateTime.now), servers: discovered - .flat() + .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/web/src/components/settings/McpServersSettings.logic.test.ts b/apps/web/src/components/settings/McpServersSettings.logic.test.ts index b4e90280c22..ee3f0df8800 100644 --- a/apps/web/src/components/settings/McpServersSettings.logic.test.ts +++ b/apps/web/src/components/settings/McpServersSettings.logic.test.ts @@ -22,6 +22,7 @@ const makeServer = ( const inventoryOf = (servers: ReadonlyArray): McpServerInventory => ({ scannedAt: "2026-07-27T00:00:00.000Z", servers, + unreadable: [], }); describe("filterMcpInventory", () => { diff --git a/apps/web/src/components/settings/McpServersSettings.tsx b/apps/web/src/components/settings/McpServersSettings.tsx index 069bbdf2e05..3bb78f4a78a 100644 --- a/apps/web/src/components/settings/McpServersSettings.tsx +++ b/apps/web/src/components/settings/McpServersSettings.tsx @@ -166,6 +166,7 @@ function EnvironmentMcpInventory({ }, [state, query]); const serverCount = state.status === "loaded" ? state.inventory.servers.length : null; + const unreadableCount = state.status === "loaded" ? state.inventory.unreadable.length : 0; return (
@@ -201,10 +202,12 @@ function EnvironmentMcpInventory({ ) : state.status === "error" ? ( {state.message} ) : groups.length === 0 ? ( - + 0 && !query.trim() ? { tone: "error" } : {})}> {query.trim() ? "No MCP servers match this search." - : "No MCP servers configured for Claude Code on this computer."} + : unreadableCount > 0 + ? "Could not read the Claude Code config on this computer, so its MCP servers are unknown." + : "No MCP servers configured for Claude Code on this computer."} ) : ( groups.map((group) => ( diff --git a/packages/contracts/src/mcpInventory.ts b/packages/contracts/src/mcpInventory.ts index 40a0764e2d6..eed8e1d3785 100644 --- a/packages/contracts/src/mcpInventory.ts +++ b/packages/contracts/src/mcpInventory.ts @@ -1,3 +1,4 @@ +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { ForwardCompatibleArray, IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; @@ -29,6 +30,14 @@ export const McpServerInventoryEntry = Schema.Struct({ }); 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, /** @@ -37,5 +46,14 @@ export const McpServerInventory = Schema.Struct({ * 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; From 7d69fd4dc0d10d62ad1737f15366b9aaa938bc49 Mon Sep 17 00:00:00 2001 From: Bartlomiej Zimny Date: Sun, 2 Aug 2026 13:26:32 +0200 Subject: [PATCH 6/7] fix(mcp): close the redaction gaps and blame the config that failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found three ways a credential could reach the inventory response: a flag value starting with `-` skipped redaction, `command` was echoed verbatim, and a remote URL kept its path. All three now go through the same policy the arguments already used. An unreadable `.mcp.json` also reported `.claude.json` as the broken file and marked every valid row "config unreadable". Read now names each file that failed, and rows carry no status — every row came out of a config that parsed. A `.claude.json` that exists as a directory is unreadable, not absent. Claude-Session: https://claude.ai/code/session_01Wwhp8pRacpU9VBcpSwXfgE --- .../src/mcpServers/ClaudeMcpConfig.test.ts | 36 ++++++++++ apps/server/src/mcpServers/ClaudeMcpConfig.ts | 20 ++++-- .../src/mcpServers/McpServerInventory.test.ts | 44 ++++++++---- .../src/mcpServers/McpServerInventory.ts | 68 ++++++++++++------- .../settings/McpServersSettings.logic.ts | 15 ++++ .../settings/McpServersSettings.tsx | 43 +++++++----- docs/user/mcp.md | 14 ++-- 7 files changed, 177 insertions(+), 63 deletions(-) diff --git a/apps/server/src/mcpServers/ClaudeMcpConfig.test.ts b/apps/server/src/mcpServers/ClaudeMcpConfig.test.ts index cbb7cfc2bce..584da4b856a 100644 --- a/apps/server/src/mcpServers/ClaudeMcpConfig.test.ts +++ b/apps/server/src/mcpServers/ClaudeMcpConfig.test.ts @@ -65,6 +65,42 @@ it.layer(NodeServices.layer)("readClaudeMcpServers", (it) => { 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), ); diff --git a/apps/server/src/mcpServers/ClaudeMcpConfig.ts b/apps/server/src/mcpServers/ClaudeMcpConfig.ts index db9162b7a64..0795b703247 100644 --- a/apps/server/src/mcpServers/ClaudeMcpConfig.ts +++ b/apps/server/src/mcpServers/ClaudeMcpConfig.ts @@ -52,6 +52,12 @@ export interface ClaudeMcpServerRead { 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]> { @@ -100,7 +106,9 @@ const readJsonObject = Effect.fn("ClaudeMcpConfig.readJsonObject")(function* ( const exists = yield* fileSystem.exists(filePath).pipe(Effect.orElseSucceed(() => true)); return exists ? { kind: "unusable" } : { kind: "absent" }; } - if (info.type !== "File") return { 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 @@ -175,6 +183,7 @@ export const readClaudeMcpServers = Effect.fn("ClaudeMcpConfig.readClaudeMcpServ 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)) { @@ -185,7 +194,7 @@ export const readClaudeMcpServers = Effect.fn("ClaudeMcpConfig.readClaudeMcpServ // 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 }; + return { complete: false, definitions: [...byName.values()], configPath, unreadablePaths }; } // Claude Code keys `projects` by the resolved real path. Matching on the raw @@ -208,7 +217,10 @@ export const readClaudeMcpServers = Effect.fn("ClaudeMcpConfig.readClaudeMcpServ 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; + 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)); @@ -221,5 +233,5 @@ export const readClaudeMcpServers = Effect.fn("ClaudeMcpConfig.readClaudeMcpServ byName.set(name, { name, scope: "local", sourcePath: configPath, definition }); } - return { complete, definitions: [...byName.values()], configPath }; + 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 index 3be8d294aa5..26a2b05b978 100644 --- a/apps/server/src/mcpServers/McpServerInventory.test.ts +++ b/apps/server/src/mcpServers/McpServerInventory.test.ts @@ -45,25 +45,38 @@ describe("stdioDetail", () => { ); }); + 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 …"); + }); + + 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 addressable part of a URL", () => { - assert.equal(remoteDetail("https://mcp.example.com/sse"), "https://mcp.example.com/sse"); + it("keeps the origin, which is what identifies the server", () => { + assert.equal(remoteDetail("https://mcp.example.com/sse"), "https://mcp.example.com"); }); - it("drops query strings, fragments, and userinfo", () => { + it("drops paths, query strings, fragments, and userinfo", () => { assert.equal( remoteDetail("https://mcp.example.com/sse?api_key=sk-secret"), - "https://mcp.example.com/sse", - ); - assert.equal( - remoteDetail("https://token@mcp.example.com/mcp"), - "https://…@mcp.example.com/mcp", + "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", () => { @@ -73,7 +86,7 @@ describe("remoteDetail", () => { }); it.layer(NodeServices.layer)("claude inventory entries", (it) => { - it.effect("flags rows when a config file cannot be read", () => + 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; @@ -94,11 +107,18 @@ it.layer(NodeServices.layer)("claude inventory entries", (it) => { 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( - entries.map((entry) => ({ name: entry.name, status: entry.status })), - [{ name: "codegraph", status: "config unreadable" }], + unreadable.map((item) => item.configPath), + [path.join(resolvedWorkspace, ".mcp.json")], ); - assert.equal(unreadable.length, 1); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/mcpServers/McpServerInventory.ts b/apps/server/src/mcpServers/McpServerInventory.ts index e638013d17c..84268d6d002 100644 --- a/apps/server/src/mcpServers/McpServerInventory.ts +++ b/apps/server/src/mcpServers/McpServerInventory.ts @@ -77,6 +77,11 @@ const SAFE_ARGUMENT_PATTERN = 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 @@ -99,11 +104,10 @@ export function stdioDetail(command: string, args: unknown): string { for (const part of parts) { if (redactNext) { redactNext = false; - // A flag cannot be the value of the previous flag, so it is not redacted. - if (!part.startsWith("-")) { - rendered.push(REDACTED); - continue; - } + // Redact whatever it looks like: a credential can start with `-`, so + // "it parses as a flag" is not evidence that it is one. + 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. @@ -126,20 +130,23 @@ export function stdioDetail(command: string, args: unknown): string { if (part.startsWith("-") && SECRET_HINT_PATTERN.test(part)) redactNext = true; rendered.push(part); } - return [command, ...rendered].join(" "); + // 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. Query strings, - * fragments, and userinfo routinely carry API keys (`?api_key=…`, - * `https://token@host/mcp`), so only the addressable part is shown. + * 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}${url.pathname}`; + 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; @@ -156,7 +163,9 @@ const MAX_DISPLAY_NAME_LENGTH = 120; function sanitizeDisplayName(name: string): string { const stripped = name.replace(/[\p{Cc}\p{Cf}]/gu, "").trim(); - const safe = stripped.length > 0 ? stripped : name.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; @@ -183,7 +192,7 @@ export const discoverClaudeMcpServerEntries = Effect.fn( const settings = decoded.value; const displayName = harnessDisplayName(instance, "Claude"); - const { complete, definitions, configPath } = yield* readClaudeMcpServers( + const { complete, definitions, unreadablePaths } = yield* readClaudeMcpServers( settings, environment, cwd, @@ -207,28 +216,35 @@ export const discoverClaudeMcpServerEntries = Effect.fn( ...(detail ? { detail } : {}), configPath: sourcePath, scope, - // The listing may be missing servers the CLI would load, so say so rather - // than presenting a partial list as the whole truth. - ...(complete ? {} : { status: "config unreadable" }), // 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, - // Reported separately from the rows because an unreadable config often - // yields *no* rows at all, and then there is nothing to hang a status on. - unreadable: complete - ? [] - : [ - { - providerInstanceId: ProviderInstanceId.make(instanceId), - harnessDisplayName: displayName, - configPath, - }, - ], + 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, + }, + ], }; }); diff --git a/apps/web/src/components/settings/McpServersSettings.logic.ts b/apps/web/src/components/settings/McpServersSettings.logic.ts index 4fbca4c55b7..482e3215046 100644 --- a/apps/web/src/components/settings/McpServersSettings.logic.ts +++ b/apps/web/src/components/settings/McpServersSettings.logic.ts @@ -1,6 +1,7 @@ import type { McpServerInventory, McpServerInventoryEntry, + McpServerUnreadableConfig, ProviderDriverKind, } from "@t3tools/contracts"; @@ -18,6 +19,20 @@ export function formatMcpConfigPath(path: string): string { 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, diff --git a/apps/web/src/components/settings/McpServersSettings.tsx b/apps/web/src/components/settings/McpServersSettings.tsx index 3bb78f4a78a..03a64877cec 100644 --- a/apps/web/src/components/settings/McpServersSettings.tsx +++ b/apps/web/src/components/settings/McpServersSettings.tsx @@ -19,6 +19,7 @@ import { formatMcpConfigPath, groupMcpServersByHarness, mcpServerKey, + unreadableConfigMessage, } from "./McpServersSettings.logic"; type InventoryState = @@ -166,7 +167,8 @@ function EnvironmentMcpInventory({ }, [state, query]); const serverCount = state.status === "loaded" ? state.inventory.servers.length : null; - const unreadableCount = state.status === "loaded" ? state.inventory.unreadable.length : 0; + const unreadable = state.status === "loaded" ? state.inventory.unreadable : []; + const unreadableCount = unreadable.length; return (
@@ -206,24 +208,33 @@ function EnvironmentMcpInventory({ {query.trim() ? "No MCP servers match this search." : unreadableCount > 0 - ? "Could not read the Claude Code config on this computer, so its MCP servers are unknown." + ? `${unreadableConfigMessage(unreadable)}, so the MCP servers declared there are unknown.` : "No MCP servers configured for Claude Code on this computer."} ) : ( - groups.map((group) => ( -
-

- {group.harnessDisplayName} -

- {group.servers.map((server) => ( - - ))} -
- )) + <> + {/* The rows below are accurate; what an unreadable config costs is + the rows it would have added, so this warns about the gap. */} + {unreadableCount > 0 && !query.trim() ? ( + + {`${unreadableConfigMessage(unreadable)}, so this list may be missing servers.`} + + ) : null} + {groups.map((group) => ( +
+

+ {group.harnessDisplayName} +

+ {group.servers.map((server) => ( + + ))} +
+ ))} + )}
)} diff --git a/docs/user/mcp.md b/docs/user/mcp.md index 37743b2fae2..8bcac9719aa 100644 --- a/docs/user/mcp.md +++ b/docs/user/mcp.md @@ -17,8 +17,11 @@ is a 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, and anything in an argument or URL that could be a credential is replaced with `…`. - The full value stays in your config; T3 Code just does not carry it across the network. + 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. @@ -36,9 +39,10 @@ resolves those outside the filesystem, where T3 Code cannot see them. ## When a config cannot be read -A row marked **config unreadable** means the config exists but T3 Code could not parse it — -malformed JSON, a permissions problem, or a `.claude.json` grown too large to read on demand. The -list may be missing servers. +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 From c175e6d6e8c36a3aadd54b0d2374ecc1ae024787 Mon Sep 17 00:00:00 2001 From: Bartlomiej Zimny Date: Sun, 2 Aug 2026 13:32:43 +0200 Subject: [PATCH 7/7] fix(mcp): re-arm redaction after a credential flag, keep the warning while searching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always redacting the argument after a credential flag consumed a following flag as that value, disarming the next one: `--token --api-key hunter2` rendered the secret verbatim. Either reading of the middle argument leaves `hunter2` a value, so redaction re-arms instead of clearing. The unreadable-config warning was hidden while a search was active, which is when it matters most — a config the search could not scan is exactly what a bare "no match" hides. Claude-Session: https://claude.ai/code/session_01Wwhp8pRacpU9VBcpSwXfgE --- .../src/mcpServers/McpServerInventory.test.ts | 3 +++ .../src/mcpServers/McpServerInventory.ts | 7 +++-- .../settings/McpServersSettings.tsx | 26 +++++++++++-------- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/apps/server/src/mcpServers/McpServerInventory.test.ts b/apps/server/src/mcpServers/McpServerInventory.test.ts index 26a2b05b978..5faf9000fcb 100644 --- a/apps/server/src/mcpServers/McpServerInventory.test.ts +++ b/apps/server/src/mcpServers/McpServerInventory.test.ts @@ -49,6 +49,9 @@ describe("stdioDetail", () => { // 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", () => { diff --git a/apps/server/src/mcpServers/McpServerInventory.ts b/apps/server/src/mcpServers/McpServerInventory.ts index 84268d6d002..557cbe829ce 100644 --- a/apps/server/src/mcpServers/McpServerInventory.ts +++ b/apps/server/src/mcpServers/McpServerInventory.ts @@ -103,9 +103,12 @@ export function stdioDetail(command: string, args: unknown): string { let redactNext = false; for (const part of parts) { if (redactNext) { - redactNext = false; // Redact whatever it looks like: a credential can start with `-`, so - // "it parses as a flag" is not evidence that it is one. + // "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; } diff --git a/apps/web/src/components/settings/McpServersSettings.tsx b/apps/web/src/components/settings/McpServersSettings.tsx index 03a64877cec..f225173e9aa 100644 --- a/apps/web/src/components/settings/McpServersSettings.tsx +++ b/apps/web/src/components/settings/McpServersSettings.tsx @@ -169,6 +169,16 @@ function EnvironmentMcpInventory({ 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 (
@@ -203,23 +213,17 @@ function EnvironmentMcpInventory({ Loading MCP servers… ) : state.status === "error" ? ( {state.message} - ) : groups.length === 0 ? ( - 0 && !query.trim() ? { tone: "error" } : {})}> - {query.trim() - ? "No MCP servers match this search." - : unreadableCount > 0 - ? `${unreadableConfigMessage(unreadable)}, so the MCP servers declared there are unknown.` - : "No MCP servers configured for Claude Code on this computer."} - ) : ( <> - {/* The rows below are accurate; what an unreadable config costs is - the rows it would have added, so this warns about the gap. */} - {unreadableCount > 0 && !query.trim() ? ( + {/* 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) => (