From d6437ef68b7dbdc5c32ba47b501f3f992ac059f0 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 14 Jun 2026 12:08:53 -0400 Subject: [PATCH 1/8] feat(mcp): MCP types, manifest block, registry loader with validation + secret-literal guard Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/errors.ts | 13 +++- packages/core/src/mcp.ts | 115 +++++++++++++++++++++++++++++++++ packages/core/src/types.ts | 36 +++++++++++ packages/core/test/mcp.test.ts | 86 ++++++++++++++++++++++++ 4 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/mcp.ts create mode 100644 packages/core/test/mcp.test.ts diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index ee30a30..34950c6 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -18,7 +18,18 @@ export type ContextErrorCode = | "AICTX_MIGRATE_DIRTY_TREE" | "AICTX_MIGRATE_NOT_GIT_REPO" | "AICTX_MIGRATE_ENTRY_FAILED" - | "AICTX_MIGRATE_ALREADY_APPLIED"; + | "AICTX_MIGRATE_ALREADY_APPLIED" + | "AICTX_MCP_REGISTRY_INVALID" + | "AICTX_MCP_NAME_INVALID" + | "AICTX_MCP_NAME_DUPLICATE" + | "AICTX_MCP_SCOPE_INVALID" + | "AICTX_MCP_TARGET_UNKNOWN" + | "AICTX_MCP_TRANSPORT_INVALID" + | "AICTX_MCP_ENV_INVALID" + | "AICTX_MCP_SECRET_LITERAL" + | "AICTX_MCP_ADAPTER_UNKNOWN" + | "AICTX_MCP_SKILL_MISSING" + | "AICTX_MCP_SECRET_LEAK"; const DEFAULT_CODE: ContextErrorCode = "AICTX_INTERNAL"; diff --git a/packages/core/src/mcp.ts b/packages/core/src/mcp.ts new file mode 100644 index 0000000..e4662eb --- /dev/null +++ b/packages/core/src/mcp.ts @@ -0,0 +1,115 @@ +import fs from "node:fs"; +import path from "node:path"; +import { ContextError } from "./errors.js"; +import type { McpRegistry, McpServer, McpClientId, Manifest } from "./types.js"; + +const NAME_PATTERN = /^[a-z0-9](?:-?[a-z0-9]+)*$/; +const KNOWN_CLIENTS: McpClientId[] = ["claude", "codex"]; + +// An ${VAR} reference is the only allowed dynamic value. A bare value that looks +// like a credential (long token / known key prefix / PEM header) is rejected so +// secrets never get committed into a registry that fans out to tracked files. +const ENV_REF = /^\$\{[A-Z0-9_]+\}$/; +const SECRET_LITERAL = /(sk-|xox[baprs]-|ghp_|AKIA|-----BEGIN|[A-Za-z0-9_-]{32,})/; + +export function parseMcpRegistry(raw: string, sourcePath: string): McpRegistry { + let json: unknown; + try { + json = JSON.parse(raw); + } catch (e) { + throw new ContextError( + "AICTX_MCP_REGISTRY_INVALID", + `Invalid JSON in ${sourcePath}: ${String(e)}` + ); + } + + const reg = json as McpRegistry; + if (reg?.version !== 1 || !Array.isArray(reg.servers)) { + throw new ContextError( + "AICTX_MCP_REGISTRY_INVALID", + `${sourcePath} must have version:1 and a servers[] array` + ); + } + + const seen = new Set(); + for (const server of reg.servers) { + validateServer(server, sourcePath, seen); + } + return reg; +} + +function validateServer(s: McpServer, src: string, seen: Set): void { + if (!s.name || typeof s.name !== "string" || !NAME_PATTERN.test(s.name)) { + throw new ContextError( + "AICTX_MCP_NAME_INVALID", + `Invalid MCP server name '${s.name}' in ${src} (must be [a-z0-9-], no leading/trailing/consecutive hyphens)` + ); + } + if (seen.has(s.name)) { + throw new ContextError( + "AICTX_MCP_NAME_DUPLICATE", + `Duplicate MCP server '${s.name}' in ${src}` + ); + } + seen.add(s.name); + + if (s.scope !== "project" && s.scope !== "user") { + throw new ContextError( + "AICTX_MCP_SCOPE_INVALID", + `MCP server '${s.name}' scope must be 'project' or 'user' (${src})` + ); + } + + if (!Array.isArray(s.targets) || s.targets.length === 0) { + throw new ContextError( + "AICTX_MCP_TARGET_UNKNOWN", + `MCP server '${s.name}' must list at least one target client (${src})` + ); + } + for (const t of s.targets) { + if (!KNOWN_CLIENTS.includes(t)) { + throw new ContextError( + "AICTX_MCP_TARGET_UNKNOWN", + `MCP server '${s.name}' has unknown target '${t}' in ${src} (known: ${KNOWN_CLIENTS.join(", ")})` + ); + } + } + + const t = s.transport; + const validHttp = + !!t && (t.type === "http" || t.type === "sse") && typeof (t as { url?: unknown }).url === "string"; + const validStdio = + !!t && t.type === "stdio" && typeof (t as { command?: unknown }).command === "string"; + if (!validHttp && !validStdio) { + throw new ContextError( + "AICTX_MCP_TRANSPORT_INVALID", + `MCP server '${s.name}' has an invalid transport in ${src} (need http/sse{url} or stdio{command})` + ); + } + + for (const [key, value] of Object.entries(s.env ?? {})) { + if (typeof value !== "string") { + throw new ContextError( + "AICTX_MCP_ENV_INVALID", + `MCP server '${s.name}' env '${key}' must be a string in ${src}` + ); + } + if (!ENV_REF.test(value) && SECRET_LITERAL.test(value)) { + throw new ContextError( + "AICTX_MCP_SECRET_LITERAL", + `MCP server '${s.name}' env '${key}' looks like a literal secret; use a \${VAR} reference instead (${src})` + ); + } + } +} + +export function loadMcpRegistry(cwd: string, manifest: Manifest): McpRegistry | null { + if (!manifest.mcp) return null; + const abs = path.isAbsolute(manifest.mcp.registry) + ? manifest.mcp.registry + : path.join(cwd, manifest.mcp.registry); + if (!fs.existsSync(abs)) { + return { version: 1, servers: [] }; + } + return parseMcpRegistry(fs.readFileSync(abs, "utf8"), manifest.mcp.registry); +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2374aff..a349052 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -13,6 +13,42 @@ export interface Manifest { targets: Record; claudeOutput?: string; skills?: SkillsManifestBlock; + mcp?: McpManifestBlock; +} + +// MCP primitive: declare servers once in .ai/mcp.json, fan out to each agent client. +export type McpClientId = "claude" | "codex"; // v1; cursor/vscode/gemini added later + +export type McpTransport = + | { type: "http" | "sse"; url: string } + | { type: "stdio"; command: string; args?: string[] }; + +export interface McpServer { + name: string; + transport: McpTransport; + scope: "project" | "user"; + targets: McpClientId[]; + auth?: "oauth" | "env" | "none"; + /** Values must be ${VAR} references, never literal secrets. */ + env?: Record; + /** Backing skill name; defaults to a co-named .ai/skills/ when present. */ + skill?: string; + /** When true, emit a catalog line into the root AGENTS.md/CLAUDE.md. */ + context?: boolean; + /** Optional shell command run by `ai-context mcp setup `. */ + setup?: string; +} + +export interface McpRegistry { + version: 1; + servers: McpServer[]; +} + +export interface McpManifestBlock { + /** Path to the registry file, e.g. ".ai/mcp.json". */ + registry: string; + /** Which client adapters are active in this repo. */ + clients: McpClientId[]; } export interface ScopeDefinition { diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts new file mode 100644 index 0000000..456bec1 --- /dev/null +++ b/packages/core/test/mcp.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { parseMcpRegistry } from "../src/mcp.js"; + +describe("parseMcpRegistry", () => { + it("parses a valid registry", () => { + const raw = JSON.stringify({ + version: 1, + servers: [ + { + name: "posthog", + transport: { type: "http", url: "https://mcp.posthog.com/mcp" }, + scope: "project", + targets: ["claude", "codex"], + env: { POSTHOG_PERSONAL_API_KEY: "${POSTHOG_PERSONAL_API_KEY}" }, + }, + ], + }); + const reg = parseMcpRegistry(raw, ".ai/mcp.json"); + expect(reg.servers[0]!.name).toBe("posthog"); + }); + + it("accepts a stdio transport", () => { + const raw = JSON.stringify({ + version: 1, + servers: [ + { + name: "grafana", + transport: { type: "stdio", command: "uvx", args: ["mcp-grafana"] }, + scope: "project", + targets: ["claude"], + }, + ], + }); + expect(parseMcpRegistry(raw, ".ai/mcp.json").servers[0]!.transport.type).toBe("stdio"); + }); + + it("rejects a literal-looking secret in env", () => { + const raw = JSON.stringify({ + version: 1, + servers: [ + { + name: "x", + transport: { type: "http", url: "https://e/x" }, + scope: "project", + targets: ["claude"], + env: { TOKEN: "ghp_abcdefghijklmnopqrstuvwxyz0123456789" }, + }, + ], + }); + expect(() => parseMcpRegistry(raw, ".ai/mcp.json")).toThrow(/literal secret/); + }); + + it("rejects an unknown target client", () => { + const raw = JSON.stringify({ + version: 1, + servers: [ + { + name: "x", + transport: { type: "http", url: "https://e/x" }, + scope: "project", + targets: ["notaclient"], + }, + ], + }); + expect(() => parseMcpRegistry(raw, ".ai/mcp.json")).toThrow(/unknown target/); + }); + + it("rejects a duplicate server name", () => { + const raw = JSON.stringify({ + version: 1, + servers: [ + { name: "dup", transport: { type: "http", url: "https://e/x" }, scope: "project", targets: ["claude"] }, + { name: "dup", transport: { type: "http", url: "https://e/y" }, scope: "project", targets: ["claude"] }, + ], + }); + expect(() => parseMcpRegistry(raw, ".ai/mcp.json")).toThrow(/Duplicate MCP server/); + }); + + it("rejects an invalid transport", () => { + const raw = JSON.stringify({ + version: 1, + servers: [{ name: "x", transport: { type: "http" }, scope: "project", targets: ["claude"] }], + }); + expect(() => parseMcpRegistry(raw, ".ai/mcp.json")).toThrow(/invalid transport/); + }); +}); From 9609824a2392a1c2e45659101b4a39c3cf35c7f6 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 14 Jun 2026 12:10:12 -0400 Subject: [PATCH 2/8] feat(mcp): adapter registry + claude (.mcp.json) and codex (config.toml) adapters Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/mcp-adapters/claude.ts | 24 +++++++ packages/core/src/mcp-adapters/codex.ts | 45 ++++++++++++ packages/core/src/mcp-adapters/index.ts | 33 +++++++++ packages/core/test/mcp-adapters.test.ts | 92 ++++++++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 packages/core/src/mcp-adapters/claude.ts create mode 100644 packages/core/src/mcp-adapters/codex.ts create mode 100644 packages/core/src/mcp-adapters/index.ts create mode 100644 packages/core/test/mcp-adapters.test.ts diff --git a/packages/core/src/mcp-adapters/claude.ts b/packages/core/src/mcp-adapters/claude.ts new file mode 100644 index 0000000..00a1bad --- /dev/null +++ b/packages/core/src/mcp-adapters/claude.ts @@ -0,0 +1,24 @@ +import type { McpServer } from "../types.js"; +import type { McpAdapter } from "./index.js"; +import { MANAGED_JSON_KEY, MANAGED_MARKER } from "./index.js"; + +function entry(s: McpServer): Record { + const t = s.transport; + const base = + t.type === "stdio" + ? { command: t.command, ...(t.args && t.args.length ? { args: t.args } : {}) } + : { type: t.type, url: t.url }; + return s.env && Object.keys(s.env).length ? { ...base, env: s.env } : base; +} + +export const claudeAdapter: McpAdapter = { + clientId: "claude", + projectOutputPath: () => ".mcp.json", + render(servers) { + const mcpServers: Record = {}; + for (const s of [...servers].sort((a, b) => a.name.localeCompare(b.name))) { + mcpServers[s.name] = entry(s); + } + return JSON.stringify({ [MANAGED_JSON_KEY]: MANAGED_MARKER, mcpServers }, null, 2) + "\n"; + }, +}; diff --git a/packages/core/src/mcp-adapters/codex.ts b/packages/core/src/mcp-adapters/codex.ts new file mode 100644 index 0000000..1916000 --- /dev/null +++ b/packages/core/src/mcp-adapters/codex.ts @@ -0,0 +1,45 @@ +import type { McpServer } from "../types.js"; +import type { McpAdapter } from "./index.js"; +import { MANAGED_MARKER } from "./index.js"; + +// TOML basic strings share JSON's escaping rules for our charset (URLs, ${VAR}, +// command names), so JSON.stringify is a safe serializer for these values. +function tomlString(value: string): string { + return JSON.stringify(value); +} + +function tomlArray(values: string[]): string { + return "[" + values.map(tomlString).join(", ") + "]"; +} + +function renderServer(s: McpServer): string { + const lines: string[] = [`[mcp_servers.${s.name}]`]; + const t = s.transport; + if (t.type === "stdio") { + lines.push(`command = ${tomlString(t.command)}`); + if (t.args && t.args.length) { + lines.push(`args = ${tomlArray(t.args)}`); + } + } else { + lines.push(`url = ${tomlString(t.url)} # remote MCP; Codex support is version-dependent`); + } + if (s.env && Object.keys(s.env).length) { + lines.push(`[mcp_servers.${s.name}.env]`); + for (const [key, value] of Object.entries(s.env)) { + lines.push(`${key} = ${tomlString(value)}`); + } + } + return lines.join("\n"); +} + +export const codexAdapter: McpAdapter = { + clientId: "codex", + projectOutputPath: () => ".codex/config.toml", + render(servers) { + const body = [...servers] + .sort((a, b) => a.name.localeCompare(b.name)) + .map(renderServer) + .join("\n\n"); + return `# ${MANAGED_MARKER}\n\n${body}\n`; + }, +}; diff --git a/packages/core/src/mcp-adapters/index.ts b/packages/core/src/mcp-adapters/index.ts new file mode 100644 index 0000000..23f92b5 --- /dev/null +++ b/packages/core/src/mcp-adapters/index.ts @@ -0,0 +1,33 @@ +import { ContextError } from "../errors.js"; +import type { McpClientId, McpServer } from "../types.js"; +import { claudeAdapter } from "./claude.js"; +import { codexAdapter } from "./codex.js"; + +/** Key embedded in generated JSON configs so humans/tools recognize a managed file. */ +export const MANAGED_JSON_KEY = "_generated"; +export const MANAGED_MARKER = "ai-context: do not edit; generated from .ai/mcp.json"; + +export interface McpAdapter { + clientId: McpClientId; + /** Repo-relative path for the project-scope config this client reads. */ + projectOutputPath(): string; + /** Render the given project-scope servers into the full file content. */ + render(servers: McpServer[]): string; +} + +const REGISTRY: Record = { + claude: claudeAdapter, + codex: codexAdapter, +}; + +export function getAdapter(client: McpClientId): McpAdapter { + const adapter = REGISTRY[client]; + if (!adapter) { + throw new ContextError("AICTX_MCP_ADAPTER_UNKNOWN", `No MCP adapter for client '${client}'`); + } + return adapter; +} + +export function allClients(): McpClientId[] { + return Object.keys(REGISTRY) as McpClientId[]; +} diff --git a/packages/core/test/mcp-adapters.test.ts b/packages/core/test/mcp-adapters.test.ts new file mode 100644 index 0000000..65ec45e --- /dev/null +++ b/packages/core/test/mcp-adapters.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { getAdapter, allClients } from "../src/mcp-adapters/index.js"; +import { claudeAdapter } from "../src/mcp-adapters/claude.js"; +import { codexAdapter } from "../src/mcp-adapters/codex.js"; +import type { McpServer } from "../src/types.js"; + +describe("adapter registry", () => { + it("returns the claude adapter", () => { + const a = getAdapter("claude"); + expect(a.clientId).toBe("claude"); + expect(a.projectOutputPath()).toBe(".mcp.json"); + }); + + it("returns the codex adapter", () => { + expect(getAdapter("codex").projectOutputPath()).toBe(".codex/config.toml"); + }); + + it("lists all known clients", () => { + expect(allClients().sort()).toEqual(["claude", "codex"]); + }); + + it("throws on an unknown client", () => { + // @ts-expect-error testing runtime guard with an invalid client id + expect(() => getAdapter("nope")).toThrow(/No MCP adapter/); + }); +}); + +describe("claude adapter", () => { + it("renders .mcp.json with mcpServers, ${VAR} preserved, and a managed marker", () => { + const servers: McpServer[] = [ + { + name: "posthog", + transport: { type: "http", url: "https://mcp.posthog.com/mcp" }, + scope: "project", + targets: ["claude"], + env: { POSTHOG_PERSONAL_API_KEY: "${POSTHOG_PERSONAL_API_KEY}" }, + }, + { + name: "grafana", + transport: { type: "stdio", command: "uvx", args: ["mcp-grafana"] }, + scope: "project", + targets: ["claude"], + }, + ]; + const out = claudeAdapter.render(servers); + const parsed = JSON.parse(out); + expect(parsed._generated).toContain("ai-context"); + expect(parsed.mcpServers.posthog).toEqual({ + type: "http", + url: "https://mcp.posthog.com/mcp", + env: { POSTHOG_PERSONAL_API_KEY: "${POSTHOG_PERSONAL_API_KEY}" }, + }); + expect(parsed.mcpServers.grafana).toEqual({ command: "uvx", args: ["mcp-grafana"] }); + }); + + it("sorts servers by name for stable output", () => { + const out = claudeAdapter.render([ + { name: "zeta", transport: { type: "http", url: "u" }, scope: "project", targets: ["claude"] }, + { name: "alpha", transport: { type: "http", url: "u" }, scope: "project", targets: ["claude"] }, + ]); + const keys = Object.keys(JSON.parse(out).mcpServers); + expect(keys).toEqual(["alpha", "zeta"]); + }); +}); + +describe("codex adapter", () => { + it("renders codex toml mcp_servers tables with a managed marker", () => { + const out = codexAdapter.render([ + { + name: "grafana", + transport: { type: "stdio", command: "uvx", args: ["mcp-grafana"] }, + scope: "project", + targets: ["codex"], + env: { GRAFANA_URL: "${GRAFANA_URL}" }, + }, + ]); + expect(out).toContain("# ai-context"); + expect(out).toContain("[mcp_servers.grafana]"); + expect(out).toContain('command = "uvx"'); + expect(out).toContain('args = ["mcp-grafana"]'); + expect(out).toContain("[mcp_servers.grafana.env]"); + expect(out).toContain('GRAFANA_URL = "${GRAFANA_URL}"'); + }); + + it("renders an http server with a url key", () => { + const out = codexAdapter.render([ + { name: "posthog", transport: { type: "http", url: "https://mcp.posthog.com/mcp" }, scope: "project", targets: ["codex"] }, + ]); + expect(out).toContain("[mcp_servers.posthog]"); + expect(out).toContain('url = "https://mcp.posthog.com/mcp"'); + }); +}); From d2f79985c825e8a9b26bed252e0862f7ddd32ee5 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 14 Jun 2026 12:11:31 -0400 Subject: [PATCH 3/8] feat(mcp): planMcpOutputs (project-scope content outputs + codex collision guard), skill-link resolution, catalog block Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/mcp.ts | 78 +++++++++++++++++ packages/core/test/mcp-plan.test.ts | 124 ++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 packages/core/test/mcp-plan.test.ts diff --git a/packages/core/src/mcp.ts b/packages/core/src/mcp.ts index e4662eb..73ebfe6 100644 --- a/packages/core/src/mcp.ts +++ b/packages/core/src/mcp.ts @@ -1,6 +1,8 @@ import fs from "node:fs"; import path from "node:path"; import { ContextError } from "./errors.js"; +import { getAdapter, MANAGED_MARKER } from "./mcp-adapters/index.js"; +import type { GeneratedOutput } from "./render.js"; import type { McpRegistry, McpServer, McpClientId, Manifest } from "./types.js"; const NAME_PATTERN = /^[a-z0-9](?:-?[a-z0-9]+)*$/; @@ -113,3 +115,79 @@ export function loadMcpRegistry(cwd: string, manifest: Manifest): McpRegistry | } return parseMcpRegistry(fs.readFileSync(abs, "utf8"), manifest.mcp.registry); } + +/** + * Render the registry into per-client content outputs (same shape as the context + * generator, so they flow through the existing writeOutputs path). Only + * project-scope servers are emitted into repo files; user-scope servers are + * installed per-machine via `ai-context mcp install --user`. + */ +export function planMcpOutputs( + cwd: string, + reg: McpRegistry, + clients: McpClientId[] +): GeneratedOutput[] { + const outputs: GeneratedOutput[] = []; + for (const client of clients) { + const adapter = getAdapter(client); + const servers = reg.servers.filter( + (s) => s.scope === "project" && s.targets.includes(client) + ); + // Always emit (even when empty) so removing the last server deterministically + // shrinks the managed file instead of leaving a stale one behind. + const outPath = resolveOutputPath(cwd, adapter.projectOutputPath()); + outputs.push({ path: outPath, content: adapter.render(servers), source: `mcp:${client}` }); + } + return outputs; +} + +/** + * Guard against clobbering a hand-written .codex/config.toml (which may hold + * budget config like project_doc_max_bytes). If a foreign file exists there, + * redirect to .codex/mcp.toml for the user to `include`. + */ +function resolveOutputPath(cwd: string, outPath: string): string { + if (outPath !== ".codex/config.toml") return outPath; + const abs = path.join(cwd, outPath); + if (!fs.existsSync(abs)) return outPath; + const existing = fs.readFileSync(abs, "utf8"); + return existing.includes(MANAGED_MARKER) ? outPath : ".codex/mcp.toml"; +} + +/** + * Resolve a server's backing skill: an explicit `skill` (validated to exist) or, + * by convention, a co-named skill. Returns undefined when there is no backing. + */ +export function resolveSkillLink( + s: McpServer, + skillExists: (name: string) => boolean +): string | undefined { + if (s.skill) { + if (!skillExists(s.skill)) { + throw new ContextError( + "AICTX_MCP_SKILL_MISSING", + `MCP server '${s.name}' references missing skill '${s.skill}'` + ); + } + return s.skill; + } + return skillExists(s.name) ? s.name : undefined; +} + +/** Render the "Available MCP servers" catalog block for context:true servers. */ +export function renderMcpCatalog( + servers: McpServer[], + skillExists: (name: string) => boolean +): string { + const listed = servers.filter((s) => s.context); + if (listed.length === 0) return ""; + const lines = [...listed] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((s) => { + const skill = resolveSkillLink(s, skillExists); + const where = s.scope === "user" ? "user-scoped (run `/mcp` to authenticate)" : "project"; + const how = skill ? ` — see the \`${skill}\` skill for usage` : ""; + return `- **${s.name}** (${where})${how}`; + }); + return `## Available MCP servers\n\n${lines.join("\n")}\n`; +} diff --git a/packages/core/test/mcp-plan.test.ts b/packages/core/test/mcp-plan.test.ts new file mode 100644 index 0000000..d2b421d --- /dev/null +++ b/packages/core/test/mcp-plan.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { planMcpOutputs, resolveSkillLink, renderMcpCatalog } from "../src/mcp.js"; +import type { McpRegistry, McpServer } from "../src/types.js"; + +let tmp: string; +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "aickit-mcp-plan-")); +}); +afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + +describe("planMcpOutputs", () => { + it("emits one project-scope output per active client, excluding user-scope servers", () => { + const reg: McpRegistry = { + version: 1, + servers: [ + { + name: "posthog", + transport: { type: "http", url: "https://mcp.posthog.com/mcp" }, + scope: "project", + targets: ["claude", "codex"], + }, + { + name: "ahrefs", + transport: { type: "http", url: "https://api.ahrefs.com/mcp/mcp" }, + scope: "user", + targets: ["claude"], + }, + ], + }; + const outputs = planMcpOutputs(tmp, reg, ["claude", "codex"]); + const claude = outputs.find((o) => o.path === ".mcp.json")!; + expect(JSON.parse(claude.content).mcpServers.posthog).toBeTruthy(); + expect(JSON.parse(claude.content).mcpServers.ahrefs).toBeUndefined(); + expect(claude.source).toBe("mcp:claude"); + expect(outputs.find((o) => o.path === ".codex/config.toml")).toBeTruthy(); + }); + + it("only includes servers that target the client", () => { + const reg: McpRegistry = { + version: 1, + servers: [ + { name: "claudeonly", transport: { type: "http", url: "u" }, scope: "project", targets: ["claude"] }, + ], + }; + const outputs = planMcpOutputs(tmp, reg, ["claude", "codex"]); + const codex = outputs.find((o) => o.path === ".codex/config.toml")!; + expect(codex.content).not.toContain("claudeonly"); + }); + + it("redirects codex output when a foreign .codex/config.toml already exists", () => { + fs.mkdirSync(path.join(tmp, ".codex"), { recursive: true }); + fs.writeFileSync(path.join(tmp, ".codex/config.toml"), "project_doc_max_bytes = 20000\n"); + const reg: McpRegistry = { + version: 1, + servers: [ + { name: "grafana", transport: { type: "stdio", command: "uvx", args: ["mcp-grafana"] }, scope: "project", targets: ["codex"] }, + ], + }; + const outputs = planMcpOutputs(tmp, reg, ["codex"]); + expect(outputs.find((o) => o.path === ".codex/mcp.toml")).toBeTruthy(); + expect(outputs.find((o) => o.path === ".codex/config.toml")).toBeUndefined(); + }); + + it("writes to .codex/config.toml when the existing file is already kit-managed", () => { + fs.mkdirSync(path.join(tmp, ".codex"), { recursive: true }); + fs.writeFileSync(path.join(tmp, ".codex/config.toml"), "# ai-context: do not edit; generated from .ai/mcp.json\n"); + const reg: McpRegistry = { + version: 1, + servers: [ + { name: "grafana", transport: { type: "stdio", command: "uvx" }, scope: "project", targets: ["codex"] }, + ], + }; + const outputs = planMcpOutputs(tmp, reg, ["codex"]); + expect(outputs.find((o) => o.path === ".codex/config.toml")).toBeTruthy(); + }); +}); + +describe("resolveSkillLink", () => { + it("auto-links a co-named skill", () => { + const s = { name: "ahrefs", transport: { type: "http", url: "u" }, scope: "user", targets: ["claude"] } as McpServer; + expect(resolveSkillLink(s, (n) => n === "ahrefs")).toBe("ahrefs"); + }); + + it("returns undefined when no co-named skill exists", () => { + const s = { name: "ahrefs", transport: { type: "http", url: "u" }, scope: "user", targets: ["claude"] } as McpServer; + expect(resolveSkillLink(s, () => false)).toBeUndefined(); + }); + + it("uses an explicit skill ref", () => { + const s = { name: "posthog", skill: "analytics-ops", transport: { type: "http", url: "u" }, scope: "project", targets: ["claude"] } as McpServer; + expect(resolveSkillLink(s, (n) => n === "analytics-ops")).toBe("analytics-ops"); + }); + + it("throws when an explicit skill ref does not exist", () => { + const s = { name: "x", skill: "missing-skill", transport: { type: "http", url: "u" }, scope: "project", targets: ["claude"] } as McpServer; + expect(() => resolveSkillLink(s, () => false)).toThrow(/missing skill/); + }); +}); + +describe("renderMcpCatalog", () => { + it("renders a catalog block for context:true servers, with skill pointers", () => { + const servers: McpServer[] = [ + { name: "ahrefs", transport: { type: "http", url: "u" }, scope: "user", targets: ["claude"], context: true }, + { name: "posthog", transport: { type: "http", url: "u" }, scope: "project", targets: ["claude"], context: true, skill: "analytics-ops" }, + { name: "hidden", transport: { type: "http", url: "u" }, scope: "project", targets: ["claude"] }, + ]; + const block = renderMcpCatalog(servers, (n) => n === "ahrefs" || n === "analytics-ops"); + expect(block).toContain("Available MCP servers"); + expect(block).toContain("**ahrefs**"); + expect(block).toContain("user-scoped"); + expect(block).toContain("`analytics-ops` skill"); + expect(block).not.toContain("hidden"); // context not set + }); + + it("returns an empty string when no servers opt into context", () => { + const servers: McpServer[] = [ + { name: "x", transport: { type: "http", url: "u" }, scope: "project", targets: ["claude"] }, + ]; + expect(renderMcpCatalog(servers, () => false)).toBe(""); + }); +}); From 963bc29604375a5ec115600f8f13aac14f99e287 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 14 Jun 2026 12:14:43 -0400 Subject: [PATCH 4/8] feat(mcp): engine build hook emits MCP configs + injects catalog into AGENTS.md/CLAUDE.md Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/engine.ts | 14 +++ packages/core/src/index.ts | 15 +++ packages/core/src/render.ts | 36 ++++++- packages/core/test/mcp-integration.test.ts | 106 +++++++++++++++++++++ 4 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 packages/core/test/mcp-integration.test.ts diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts index a878f55..6404783 100644 --- a/packages/core/src/engine.ts +++ b/packages/core/src/engine.ts @@ -20,6 +20,7 @@ import { findOrphanedSkillMirrors, planSkillMirrors, } from "./skills.js"; +import { loadMcpRegistry, planMcpOutputs } from "./mcp.js"; import type { BuildOptions, BuildResult, @@ -161,6 +162,19 @@ function buildInternal(cwd: string, options: BuildOptions): BuildResult { } } + if (manifest.mcp) { + const reg = loadMcpRegistry(cwd, manifest); + if (reg) { + const mcpOutputs = planMcpOutputs(cwd, reg, manifest.mcp.clients); + // Never pass removeOrphans here: writeOutputs' orphan scan would treat the + // context .md files as orphans against the MCP-only output set and delete them. + const mcpResult = writeOutputs(cwd, mcpOutputs, { ...options, removeOrphans: false }); + result.written.push(...mcpResult.written); + result.unchanged.push(...mcpResult.unchanged); + if (!mcpResult.upToDate) result.upToDate = false; + } + } + return result; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fc35832..62d7029 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,11 @@ export type { DiffReport, InitOptions, Manifest, + McpClientId, + McpManifestBlock, + McpRegistry, + McpServer, + McpTransport, ScopeDefinition, ScopeManifest, Template, @@ -51,3 +56,13 @@ export { } from "./migrate.js"; export type { ApplyPlanOptions, ApplyPlanReport } from "./migrate.js"; + +export { + parseMcpRegistry, + loadMcpRegistry, + planMcpOutputs, + resolveSkillLink, + renderMcpCatalog, +} from "./mcp.js"; +export { getAdapter, allClients, MANAGED_JSON_KEY, MANAGED_MARKER } from "./mcp-adapters/index.js"; +export type { McpAdapter } from "./mcp-adapters/index.js"; diff --git a/packages/core/src/render.ts b/packages/core/src/render.ts index c91175d..1030a9f 100644 --- a/packages/core/src/render.ts +++ b/packages/core/src/render.ts @@ -4,6 +4,8 @@ import { ensureDotRelative, normalizePosix, rel, toPosix } from "./path-utils.js import { ContextError } from "./errors.js"; import type { ContextModule, Manifest, ScopeDefinition, ScopeManifest } from "./types.js"; import { resolveScopeIncludes } from "./config.js"; +import { loadMcpRegistry, renderMcpCatalog } from "./mcp.js"; +import { discoverSkills } from "./skills.js"; export interface GeneratedOutput { path: string; @@ -78,10 +80,15 @@ Run \`ai-context build\` after editing anything under \`.ai/\`. Generated files --- `; -export function buildRootAgents(manifest: Manifest, modules: ContextModule[]): string { - const body = manifest.skills +export function buildRootAgents( + manifest: Manifest, + modules: ContextModule[], + mcpCatalog = "" +): string { + const base = manifest.skills ? `${KIT_AWARENESS_STANZA}${modulesBody("root", modules)}` : modulesBody("root", modules); + const body = mcpCatalog ? `${base}\n\n${mcpCatalog.trim()}` : base; return withGeneratedHeader(".ai/context/modules/*.md", body); } @@ -111,7 +118,11 @@ function buildScopedAgents(cwd: string, scope: ScopeDefinition): string { return withGeneratedHeader(".ai/context/scopes.json", body); } -export function buildClaudeRoot(manifest: Manifest, modules: ContextModule[]): string { +export function buildClaudeRoot( + manifest: Manifest, + modules: ContextModule[], + mcpCatalog = "" +): string { const lines = [ "# Claude Instructions", "", @@ -138,9 +149,23 @@ export function buildClaudeRoot(manifest: Manifest, modules: ContextModule[]): s "- Keep local secrets in `.ai/secrets.local.env` (gitignored)." ); + if (mcpCatalog) { + lines.push("", mcpCatalog.trim()); + } + return withGeneratedHeader(".ai/context/scopes.json", lines.join("\n")); } +function buildMcpCatalog(cwd: string, manifest: Manifest): string { + if (!manifest.mcp) return ""; + const reg = loadMcpRegistry(cwd, manifest); + if (!reg) return ""; + const skillNames = new Set( + manifest.skills ? discoverSkills(cwd, manifest.skills.source).map((s) => s.name) : [] + ); + return renderMcpCatalog(reg.servers, (n) => skillNames.has(n)); +} + function buildScopedClaude(cwd: string, scope: ScopeDefinition): string { const includes = resolveScopeIncludes(scope, "claude"); const intro = [ @@ -212,10 +237,11 @@ export function collectGeneratedOutputs( "Manifest targets must define root output path" ); } - add(rootOutput, buildRootAgents(manifest, modules), "target:root"); + const mcpCatalog = buildMcpCatalog(cwd, manifest); + add(rootOutput, buildRootAgents(manifest, modules, mcpCatalog), "target:root"); const claudeOutput = manifest.claudeOutput ?? DEFAULT_CLAUDE_OUTPUT; - add(claudeOutput, buildClaudeRoot(manifest, modules), "claude:root"); + add(claudeOutput, buildClaudeRoot(manifest, modules, mcpCatalog), "claude:root"); for (const scope of scopeManifest.scopes) { if (scope.codexAgents && scope.codexAgents.length > 0) { diff --git a/packages/core/test/mcp-integration.test.ts b/packages/core/test/mcp-integration.test.ts new file mode 100644 index 0000000..dd23b5d --- /dev/null +++ b/packages/core/test/mcp-integration.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { buildAll, verifyAll } from "../src/engine.js"; + +let tmp: string; + +function writeManifest(extra: Record = {}): void { + fs.writeFileSync( + path.join(tmp, ".ai/context/manifest.json"), + JSON.stringify({ + version: 1, + modulesDir: ".ai/context/modules", + scopesFile: ".ai/context/scopes.json", + targets: { root: "AGENTS.md" }, + mcp: { registry: ".ai/mcp.json", clients: ["claude", "codex"] }, + ...extra, + }) + ); +} + +function writeRegistry(servers: unknown[]): void { + fs.writeFileSync(path.join(tmp, ".ai/mcp.json"), JSON.stringify({ version: 1, servers })); +} + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "aickit-mcp-int-")); + fs.mkdirSync(path.join(tmp, ".ai/context/modules"), { recursive: true }); + fs.writeFileSync( + path.join(tmp, ".ai/context/modules/010-overview.md"), + "---\nid: overview\ntargets: [root]\norder: 10\n---\n\n# Overview\n\nContent.\n" + ); + fs.writeFileSync(path.join(tmp, ".ai/context/scopes.json"), JSON.stringify({ version: 1, scopes: [] })); + writeManifest(); +}); +afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + +describe("build emits MCP config", () => { + it("writes .mcp.json and .codex/config.toml from the registry", () => { + writeRegistry([ + { name: "posthog", transport: { type: "http", url: "https://mcp.posthog.com/mcp" }, scope: "project", targets: ["claude", "codex"] }, + ]); + buildAll(tmp); + const claude = JSON.parse(fs.readFileSync(path.join(tmp, ".mcp.json"), "utf8")); + expect(claude.mcpServers.posthog.url).toBe("https://mcp.posthog.com/mcp"); + expect(claude._generated).toContain("ai-context"); + expect(fs.existsSync(path.join(tmp, ".codex/config.toml"))).toBe(true); + }); + + it("injects the catalog block into AGENTS.md for context:true servers", () => { + writeRegistry([ + { name: "posthog", transport: { type: "http", url: "u" }, scope: "project", targets: ["claude"], context: true }, + ]); + buildAll(tmp); + const agents = fs.readFileSync(path.join(tmp, "AGENTS.md"), "utf8"); + expect(agents).toContain("Available MCP servers"); + expect(agents).toContain("**posthog**"); + }); + + it("excludes user-scope servers from committed configs", () => { + writeRegistry([ + { name: "ahrefs", transport: { type: "http", url: "https://api.ahrefs.com/mcp/mcp" }, scope: "user", targets: ["claude"] }, + ]); + buildAll(tmp); + const claude = JSON.parse(fs.readFileSync(path.join(tmp, ".mcp.json"), "utf8")); + expect(claude.mcpServers.ahrefs).toBeUndefined(); + }); + + it("is idempotent — a second build reports up to date", () => { + writeRegistry([ + { name: "posthog", transport: { type: "http", url: "u" }, scope: "project", targets: ["claude"] }, + ]); + buildAll(tmp); + const second = buildAll(tmp); + expect(second.upToDate).toBe(true); + }); + + it("skips MCP entirely when manifest.mcp is absent", () => { + writeManifest(); + // overwrite manifest without mcp + fs.writeFileSync( + path.join(tmp, ".ai/context/manifest.json"), + JSON.stringify({ version: 1, modulesDir: ".ai/context/modules", scopesFile: ".ai/context/scopes.json", targets: { root: "AGENTS.md" } }) + ); + buildAll(tmp); + expect(fs.existsSync(path.join(tmp, ".mcp.json"))).toBe(false); + }); +}); + +describe("verify covers MCP staleness", () => { + it("fails when .mcp.json is stale relative to the registry", () => { + writeRegistry([ + { name: "posthog", transport: { type: "http", url: "u" }, scope: "project", targets: ["claude"] }, + ]); + buildAll(tmp); + // mutate registry without rebuilding + writeRegistry([ + { name: "posthog", transport: { type: "http", url: "u" }, scope: "project", targets: ["claude"] }, + { name: "grafana", transport: { type: "stdio", command: "uvx", args: ["mcp-grafana"] }, scope: "project", targets: ["claude"] }, + ]); + const res = verifyAll(tmp, {}); + expect(res.ok).toBe(false); + expect(res.errors.join("\n")).toMatch(/out of date/); + }); +}); From da036a9f351db12fda9df7c44f55a2e2a6158609 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 14 Jun 2026 12:16:32 -0400 Subject: [PATCH 5/8] feat(mcp): secret-leak guard in verify + MCP coverage in diff Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/engine.ts | 37 ++++++++++++++++++++++ packages/core/test/mcp-integration.test.ts | 36 +++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts index 6404783..d86bd1b 100644 --- a/packages/core/src/engine.ts +++ b/packages/core/src/engine.ts @@ -209,6 +209,20 @@ export function diffGenerated(cwd: string, options: BuildOptions = {}): DiffRepo } } + if (manifest.mcp) { + const reg = loadMcpRegistry(cwd, manifest); + if (reg) { + for (const out of planMcpOutputs(cwd, reg, manifest.mcp.clients)) { + const abs = path.join(cwd, out.path); + if (!exists(abs)) { + items.push({ path: out.path, type: "create" }); + } else if (readUtf8(abs) !== out.content) { + items.push({ path: out.path, type: "update" }); + } + } + } + } + if (manifest.skills) { const skills = discoverSkills(cwd, manifest.skills.source); const plans = planSkillMirrors(cwd, manifest, skills); @@ -280,6 +294,29 @@ export function verifyAll(cwd: string, options: VerifyOptions = {}): VerifyResul errors.push(formatContextError(skillError)); } + // MCP: scan managed config files for credential literals that slipped past + // the registry validator (e.g. a hand-edit). ${VAR} references are stripped first. + try { + const manifest = loadManifest(cwd, options.manifestPath); + if (manifest.mcp) { + const reg = loadMcpRegistry(cwd, manifest); + if (reg) { + for (const out of planMcpOutputs(cwd, reg, manifest.mcp.clients)) { + const abs = path.join(cwd, out.path); + if (!exists(abs)) continue; + const stripped = readUtf8(abs).replace(/\$\{[A-Z0-9_]+\}/g, ""); + if (/(sk-|xox[baprs]-|ghp_|AKIA|-----BEGIN|[A-Za-z0-9_-]{40,})/.test(stripped)) { + errors.push( + `[AICTX_MCP_SECRET_LEAK] Possible secret literal in managed file ${out.path}; use a \${VAR} reference` + ); + } + } + } + } + } catch (mcpError) { + errors.push(formatContextError(mcpError)); + } + const diff = diffGenerated(cwd, { manifestPath: options.manifestPath }); const orphanDeletes = diff.items.filter((item) => item.type === "delete"); if (orphanDeletes.length > 0) { diff --git a/packages/core/test/mcp-integration.test.ts b/packages/core/test/mcp-integration.test.ts index dd23b5d..2b05571 100644 --- a/packages/core/test/mcp-integration.test.ts +++ b/packages/core/test/mcp-integration.test.ts @@ -103,4 +103,40 @@ describe("verify covers MCP staleness", () => { expect(res.ok).toBe(false); expect(res.errors.join("\n")).toMatch(/out of date/); }); + + it("fails when a generated config contains a resolved secret literal", () => { + writeRegistry([ + { name: "posthog", transport: { type: "http", url: "u" }, scope: "project", targets: ["claude"] }, + ]); + buildAll(tmp); + // Simulate a leak: hand-edit the managed file to embed a credential literal. + fs.writeFileSync( + path.join(tmp, ".mcp.json"), + JSON.stringify( + { + _generated: "ai-context: do not edit; generated from .ai/mcp.json", + mcpServers: { posthog: { type: "http", url: "u", env: { TOKEN: "ghp_abcdefghijklmnopqrstuvwxyz0123456789" } } }, + }, + null, + 2 + ) + "\n" + ); + const res = verifyAll(tmp, {}); + expect(res.ok).toBe(false); + expect(res.errors.join("\n")).toMatch(/secret/i); + }); +}); + +describe("diff covers MCP outputs", () => { + it("reports .mcp.json as create then update", async () => { + const { diffGenerated } = await import("../src/engine.js"); + writeRegistry([ + { name: "posthog", transport: { type: "http", url: "u" }, scope: "project", targets: ["claude"] }, + ]); + const before = diffGenerated(tmp); + expect(before.items.some((i) => i.path === ".mcp.json" && i.type === "create")).toBe(true); + buildAll(tmp); + const after = diffGenerated(tmp); + expect(after.items.some((i) => i.path === ".mcp.json")).toBe(false); + }); }); From 25bdefc832465afefd285e309ba8ebd8c60e8ec2 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 14 Jun 2026 12:17:03 -0400 Subject: [PATCH 6/8] test(mcp): dogfood integration for posthog/grafana/rn-debugger fan-out Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/test/mcp-integration.test.ts | 61 ++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/core/test/mcp-integration.test.ts b/packages/core/test/mcp-integration.test.ts index 2b05571..2aec9bb 100644 --- a/packages/core/test/mcp-integration.test.ts +++ b/packages/core/test/mcp-integration.test.ts @@ -127,6 +127,67 @@ describe("verify covers MCP staleness", () => { }); }); +describe("dogfood: list-forge's real servers", () => { + it("posthog + grafana + rn-debugger produce stable per-client configs", () => { + writeRegistry([ + { + name: "posthog", + transport: { type: "http", url: "https://mcp.posthog.com/mcp" }, + scope: "project", + targets: ["claude", "codex"], + env: { POSTHOG_PERSONAL_API_KEY: "${POSTHOG_PERSONAL_API_KEY}" }, + skill: "analytics-ops", + context: true, + }, + { + name: "grafana", + transport: { type: "stdio", command: "uvx", args: ["mcp-grafana"] }, + scope: "project", + targets: ["claude", "codex"], + env: { GRAFANA_URL: "${GRAFANA_URL}", GRAFANA_SERVICE_ACCOUNT_TOKEN: "${GRAFANA_SERVICE_ACCOUNT_TOKEN}" }, + }, + { + name: "rn-debugger", + transport: { type: "stdio", command: "node_modules/.bin/react-native-ai-devtools" }, + scope: "project", + targets: ["claude"], + }, + ]); + // analytics-ops skill must exist for the context-link to resolve. + fs.mkdirSync(path.join(tmp, ".ai/skills/analytics-ops"), { recursive: true }); + fs.writeFileSync( + path.join(tmp, ".ai/skills/analytics-ops/SKILL.md"), + "---\nname: analytics-ops\ndescription: PostHog + Grafana ops\n---\n\n# Analytics Ops\n" + ); + fs.writeFileSync( + path.join(tmp, ".ai/context/manifest.json"), + JSON.stringify({ + version: 1, + modulesDir: ".ai/context/modules", + scopesFile: ".ai/context/scopes.json", + targets: { root: "AGENTS.md" }, + skills: { source: ".ai/skills", mirrors: [".agents/skills", ".claude/skills"] }, + mcp: { registry: ".ai/mcp.json", clients: ["claude", "codex"] }, + }) + ); + buildAll(tmp); + + const claude = JSON.parse(fs.readFileSync(path.join(tmp, ".mcp.json"), "utf8")); + expect(Object.keys(claude.mcpServers).sort()).toEqual(["grafana", "posthog", "rn-debugger"]); + expect(claude.mcpServers.posthog.env.POSTHOG_PERSONAL_API_KEY).toBe("${POSTHOG_PERSONAL_API_KEY}"); + expect(claude.mcpServers["rn-debugger"]).toEqual({ command: "node_modules/.bin/react-native-ai-devtools" }); + + const codex = fs.readFileSync(path.join(tmp, ".codex/config.toml"), "utf8"); + expect(codex).toContain("[mcp_servers.grafana]"); + expect(codex).toContain("[mcp_servers.posthog]"); + expect(codex).not.toContain("rn-debugger"); // not a codex target + + const agents = fs.readFileSync(path.join(tmp, "AGENTS.md"), "utf8"); + expect(agents).toContain("**posthog**"); + expect(agents).toContain("`analytics-ops` skill"); + }); +}); + describe("diff covers MCP outputs", () => { it("reports .mcp.json as create then update", async () => { const { diffGenerated } = await import("../src/engine.js"); From e203d5c133d14ca69bc4948aada19a5dba42b8dc Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 14 Jun 2026 12:20:38 -0400 Subject: [PATCH 7/8] feat(mcp): ai-context mcp list|install --user|setup CLI Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/commands/mcp/index.ts | 28 +++++++++++ packages/cli/src/commands/mcp/install.ts | 64 ++++++++++++++++++++++++ packages/cli/src/commands/mcp/list.ts | 57 +++++++++++++++++++++ packages/cli/src/commands/mcp/setup.ts | 37 ++++++++++++++ packages/cli/src/index.ts | 2 + packages/cli/test/mcp-cli.test.ts | 60 ++++++++++++++++++++++ 6 files changed, 248 insertions(+) create mode 100644 packages/cli/src/commands/mcp/index.ts create mode 100644 packages/cli/src/commands/mcp/install.ts create mode 100644 packages/cli/src/commands/mcp/list.ts create mode 100644 packages/cli/src/commands/mcp/setup.ts create mode 100644 packages/cli/test/mcp-cli.test.ts diff --git a/packages/cli/src/commands/mcp/index.ts b/packages/cli/src/commands/mcp/index.ts new file mode 100644 index 0000000..f0fbe6b --- /dev/null +++ b/packages/cli/src/commands/mcp/index.ts @@ -0,0 +1,28 @@ +import { Command } from "commander"; +import { runMcpList } from "./list.js"; +import { runMcpInstall } from "./install.js"; +import { runMcpSetup } from "./setup.js"; + +export function registerMcpCommand(program: Command): void { + const mcp = program.command("mcp").description("Manage MCP servers (.ai/mcp.json)"); + + mcp + .command("list") + .description("List registered MCP servers") + .option("--json", "Emit JSON output", false) + .action((opts: { json: boolean }) => runMcpList({ json: Boolean(opts.json) })); + + mcp + .command("install ") + .description("Install a user-scope MCP server into your client's user config") + .option("--user", "Install into user config (required)", false) + .option("--dry-run", "Print the command without running it", false) + .action((name: string, opts: { user: boolean; dryRun: boolean }) => + runMcpInstall(name, { user: Boolean(opts.user), dryRun: Boolean(opts.dryRun) }) + ); + + mcp + .command("setup ") + .description("Run a server's setup command or print its auth hint") + .action((name: string) => runMcpSetup(name)); +} diff --git a/packages/cli/src/commands/mcp/install.ts b/packages/cli/src/commands/mcp/install.ts new file mode 100644 index 0000000..a0c6efe --- /dev/null +++ b/packages/cli/src/commands/mcp/install.ts @@ -0,0 +1,64 @@ +import process from "node:process"; +import { execFileSync } from "node:child_process"; +import { + formatContextError, + loadManifest, + loadMcpRegistry, +} from "@timothycrooker/ai-context-core"; +import type { McpServer } from "@timothycrooker/ai-context-core"; + +interface InstallOptions { + user?: boolean; + dryRun?: boolean; +} + +// Build the `claude mcp add ...` argv for a user-scope server. +function claudeAddArgs(s: McpServer): string[] { + const t = s.transport; + if (t.type === "stdio") { + const env = Object.entries(s.env ?? {}).flatMap(([k, v]) => ["-e", `${k}=${v}`]); + return ["mcp", "add", s.name, "-s", "user", ...env, "--", t.command, ...(t.args ?? [])]; + } + const transportFlag = t.type === "sse" ? "sse" : "http"; + return ["mcp", "add", s.name, t.url, "-t", transportFlag, "-s", "user"]; +} + +export function runMcpInstall(name: string, opts: InstallOptions): void { + try { + const cwd = process.cwd(); + const manifest = loadManifest(cwd); + const reg = loadMcpRegistry(cwd, manifest); + const server = reg?.servers.find((s) => s.name === name); + if (!server) { + console.error(`MCP server '${name}' not found in registry.`); + process.exit(1); + return; + } + if (!opts.user) { + console.error( + "Only --user install is supported. Project-scope servers are generated by `ai-context build`." + ); + process.exit(1); + return; + } + + for (const target of server.targets) { + if (target !== "claude") { + console.log(`note: automated user-install is not implemented for '${target}'; configure '${name}' manually.`); + continue; + } + const args = claudeAddArgs(server); + console.log(`claude ${args.join(" ")}`); + if (!opts.dryRun) { + execFileSync("claude", args, { stdio: "inherit" }); + } + } + + if (server.auth === "oauth") { + console.log(`Next: run /mcp in your client to authenticate '${name}'.`); + } + } catch (error) { + console.error(formatContextError(error)); + process.exit(1); + } +} diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts new file mode 100644 index 0000000..38d0dea --- /dev/null +++ b/packages/cli/src/commands/mcp/list.ts @@ -0,0 +1,57 @@ +import process from "node:process"; +import { + discoverSkills, + formatContextError, + loadManifest, + loadMcpRegistry, + resolveSkillLink, +} from "@timothycrooker/ai-context-core"; + +interface ListOptions { + json?: boolean; +} + +export function runMcpList(opts: ListOptions): void { + try { + const cwd = process.cwd(); + const manifest = loadManifest(cwd); + const reg = loadMcpRegistry(cwd, manifest); + if (!manifest.mcp || !reg) { + if (opts.json) console.log(JSON.stringify({ servers: [] }, null, 2)); + else console.log("No MCP registry configured (manifest.mcp absent)."); + return; + } + + const skillNames = new Set( + manifest.skills ? discoverSkills(cwd, manifest.skills.source).map((s) => s.name) : [] + ); + + const rows = reg.servers.map((s) => ({ + name: s.name, + scope: s.scope, + targets: s.targets, + transport: s.transport.type, + skill: resolveSkillLink(s, (n) => skillNames.has(n)) ?? null, + auth: s.auth ?? null, + })); + + if (opts.json) { + console.log(JSON.stringify({ servers: rows }, null, 2)); + return; + } + + if (rows.length === 0) { + console.log("No MCP servers registered."); + return; + } + + for (const r of rows) { + const skill = r.skill ? `skill=${r.skill}` : "no-skill"; + const hint = r.scope === "user" ? ` (install: ai-context mcp install ${r.name} --user)` : ""; + console.log(`${r.name} [${r.scope}] targets=${r.targets.join(",")} ${r.transport} ${skill}${hint}`); + } + } catch (error) { + console.error(formatContextError(error)); + process.exit(1); + } +} diff --git a/packages/cli/src/commands/mcp/setup.ts b/packages/cli/src/commands/mcp/setup.ts new file mode 100644 index 0000000..13d56d1 --- /dev/null +++ b/packages/cli/src/commands/mcp/setup.ts @@ -0,0 +1,37 @@ +import process from "node:process"; +import { execSync } from "node:child_process"; +import { + formatContextError, + loadManifest, + loadMcpRegistry, +} from "@timothycrooker/ai-context-core"; + +export function runMcpSetup(name: string): void { + try { + const cwd = process.cwd(); + const manifest = loadManifest(cwd); + const reg = loadMcpRegistry(cwd, manifest); + const server = reg?.servers.find((s) => s.name === name); + if (!server) { + console.error(`MCP server '${name}' not found in registry.`); + process.exit(1); + return; + } + + if (server.setup) { + console.log(`Running setup for '${name}': ${server.setup}`); + execSync(server.setup, { cwd, stdio: "inherit" }); + return; + } + + if (server.auth === "oauth") { + console.log(`'${name}' uses OAuth — run /mcp in your client and authenticate.`); + return; + } + + console.log(`No setup defined for '${name}'.`); + } catch (error) { + console.error(formatContextError(error)); + process.exit(1); + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 15b2d10..f390e11 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,6 +13,7 @@ import { Command } from "commander"; import { resolveCliVersion } from "./version.js"; import { registerSkillsCommand } from "./commands/skills/index.js"; import { registerMigrateCommand } from "./commands/migrate/index.js"; +import { registerMcpCommand } from "./commands/mcp/index.js"; const program = new Command(); @@ -23,6 +24,7 @@ program registerSkillsCommand(program); registerMigrateCommand(program); +registerMcpCommand(program); program .command("init") diff --git a/packages/cli/test/mcp-cli.test.ts b/packages/cli/test/mcp-cli.test.ts new file mode 100644 index 0000000..970dc05 --- /dev/null +++ b/packages/cli/test/mcp-cli.test.ts @@ -0,0 +1,60 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const cliBin = path.resolve(__dirname, "../dist/index.js"); + +describe("ai-context mcp", () => { + let tmp: string; + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "aickit-cli-mcp-")); + fs.mkdirSync(path.join(tmp, ".ai/context/modules"), { recursive: true }); + fs.writeFileSync( + path.join(tmp, ".ai/context/modules/010-overview.md"), + "---\nid: overview\ntargets: [root]\norder: 10\n---\n\n# Overview\n" + ); + fs.writeFileSync(path.join(tmp, ".ai/context/scopes.json"), JSON.stringify({ version: 1, scopes: [] })); + fs.writeFileSync( + path.join(tmp, ".ai/context/manifest.json"), + JSON.stringify({ + version: 1, + modulesDir: ".ai/context/modules", + scopesFile: ".ai/context/scopes.json", + targets: { root: "AGENTS.md" }, + mcp: { registry: ".ai/mcp.json", clients: ["claude", "codex"] }, + }) + ); + fs.writeFileSync( + path.join(tmp, ".ai/mcp.json"), + JSON.stringify({ + version: 1, + servers: [ + { name: "posthog", transport: { type: "http", url: "https://mcp.posthog.com/mcp" }, scope: "project", targets: ["claude", "codex"] }, + { name: "ahrefs", transport: { type: "http", url: "https://api.ahrefs.com/mcp/mcp" }, scope: "user", targets: ["claude"], auth: "oauth" }, + ], + }) + ); + }); + afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + + it("lists registered servers with scope + install hint", () => { + const out = execSync(`node ${cliBin} mcp list`, { cwd: tmp }).toString(); + expect(out).toContain("posthog"); + expect(out).toContain("[project]"); + expect(out).toContain("ahrefs"); + expect(out).toContain("install: ai-context mcp install ahrefs --user"); + }); + + it("install --user --dry-run prints the claude add command without running it", () => { + const out = execSync(`node ${cliBin} mcp install ahrefs --user --dry-run`, { cwd: tmp }).toString(); + expect(out).toContain("claude mcp add ahrefs https://api.ahrefs.com/mcp/mcp -t http -s user"); + }); + + it("list --json emits structured output", () => { + const out = execSync(`node ${cliBin} mcp list --json`, { cwd: tmp }).toString(); + const parsed = JSON.parse(out); + expect(parsed.servers.find((s: { name: string }) => s.name === "posthog").scope).toBe("project"); + }); +}); From edc89d14d050737c36a77894a7dd69dfbf594c3c Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 14 Jun 2026 12:22:53 -0400 Subject: [PATCH 8/8] docs(mcp): document the MCP primitive in meta-skill + README Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 + .../src/skills/ai-context-kit/SKILL.md | 16 ++++ .../references/authoring-mcp.md | 91 +++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 packages/templates/src/skills/ai-context-kit/references/authoring-mcp.md diff --git a/README.md b/README.md index 49f8722..4f5d3c4 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,10 @@ Set up the trusted publisher in npm for `TimCrooker/ai-context-kit` before runni ai-context-kit supports cross-CLI skills via the [agentskills.io](https://agentskills.io) open standard. Author once at `.ai/skills//SKILL.md`; the kit creates symlinks at `.agents/skills/` (read by Codex, Gemini, Cursor, Goose, OpenCode, Aider, and 17+ other tools) and `.claude/skills/` (read by Claude Code). See [docs/skills-guide.md](docs/skills-guide.md). +## MCP servers (1.2+) + +Register MCP servers once in `.ai/mcp.json`; `ai-context build` fans them out to each agent client's native config (Claude `.mcp.json`, Codex `.codex/config.toml`) instead of hand-editing per-client, per-machine files. Servers can carry backing — a linked skill and a one-line catalog entry in `AGENTS.md`/`CLAUDE.md` — so an agent gets the tool *and* the knowledge to use it. Secrets stay as `${VAR}` references (resolved from `.ai/secrets.local.env`); `project`-scope servers are committed while `user`-scope servers are installed per-machine via `ai-context mcp install --user`. See the `references/authoring-mcp.md` section of the `ai-context-kit` skill. + ## Migration (1.1+) Existing repos with legacy `.claude/skills/` layouts can migrate to ai-context-kit's `.ai/skills/` source-of-truth via: diff --git a/packages/templates/src/skills/ai-context-kit/SKILL.md b/packages/templates/src/skills/ai-context-kit/SKILL.md index ca6ccff..162f291 100644 --- a/packages/templates/src/skills/ai-context-kit/SKILL.md +++ b/packages/templates/src/skills/ai-context-kit/SKILL.md @@ -19,6 +19,7 @@ This repo's `AGENTS.md`, `CLAUDE.md`, `.claude/rules/*.md`, `.agents/skills/*`, | Understand `.ai/context/manifest.json` | `references/manifest-schema.md` | | Look up an `ai-context` CLI command | `references/cli-commands.md` | | Pick what kind of content to put in a module vs a skill vs a rule | `references/content-guide.md` | +| Register an MCP server (fans out to client configs) | `references/authoring-mcp.md` | ## Authoring workflow (read this first) @@ -44,6 +45,19 @@ ai-context verify # CI-friendly: fails if outputs are stale ai-context doctor # diagnose config / mirror issues ``` +## MCP servers (1.2+) + +Register MCP servers once in `.ai/mcp.json`; `ai-context build` fans them out to each agent client's native config (Claude `.mcp.json`, Codex `.codex/config.toml`) and lists `context: true` servers in the AGENTS.md/CLAUDE.md catalog. See `references/authoring-mcp.md`. + +Principles: + +- **One registry, many clients.** Declare a server once; the build emits per-client config. Add a client by listing it in the server's `targets` and `manifest.mcp.clients`. +- **Secrets are references, never literals.** Use `${VAR}` in `env`; values resolve from `.ai/secrets.local.env`. `ai-context verify` fails if a credential literal lands in a generated file. +- **Declaration is shared; auth is per-user.** `project`-scope servers are committed; `user`-scope servers are installed per-machine via `ai-context mcp install --user` and authenticated with `/mcp`. +- **Back a server with knowledge, not just config.** Set `skill` (or co-name a `.ai/skills/`) and `context: true` so agents know what the tool is for. + +Commands: `ai-context mcp list`, `ai-context mcp install --user`, `ai-context mcp setup `. + ## Directory map | Path | What lives here | Who edits | @@ -51,6 +65,8 @@ ai-context doctor # diagnose config / mirror issues | `.ai/context/modules/*.md` | Module content (composed into AGENTS.md/CLAUDE.md per target) | You | | `.ai/context/scopes.json` | Scope definitions: which modules go to which targets | You | | `.ai/context/manifest.json` | Top-level kit configuration | You | +| `.ai/mcp.json` | MCP server registry (fans out to client configs) | You | +| `.mcp.json`, `.codex/config.toml` | Per-client MCP config | Kit (generated) | | `.ai/skills//` | Skill source (SKILL.md + optional references/, scripts/, assets/) | You | | `.agents/skills/` | Symlink to `.ai/skills//` — discovered by Codex, Gemini, Cursor, Goose, etc. | Kit (symlink) | | `.claude/skills/` | Symlink to `.ai/skills//` — discovered by Claude Code | Kit (symlink) | diff --git a/packages/templates/src/skills/ai-context-kit/references/authoring-mcp.md b/packages/templates/src/skills/ai-context-kit/references/authoring-mcp.md new file mode 100644 index 0000000..4bbfdc9 --- /dev/null +++ b/packages/templates/src/skills/ai-context-kit/references/authoring-mcp.md @@ -0,0 +1,91 @@ +# Authoring MCP servers + +Register Model Context Protocol (MCP) servers once in `.ai/mcp.json`. On `ai-context build`, the kit fans each server out to every agent client's native config and (optionally) advertises it in the AGENTS.md / CLAUDE.md catalog. MCP is the kit's third primitive, alongside context and skills. + +## Enable it + +Add an `mcp` block to `.ai/context/manifest.json`: + +```json +{ + "mcp": { "registry": ".ai/mcp.json", "clients": ["claude", "codex"] } +} +``` + +`clients` is the set of adapters this repo emits. v1 ships `claude` and `codex`. + +## Declare servers + +`.ai/mcp.json`: + +```json +{ + "version": 1, + "servers": [ + { + "name": "posthog", + "transport": { "type": "http", "url": "https://mcp.posthog.com/mcp" }, + "scope": "project", + "targets": ["claude", "codex"], + "env": { "POSTHOG_PERSONAL_API_KEY": "${POSTHOG_PERSONAL_API_KEY}" }, + "skill": "analytics-ops", + "context": true + }, + { + "name": "ahrefs", + "transport": { "type": "http", "url": "https://api.ahrefs.com/mcp/mcp" }, + "scope": "user", + "targets": ["claude"], + "auth": "oauth", + "context": true + } + ] +} +``` + +Field reference: + +| Field | Meaning | +|---|---| +| `name` | Lowercase, hyphenated identifier. Unique. | +| `transport` | `{ "type": "http"\|"sse", "url" }` or `{ "type": "stdio", "command", "args"? }`. | +| `scope` | `project` (committed config) or `user` (per-machine, not committed). | +| `targets` | Which clients get this server. | +| `auth` | `oauth` \| `env` \| `none` — drives the setup hint. | +| `env` | Map of env var injections. **Values must be `${VAR}` references.** | +| `skill` | Backing skill name. Defaults to a co-named `.ai/skills/`. | +| `context` | When `true`, list the server in the AGENTS/CLAUDE catalog. | +| `setup` | Optional shell command run by `ai-context mcp setup `. | + +## What the build emits + +- **Claude** → `.mcp.json` (`mcpServers` map) +- **Codex** → `.codex/config.toml` (`[mcp_servers.]` tables). If a hand-written `.codex/config.toml` already exists, the kit writes `.codex/mcp.toml` instead so it never clobbers your budget config. +- **Catalog** → an "Available MCP servers" block appended to the root AGENTS.md / CLAUDE.md for `context: true` servers, pointing at each server's backing skill. + +Only `project`-scope servers are written into committed files. + +## Secrets + +Never put a literal secret in `env`. Use `${VAR}` references; values resolve from `.ai/secrets.local.env` at the agent's runtime. `ai-context verify` fails the build if a credential-looking literal appears in a generated config (`AICTX_MCP_SECRET_LEAK`) or in the registry itself (`AICTX_MCP_SECRET_LITERAL`). + +## User-scope servers + +`scope: "user"` servers are personal (your own API keys, not the team's). They are not written into the repo. Install them per-machine: + +```bash +ai-context mcp install --user # writes to your client's user config +ai-context mcp setup # runs setup, or prints the auth hint +``` + +Remote/OAuth servers: the kit emits the declaration, but authentication is always per-user — run `/mcp` in your client after install. + +## Commands + +```bash +ai-context mcp list # show registered servers + backing skills +ai-context mcp install --user # install a user-scope server +ai-context mcp setup # run setup / print auth hint +ai-context build # (re)generate all client configs +ai-context verify # fail if configs are stale or leak secrets +```