diff --git a/apps/codex-claw/src/routeTree.gen.ts b/apps/codex-claw/src/routeTree.gen.ts index 10adf2d..7f07e6c 100644 --- a/apps/codex-claw/src/routeTree.gen.ts +++ b/apps/codex-claw/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as ApiSendRouteImport } from './routes/api/send' import { Route as ApiRepoContextRouteImport } from './routes/api/repo-context' import { Route as ApiPingRouteImport } from './routes/api/ping' import { Route as ApiPathsRouteImport } from './routes/api/paths' +import { Route as ApiMcpHealthRouteImport } from './routes/api/mcp-health' import { Route as ApiHistoryRouteImport } from './routes/api/history' import { Route as ApiGitReviewRouteImport } from './routes/api/git-review' @@ -78,6 +79,11 @@ const ApiPathsRoute = ApiPathsRouteImport.update({ path: '/api/paths', getParentRoute: () => rootRouteImport, } as any) +const ApiMcpHealthRoute = ApiMcpHealthRouteImport.update({ + id: '/api/mcp-health', + path: '/api/mcp-health', + getParentRoute: () => rootRouteImport, +} as any) const ApiHistoryRoute = ApiHistoryRouteImport.update({ id: '/api/history', path: '/api/history', @@ -95,6 +101,7 @@ export interface FileRoutesByFullPath { '/new': typeof NewRoute '/api/git-review': typeof ApiGitReviewRoute '/api/history': typeof ApiHistoryRoute + '/api/mcp-health': typeof ApiMcpHealthRoute '/api/paths': typeof ApiPathsRoute '/api/ping': typeof ApiPingRoute '/api/repo-context': typeof ApiRepoContextRoute @@ -110,6 +117,7 @@ export interface FileRoutesByTo { '/new': typeof NewRoute '/api/git-review': typeof ApiGitReviewRoute '/api/history': typeof ApiHistoryRoute + '/api/mcp-health': typeof ApiMcpHealthRoute '/api/paths': typeof ApiPathsRoute '/api/ping': typeof ApiPingRoute '/api/repo-context': typeof ApiRepoContextRoute @@ -126,6 +134,7 @@ export interface FileRoutesById { '/new': typeof NewRoute '/api/git-review': typeof ApiGitReviewRoute '/api/history': typeof ApiHistoryRoute + '/api/mcp-health': typeof ApiMcpHealthRoute '/api/paths': typeof ApiPathsRoute '/api/ping': typeof ApiPingRoute '/api/repo-context': typeof ApiRepoContextRoute @@ -143,6 +152,7 @@ export interface FileRouteTypes { | '/new' | '/api/git-review' | '/api/history' + | '/api/mcp-health' | '/api/paths' | '/api/ping' | '/api/repo-context' @@ -158,6 +168,7 @@ export interface FileRouteTypes { | '/new' | '/api/git-review' | '/api/history' + | '/api/mcp-health' | '/api/paths' | '/api/ping' | '/api/repo-context' @@ -173,6 +184,7 @@ export interface FileRouteTypes { | '/new' | '/api/git-review' | '/api/history' + | '/api/mcp-health' | '/api/paths' | '/api/ping' | '/api/repo-context' @@ -189,6 +201,7 @@ export interface RootRouteChildren { NewRoute: typeof NewRoute ApiGitReviewRoute: typeof ApiGitReviewRoute ApiHistoryRoute: typeof ApiHistoryRoute + ApiMcpHealthRoute: typeof ApiMcpHealthRoute ApiPathsRoute: typeof ApiPathsRoute ApiPingRoute: typeof ApiPingRoute ApiRepoContextRoute: typeof ApiRepoContextRoute @@ -278,6 +291,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiPathsRouteImport parentRoute: typeof rootRouteImport } + '/api/mcp-health': { + id: '/api/mcp-health' + path: '/api/mcp-health' + fullPath: '/api/mcp-health' + preLoaderRoute: typeof ApiMcpHealthRouteImport + parentRoute: typeof rootRouteImport + } '/api/history': { id: '/api/history' path: '/api/history' @@ -301,6 +321,7 @@ const rootRouteChildren: RootRouteChildren = { NewRoute: NewRoute, ApiGitReviewRoute: ApiGitReviewRoute, ApiHistoryRoute: ApiHistoryRoute, + ApiMcpHealthRoute: ApiMcpHealthRoute, ApiPathsRoute: ApiPathsRoute, ApiPingRoute: ApiPingRoute, ApiRepoContextRoute: ApiRepoContextRoute, diff --git a/apps/codex-claw/src/routes/api/mcp-health.ts b/apps/codex-claw/src/routes/api/mcp-health.ts new file mode 100644 index 0000000..27fc7b1 --- /dev/null +++ b/apps/codex-claw/src/routes/api/mcp-health.ts @@ -0,0 +1,24 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' + +import { getMcpHealthPayload } from '../../server/mcp-health' + +export const Route = createFileRoute('/api/mcp-health')({ + server: { + handlers: { + GET: () => { + try { + return json(getMcpHealthPayload()) + } catch (err) { + return json( + { + ok: false, + error: err instanceof Error ? err.message : String(err), + }, + { status: 500 }, + ) + } + }, + }, + }, +}) diff --git a/apps/codex-claw/src/screens/chat/chat-queries.ts b/apps/codex-claw/src/screens/chat/chat-queries.ts index aeae065..7e95c8b 100644 --- a/apps/codex-claw/src/screens/chat/chat-queries.ts +++ b/apps/codex-claw/src/screens/chat/chat-queries.ts @@ -4,6 +4,7 @@ import type { GatewayMessage, GitReviewPayload, HistoryResponse, + McpHealthPayload, RepoContextPayload, RepoContextSelection, SessionListResponse, @@ -134,6 +135,12 @@ export async function fetchGitReview(): Promise { return (await res.json()) as GitReviewPayload } +export async function fetchMcpHealth(): Promise { + const res = await fetch('/api/mcp-health') + if (!res.ok) throw new Error(await readError(res)) + return (await res.json()) as McpHealthPayload +} + export async function stageGitReviewFiles( paths: Array, ): Promise { diff --git a/apps/codex-claw/src/screens/chat/chat-screen.tsx b/apps/codex-claw/src/screens/chat/chat-screen.tsx index ffd8d37..78f515f 100644 --- a/apps/codex-claw/src/screens/chat/chat-screen.tsx +++ b/apps/codex-claw/src/screens/chat/chat-screen.tsx @@ -24,6 +24,7 @@ import { ChatHeader } from './components/chat-header' import { ChatMessageList } from './components/chat-message-list' import { ChatComposer } from './components/chat-composer' import { GitReviewPanel } from './components/git-review-panel' +import { McpHealthPanel } from './components/mcp-health-panel' import { GatewayStatusMessage } from './components/gateway-status-message' import { hasPendingGeneration, @@ -71,6 +72,7 @@ export function ChatScreen({ const [creatingSession, setCreatingSession] = useState(false) const [isRedirecting, setIsRedirecting] = useState(false) const [gitReviewOpen, setGitReviewOpen] = useState(false) + const [mcpHealthOpen, setMcpHealthOpen] = useState(false) const { headerRef, composerRef, mainRef, pinGroupMinHeight, headerHeight } = useChatMeasurements() const [waitingForResponse, setWaitingForResponse] = useState( @@ -629,6 +631,12 @@ export function ChatScreen({ maxTokens={activeSession?.contextTokens} gitReviewOpen={gitReviewOpen} onToggleGitReview={() => setGitReviewOpen((current) => !current)} + mcpHealthOpen={mcpHealthOpen} + onToggleMcpHealth={() => setMcpHealthOpen((current) => !current)} + /> + setMcpHealthOpen(false)} /> void + mcpHealthOpen?: boolean + onToggleMcpHealth?: () => void } function ChatHeaderComponent({ @@ -33,6 +39,8 @@ function ChatHeaderComponent({ showExport = true, gitReviewOpen = false, onToggleGitReview, + mcpHealthOpen = false, + onToggleMcpHealth, }: ChatHeaderProps) { return (
- + ) : null}
@@ -62,7 +70,18 @@ function ChatHeaderComponent({ className={gitReviewOpen ? 'bg-primary-100' : undefined} aria-label="Review local changes" > - + + + ) : null} + {onToggleMcpHealth ? ( + ) : null} {showExport ? ( diff --git a/apps/codex-claw/src/screens/chat/components/mcp-health-panel.tsx b/apps/codex-claw/src/screens/chat/components/mcp-health-panel.tsx new file mode 100644 index 0000000..765ffe1 --- /dev/null +++ b/apps/codex-claw/src/screens/chat/components/mcp-health-panel.tsx @@ -0,0 +1,243 @@ +import { useEffect, useMemo, useState } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { + Cancel01Icon, + Copy01Icon, + RefreshIcon, + Settings01Icon, +} from '@hugeicons/core-free-icons' +import { fetchMcpHealth } from '../chat-queries' +import type { + McpHealthPayload, + McpHealthStatus, + McpServerHealth, + McpSetupSnippet, +} from '../types' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +type McpHealthPanelProps = { + open: boolean + onClose: () => void +} + +function copyText(value: string) { + try { + void navigator.clipboard.writeText(value) + } catch { + // ignore + } +} + +function statusClass(status: McpHealthStatus) { + if (status === 'ok') return 'bg-emerald-100 text-emerald-700' + if (status === 'warning') return 'bg-amber-100 text-amber-700' + return 'bg-red-100 text-red-700' +} + +function statusLabel(status: McpHealthStatus) { + if (status === 'ok') return 'Ready' + if (status === 'warning') return 'Needs attention' + return 'Unavailable' +} + +function serverCommand(server: McpServerHealth) { + return [server.command, ...server.args].filter(Boolean).join(' ') +} + +function envLabel(server: McpServerHealth) { + if (server.env.length === 0) return 'No environment requirements' + return server.env + .map((item) => { + const suffix = item.reference ? ' from ' + item.reference : '' + return item.name + suffix + ': ' + statusLabel(item.status) + }) + .join(', ') +} + +function SnippetRow({ snippet }: { snippet: McpSetupSnippet }) { + return ( +
+
+
+
+ {snippet.label} +
+
{snippet.description}
+
+ +
+
+        {snippet.snippet}
+      
+
+ ) +} + +export function McpHealthPanel({ open, onClose }: McpHealthPanelProps) { + const [payload, setPayload] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + function loadHealth() { + setLoading(true) + setError(null) + fetchMcpHealth() + .then(setPayload) + .catch((err) => + setError(err instanceof Error ? err.message : String(err)), + ) + .finally(() => setLoading(false)) + } + + useEffect(() => { + if (!open) return + loadHealth() + }, [open]) + + const summary = useMemo(() => { + const servers = payload?.servers ?? [] + return { + ready: servers.filter((server) => server.status === 'ok').length, + warning: servers.filter((server) => server.status === 'warning').length, + error: servers.filter((server) => server.status === 'error').length, + } + }, [payload]) + + if (!open) return null + + return ( +
+
+ +
+
+ MCP tools +
+
+ {payload?.configPath || 'No Codex MCP config detected'} +
+
+
+ {summary.ready} ready + {summary.warning} warning + {summary.error} unavailable +
+ + +
+ + {error ? ( +
{error}
+ ) : null} + {loading ? ( +
Refreshing...
+ ) : null} + +
+
+
+ {payload && payload.servers.length === 0 ? ( +
+ No configured MCP servers were found for this workspace. +
+ ) : null} + {payload?.servers.map((server) => ( +
+
+
+
+ + {server.name} + + + {statusLabel(server.status)} + +
+
+ {server.summary} +
+
+ +
+
+
+ {serverCommand(server) || 'No command configured'} +
+
+ {server.commandPath + ? 'Resolved: ' + server.commandPath + : 'Command path not resolved'} +
+
{envLabel(server)}
+
+
+ ))} +
+
+
+
+ Setup snippets +
+
+ Copy into the active Codex config and edit paths as needed. +
+
+ {payload?.setupSnippets.map((snippet) => ( + + ))} +
+
+ {payload ? ( +
+ Checked {payload.checkedConfigPaths.length} config path + {payload.checkedConfigPaths.length === 1 ? '' : 's'} at{' '} + {new Date(payload.checkedAt).toLocaleTimeString()}. +
+ ) : null} +
+
+ ) +} diff --git a/apps/codex-claw/src/screens/chat/types.ts b/apps/codex-claw/src/screens/chat/types.ts index d1e90a1..d447191 100644 --- a/apps/codex-claw/src/screens/chat/types.ts +++ b/apps/codex-claw/src/screens/chat/types.ts @@ -202,3 +202,41 @@ export type GitReviewPayload = { patch: string draftCommitMessage: string } + +export type McpHealthStatus = 'ok' | 'warning' | 'error' + +export type McpEnvRequirement = { + name: string + status: McpHealthStatus + source: 'config' | 'process' | 'missing' + reference?: string +} + +export type McpServerHealth = { + name: string + enabled: boolean + command: string + args: Array + env: Array + status: McpHealthStatus + summary: string + commandPath?: string +} + +export type McpSetupSnippet = { + id: string + label: string + description: string + snippet: string +} + +export type McpHealthPayload = { + ok: boolean + workspaceId: string + workdir: string + configPath?: string + checkedConfigPaths: Array + checkedAt: number + servers: Array + setupSnippets: Array +} diff --git a/apps/codex-claw/src/server/mcp-health.test.ts b/apps/codex-claw/src/server/mcp-health.test.ts new file mode 100644 index 0000000..d686719 --- /dev/null +++ b/apps/codex-claw/src/server/mcp-health.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' + +import { parseCodexMcpServers, redactMcpArgs } from './mcp-health' + +describe('parseCodexMcpServers', function () { + it('reads server command, args, and inline env config', function () { + const config = [ + '[mcp_servers.filesystem]', + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-filesystem", "/repo"]', + 'env = { API_TOKEN = "$API_TOKEN" }', + ].join('\n') + const servers = parseCodexMcpServers(config) + + expect(servers).toEqual([ + { + name: 'filesystem', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/repo'], + env: { API_TOKEN: '$API_TOKEN' }, + enabled: true, + }, + ]) + }) + + it('reads nested env sections without treating comments as values', function () { + const config = [ + '[mcp_servers."local memory"]', + 'command = "node"', + 'args = ["server.js"] # keep comments out', + '', + '[mcp_servers."local memory".env]', + 'SECRET_KEY = "literal-secret"', + ].join('\n') + const servers = parseCodexMcpServers(config) + + expect(servers[0]).toMatchObject({ + name: 'local memory', + command: 'node', + args: ['server.js'], + env: { SECRET_KEY: 'literal-secret' }, + }) + }) + + it('redacts secret-like command arguments', function () { + expect( + redactMcpArgs([ + '--token', + 'secret-value', + '--api-key=another-secret', + 'SAFE_VALUE=yes', + ]), + ).toEqual([ + '--token', + '[redacted]', + '--api-key=[redacted]', + 'SAFE_VALUE=yes', + ]) + }) +}) diff --git a/apps/codex-claw/src/server/mcp-health.ts b/apps/codex-claw/src/server/mcp-health.ts new file mode 100644 index 0000000..f3d372e --- /dev/null +++ b/apps/codex-claw/src/server/mcp-health.ts @@ -0,0 +1,470 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { getCodexPaths } from './codex-cli' + +type McpHealthStatus = 'ok' | 'warning' | 'error' + +type McpEnvRequirement = { + name: string + status: McpHealthStatus + source: 'config' | 'process' | 'missing' + reference?: string +} + +type McpServerHealth = { + name: string + enabled: boolean + command: string + args: Array + env: Array + status: McpHealthStatus + summary: string + commandPath?: string +} + +type McpSetupSnippet = { + id: string + label: string + description: string + snippet: string +} + +type McpHealthPayload = { + ok: boolean + workspaceId: string + workdir: string + configPath?: string + checkedConfigPaths: Array + checkedAt: number + servers: Array + setupSnippets: Array +} + +type ParsedMcpServer = { + name: string + command: string + args: Array + env: Record + enabled: boolean +} + +const secretArgPattern = + /(api[_-]?key|token|secret|password|passwd|credential|auth|bearer)/i + +function codexConfigCandidates(workdir: string) { + const candidates = new Set() + const configuredHome = process.env.CODEX_HOME?.trim() + if (configuredHome) candidates.add(path.join(configuredHome, 'config.toml')) + candidates.add(path.join(os.homedir(), '.codex', 'config.toml')) + candidates.add(path.join(workdir, '.codex', 'config.toml')) + return [...candidates].map((candidate) => path.resolve(candidate)) +} + +function stripComment(line: string) { + let quote: string | null = null + let escaped = false + for (let index = 0; index < line.length; index += 1) { + const char = line[index] + if (escaped) { + escaped = false + continue + } + if (quote && char === '\\') { + escaped = true + continue + } + if (char === '"' || char === "'") { + if (quote === char) { + quote = null + } else if (!quote) { + quote = char + } + continue + } + if (!quote && char === '#') return line.slice(0, index).trim() + } + return line.trim() +} + +function unquote(value: string) { + const trimmed = value.trim() + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') + } + return trimmed +} + +function splitTopLevel(value: string) { + const items: Array = [] + let quote: string | null = null + let escaped = false + let depth = 0 + let start = 0 + for (let index = 0; index < value.length; index += 1) { + const char = value[index] + if (escaped) { + escaped = false + continue + } + if (quote && char === '\\') { + escaped = true + continue + } + if (char === '"' || char === "'") { + if (quote === char) { + quote = null + } else if (!quote) { + quote = char + } + continue + } + if (quote) continue + if (char === '[' || char === '{') depth += 1 + if (char === ']' || char === '}') depth -= 1 + if (char === ',' && depth === 0) { + items.push(value.slice(start, index).trim()) + start = index + 1 + } + } + const tail = value.slice(start).trim() + if (tail) items.push(tail) + return items +} + +function parseArray(value: string) { + const trimmed = value.trim() + if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return [] + return splitTopLevel(trimmed.slice(1, -1)).map(unquote).filter(Boolean) +} + +function splitAssignment(line: string): [string, string | undefined] { + let quote: string | null = null + let escaped = false + for (let index = 0; index < line.length; index += 1) { + const char = line[index] + if (escaped) { + escaped = false + continue + } + if (quote && char === '\\') { + escaped = true + continue + } + if (char === '"' || char === "'") { + if (quote === char) { + quote = null + } else if (!quote) { + quote = char + } + continue + } + if (!quote && char === '=') { + return [line.slice(0, index).trim(), line.slice(index + 1).trim()] + } + } + return [line.trim(), undefined] +} + +function parseInlineTable(value: string) { + const trimmed = value.trim() + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return {} + const result: Record = {} + for (const item of splitTopLevel(trimmed.slice(1, -1))) { + const [key, rawValue] = splitAssignment(item) + if (!key || typeof rawValue === 'undefined') continue + result[unquote(key)] = unquote(rawValue) + } + return result +} + +function splitSectionParts(value: string) { + const parts: Array = [] + let quote: string | null = null + let escaped = false + let start = 0 + for (let index = 0; index < value.length; index += 1) { + const char = value[index] + if (escaped) { + escaped = false + continue + } + if (quote && char === '\\') { + escaped = true + continue + } + if (char === '"' || char === "'") { + if (quote === char) { + quote = null + } else if (!quote) { + quote = char + } + continue + } + if (!quote && char === '.') { + parts.push(unquote(value.slice(start, index))) + start = index + 1 + } + } + parts.push(unquote(value.slice(start))) + return parts.filter(Boolean) +} + +function parseSectionName(rawSection: string) { + const trimmed = rawSection.trim() + if (!trimmed.startsWith('mcp_servers.')) return null + const parts = splitSectionParts(trimmed.slice('mcp_servers.'.length)) + if (parts.length === 0) return null + return { + serverName: parts[0], + nested: parts.slice(1).join('.'), + } +} + +function ensureParsedServer( + servers: Map, + name: string, +) { + const existing = servers.get(name) + if (existing) return existing + const server: ParsedMcpServer = { + name, + command: '', + args: [], + env: {}, + enabled: true, + } + servers.set(name, server) + return server +} + +export function parseCodexMcpServers(configText: string) { + const servers = new Map() + let currentSection: ReturnType = null + + for (const rawLine of configText.split(/\r?\n/)) { + const line = stripComment(rawLine) + if (!line) continue + const sectionMatch = /^\[(?
[^\]]+)]$/.exec(line) + if (sectionMatch?.groups?.section) { + currentSection = parseSectionName(sectionMatch.groups.section) + continue + } + if (!currentSection) continue + const [key, rawValue] = splitAssignment(line) + if (!key || typeof rawValue === 'undefined') continue + + const server = ensureParsedServer(servers, currentSection.serverName) + if (currentSection.nested === 'env') { + server.env[unquote(key)] = unquote(rawValue) + continue + } + + if (key === 'command') server.command = unquote(rawValue) + if (key === 'args') server.args = parseArray(rawValue) + if (key === 'env') { + server.env = { ...server.env, ...parseInlineTable(rawValue) } + } + if (key === 'disabled') { + server.enabled = rawValue.trim().toLowerCase() !== 'true' + } + if (key === 'enabled') { + server.enabled = rawValue.trim().toLowerCase() !== 'false' + } + } + + return [...servers.values()].sort((first, second) => + first.name.localeCompare(second.name), + ) +} + +function referencedEnvName(value: string) { + const trimmed = value.trim() + const braced = /^\$\{(?[A-Za-z_][A-Za-z0-9_]*)}$/.exec(trimmed) + if (braced?.groups?.name) return braced.groups.name + const simple = /^\$(?[A-Za-z_][A-Za-z0-9_]*)$/.exec(trimmed) + if (simple?.groups?.name) return simple.groups.name + return null +} + +function envRequirement(name: string, value: string): McpEnvRequirement { + const reference = referencedEnvName(value) + if (reference) { + const found = Boolean(process.env[reference]) + return { + name, + reference, + source: found ? 'process' : 'missing', + status: found ? 'ok' : 'warning', + } + } + if (!value.trim()) { + const found = Boolean(process.env[name]) + return { + name, + source: found ? 'process' : 'missing', + status: found ? 'ok' : 'warning', + } + } + return { + name, + source: 'config', + status: 'ok', + } +} + +function redactedAssignment(value: string) { + const separator = value.indexOf('=') + if (separator < 0) return value + return value.slice(0, separator + 1) + '[redacted]' +} + +function redactMcpArg(arg: string, previousArg?: string) { + if ( + previousArg && + previousArg.startsWith('-') && + !previousArg.includes('=') && + secretArgPattern.test(previousArg) + ) { + return '[redacted]' + } + if (secretArgPattern.test(arg) && arg.includes('=')) { + return redactedAssignment(arg) + } + return arg +} + +export function redactMcpArgs(args: Array) { + return args.map((arg, index) => redactMcpArg(arg, args[index - 1])) +} + +function resolveCommand(command: string, workdir: string) { + if (!command.trim()) return undefined + const trimmed = command.trim() + if (path.isAbsolute(trimmed)) return existsSync(trimmed) ? trimmed : undefined + if (trimmed.includes('/') || trimmed.includes('\\')) { + const absolute = path.resolve(workdir, trimmed) + return existsSync(absolute) ? absolute : undefined + } + const resolver = + process.platform === 'win32' + ? spawnSync('where.exe', [trimmed], { encoding: 'utf8' }) + : spawnSync('which', [trimmed], { encoding: 'utf8' }) + if (resolver.status !== 0) return undefined + return resolver.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) +} + +function serverHealth( + server: ParsedMcpServer, + workdir: string, +): McpServerHealth { + const env = Object.entries(server.env) + .map(([name, value]) => envRequirement(name, value)) + .sort((first, second) => first.name.localeCompare(second.name)) + const missingEnv = env.filter((item) => item.status !== 'ok') + const commandPath = resolveCommand(server.command, workdir) + const commandMissing = server.enabled && !commandPath + + const status: McpHealthStatus = commandMissing + ? 'error' + : missingEnv.length > 0 + ? 'warning' + : 'ok' + const summary = !server.enabled + ? 'Disabled in Codex config' + : commandMissing + ? 'Command was not found on this machine' + : missingEnv.length > 0 + ? 'Environment variables need attention' + : 'Ready for Codex CLI' + + return { + name: server.name, + enabled: server.enabled, + command: server.command, + args: redactMcpArgs(server.args), + env, + status: server.enabled ? status : 'warning', + summary, + commandPath, + } +} + +function tomlString(value: string) { + return JSON.stringify(value) +} + +function snippet( + id: string, + label: string, + description: string, + lines: Array, +) { + return { + id, + label, + description, + snippet: lines.join('\n'), + } +} + +function setupSnippets(workdir: string): Array { + return [ + snippet('filesystem', 'Filesystem', 'Expose the active workspace tree.', [ + '[mcp_servers.filesystem]', + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-filesystem", ' + + tomlString(workdir) + + ']', + ]), + snippet('git', 'Git', 'Expose repository-aware git helpers.', [ + '[mcp_servers.git]', + 'command = "uvx"', + 'args = ["mcp-server-git", "--repository", ' + tomlString(workdir) + ']', + ]), + snippet('memory', 'Memory', 'Add a local memory graph server.', [ + '[mcp_servers.memory]', + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-memory"]', + ]), + snippet('fetch', 'Fetch', 'Add controlled URL fetching support.', [ + '[mcp_servers.fetch]', + 'command = "uvx"', + 'args = ["mcp-server-fetch"]', + ]), + ] +} + +export function getMcpHealthPayload(): McpHealthPayload { + const paths = getCodexPaths() + const workdir = paths.workspace.codexWorkdir + const checkedConfigPaths = codexConfigCandidates(workdir) + const configPath = checkedConfigPaths.find((candidate) => + existsSync(candidate), + ) + const configText = configPath ? readFileSync(configPath, 'utf8') : '' + const servers = configPath + ? parseCodexMcpServers(configText).map((server) => + serverHealth(server, workdir), + ) + : [] + + return { + ok: !servers.some((server) => server.status === 'error'), + workspaceId: paths.workspace.id, + workdir, + configPath, + checkedConfigPaths, + checkedAt: Date.now(), + servers, + setupSnippets: setupSnippets(workdir), + } +}