Skip to content
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/SKILL.md`; the kit creates symlinks at `.agents/skills/<name>` (read by Codex, Gemini, Cursor, Goose, OpenCode, Aider, and 17+ other tools) and `.claude/skills/<name>` (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 <name> --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:
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/commands/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -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 <name>")
.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 <name>")
.description("Run a server's setup command or print its auth hint")
.action((name: string) => runMcpSetup(name));
}
64 changes: 64 additions & 0 deletions packages/cli/src/commands/mcp/install.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
57 changes: 57 additions & 0 deletions packages/cli/src/commands/mcp/list.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
37 changes: 37 additions & 0 deletions packages/cli/src/commands/mcp/setup.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
2 changes: 2 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -23,6 +24,7 @@ program

registerSkillsCommand(program);
registerMigrateCommand(program);
registerMcpCommand(program);

program
.command("init")
Expand Down
60 changes: 60 additions & 0 deletions packages/cli/test/mcp-cli.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
51 changes: 51 additions & 0 deletions packages/core/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
findOrphanedSkillMirrors,
planSkillMirrors,
} from "./skills.js";
import { loadMcpRegistry, planMcpOutputs } from "./mcp.js";
import type {
BuildOptions,
BuildResult,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -195,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);
Expand Down Expand Up @@ -266,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) {
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Loading
Loading