diff --git a/docs/public/llms.txt b/docs/public/llms.txt index c1e4816..3091aa3 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -284,6 +284,39 @@ dotagents sync Reconcile project state: adopt orphaned skills (installed but not declared), regenerate `.agents/.gitignore`, check for missing skills, verify integrity hashes, repair symlinks, verify/repair MCP and hook configs. Reports issues as warnings or errors. +### mcp add + +``` +dotagents mcp add --command [--args ...] [--env ...] +dotagents mcp add --url [--header ...] [--env ...] +``` + +Add an MCP server declaration to `agents.toml` and run `install` to generate agent configs. Specify exactly one transport: `--command` for stdio or `--url` for HTTP. + +| Flag | Description | +|------|-------------| +| `--command ` | Command to execute (stdio transport) | +| `--args ` | Command argument (repeatable) | +| `--url ` | Server URL (HTTP transport) | +| `--header ` | HTTP header (repeatable, url servers only) | +| `--env ` | Environment variable name to pass through (repeatable) | + +### mcp remove + +``` +dotagents mcp remove +``` + +Remove an MCP server declaration from `agents.toml` and run `install` to regenerate agent configs. + +### mcp list + +``` +dotagents mcp list [--json] +``` + +Show declared MCP servers. Use `--json` for machine-readable output. + ### list ``` diff --git a/docs/src/app/cli/page.tsx b/docs/src/app/cli/page.tsx index 1ee3b28..618417b 100644 --- a/docs/src/app/cli/page.tsx +++ b/docs/src/app/cli/page.tsx @@ -199,6 +199,77 @@ export default function CliPage() { description="Reconcile project state: adopt orphaned skills, regenerate gitignore, verify integrity hashes, repair symlinks and MCP/hook configs. Reports issues as warnings or errors." /> + + Add an MCP server declaration to agents.toml and run{" "} + install to generate agent configs. Specify exactly one + transport: --command for stdio or --url{" "} + for HTTP. + + } + options={[ + { + flag: "--command ", + description: "Command to execute (stdio transport)", + }, + { + flag: "--args ", + description: "Command argument (repeatable)", + }, + { + flag: "--url ", + description: "Server URL (HTTP transport)", + }, + { + flag: "--header ", + description: "HTTP header (repeatable, url servers only)", + }, + { + flag: "--env ", + description: + "Environment variable name to pass through (repeatable)", + }, + ]} + examples={[ + "# Stdio server", + "dotagents mcp add github --command npx --args -y --args @modelcontextprotocol/server-github --env GITHUB_TOKEN", + "", + "# HTTP server with auth header", + "dotagents mcp add remote --url https://mcp.example.com/sse --header Authorization:Bearer\\ tok", + ]} + /> + + + Remove an MCP server declaration from agents.toml and + run install to regenerate agent configs. + + } + examples={["dotagents mcp remove github"]} + /> + + + Show declared MCP servers. Use --json for + machine-readable output. + + } + examples={[ + "dotagents mcp list", + "dotagents mcp list --json", + ]} + /> + { + let tmpDir: string; + let stateDir: string; + let projectRoot: string; + let scope: ScopeRoot; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "dotagents-mcp-")); + stateDir = join(tmpDir, "state"); + projectRoot = join(tmpDir, "project"); + + process.env["DOTAGENTS_STATE_DIR"] = stateDir; + + await mkdir(join(projectRoot, ".agents", "skills"), { recursive: true }); + await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); + + scope = { + scope: "project", + root: projectRoot, + agentsDir: join(projectRoot, ".agents"), + skillsDir: join(projectRoot, ".agents", "skills"), + configPath: join(projectRoot, "agents.toml"), + lockPath: join(projectRoot, "agents.lock"), + }; + }); + + afterEach(async () => { + delete process.env["DOTAGENTS_STATE_DIR"]; + await rm(tmpDir, { recursive: true }); + }); + + describe("validateMcpName", () => { + it("accepts valid names", () => { + expect(() => validateMcpName("github")).not.toThrow(); + expect(() => validateMcpName("my-server")).not.toThrow(); + expect(() => validateMcpName("server.v2")).not.toThrow(); + expect(() => validateMcpName("MCP_Server")).not.toThrow(); + }); + + it("rejects invalid names", () => { + expect(() => validateMcpName("")).toThrow(McpError); + expect(() => validateMcpName("-bad")).toThrow(McpError); + expect(() => validateMcpName(".bad")).toThrow(McpError); + expect(() => validateMcpName("has space")).toThrow(McpError); + }); + }); + + describe("parseHeader", () => { + it("splits on first colon", () => { + expect(parseHeader("Authorization:Bearer tok")).toEqual(["Authorization", "Bearer tok"]); + }); + + it("handles colons in value", () => { + expect(parseHeader("X-Key:val:ue")).toEqual(["X-Key", "val:ue"]); + }); + + it("throws on malformed header", () => { + expect(() => parseHeader("no-colon")).toThrow(McpError); + expect(() => parseHeader(":no-key")).toThrow(McpError); + }); + }); + + describe("runMcpAdd", () => { + it("adds a stdio server", async () => { + await runMcpAdd({ + scope, + name: "github", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + env: ["GITHUB_TOKEN"], + }); + + const config = await loadConfig(scope.configPath); + expect(config.mcp).toHaveLength(1); + expect(config.mcp[0]!.name).toBe("github"); + expect(config.mcp[0]!.command).toBe("npx"); + expect(config.mcp[0]!.args).toEqual(["-y", "@modelcontextprotocol/server-github"]); + expect(config.mcp[0]!.env).toEqual(["GITHUB_TOKEN"]); + }); + + it("adds an http server", async () => { + await runMcpAdd({ + scope, + name: "remote", + url: "https://mcp.example.com/sse", + headers: ["Authorization:Bearer tok"], + env: ["API_KEY"], + }); + + const config = await loadConfig(scope.configPath); + expect(config.mcp).toHaveLength(1); + expect(config.mcp[0]!.url).toBe("https://mcp.example.com/sse"); + expect(config.mcp[0]!.headers).toEqual({ Authorization: "Bearer tok" }); + }); + + it("rejects duplicate name", async () => { + await runMcpAdd({ scope, name: "github", command: "npx" }); + await expect( + runMcpAdd({ scope, name: "github", command: "other" }), + ).rejects.toThrow(/already exists/); + }); + + it("rejects both --command and --url", async () => { + await expect( + runMcpAdd({ scope, name: "bad", command: "npx", url: "https://example.com" }), + ).rejects.toThrow(/Cannot specify both/); + }); + + it("rejects neither --command nor --url", async () => { + await expect( + runMcpAdd({ scope, name: "bad" }), + ).rejects.toThrow(/Must specify either/); + }); + + it("rejects invalid name", async () => { + await expect( + runMcpAdd({ scope, name: "-bad", command: "npx" }), + ).rejects.toThrow(McpError); + }); + }); + + describe("runMcpRemove", () => { + it("removes an existing server", async () => { + await runMcpAdd({ scope, name: "github", command: "npx" }); + await runMcpRemove({ scope, name: "github" }); + + const config = await loadConfig(scope.configPath); + expect(config.mcp).toHaveLength(0); + }); + + it("throws for non-existent server", async () => { + await expect( + runMcpRemove({ scope, name: "nope" }), + ).rejects.toThrow(/not found/); + }); + + it("preserves other servers", async () => { + await runMcpAdd({ scope, name: "a", command: "cmd-a" }); + await runMcpAdd({ scope, name: "b", command: "cmd-b" }); + await runMcpRemove({ scope, name: "a" }); + + const config = await loadConfig(scope.configPath); + expect(config.mcp).toHaveLength(1); + expect(config.mcp[0]!.name).toBe("b"); + }); + }); + + describe("getMcpList", () => { + it("returns empty for no servers", async () => { + const config = await loadConfig(scope.configPath); + expect(getMcpList(config)).toEqual([]); + }); + + it("returns stdio entries", async () => { + await runMcpAdd({ scope, name: "github", command: "npx", env: ["TOKEN"] }); + const config = await loadConfig(scope.configPath); + const list = getMcpList(config); + expect(list).toHaveLength(1); + expect(list[0]).toEqual({ + name: "github", + transport: "stdio", + target: "npx", + env: ["TOKEN"], + }); + }); + + it("returns http entries", async () => { + await runMcpAdd({ scope, name: "remote", url: "https://example.com/mcp" }); + const config = await loadConfig(scope.configPath); + const list = getMcpList(config); + expect(list).toHaveLength(1); + expect(list[0]).toEqual({ + name: "remote", + transport: "http", + target: "https://example.com/mcp", + env: [], + }); + }); + }); +}); diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts new file mode 100644 index 0000000..43a4100 --- /dev/null +++ b/src/cli/commands/mcp.ts @@ -0,0 +1,260 @@ +import { resolve } from "node:path"; +import { parseArgs } from "node:util"; +import chalk from "chalk"; +import { loadConfig } from "../../config/loader.js"; +import type { AgentsConfig, McpConfig } from "../../config/schema.js"; +import { addMcpToConfig, removeMcpFromConfig } from "../../config/writer.js"; +import { runInstall } from "./install.js"; +import { resolveScope, resolveDefaultScope, ScopeError } from "../../scope.js"; +import type { ScopeRoot } from "../../scope.js"; + +export class McpError extends Error { + constructor(message: string) { + super(message); + this.name = "McpError"; + } +} + +const MCP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +export function validateMcpName(name: string): void { + if (!MCP_NAME_RE.test(name)) { + throw new McpError( + `Invalid MCP server name "${name}". Names must start with alphanumeric and contain only [a-zA-Z0-9._-].`, + ); + } +} + +export function parseHeader(raw: string): [string, string] { + const idx = raw.indexOf(":"); + if (idx < 1) { + throw new McpError(`Invalid header format: "${raw}". Expected "Key:Value".`); + } + return [raw.slice(0, idx), raw.slice(idx + 1)]; +} + +export interface McpAddOptions { + scope: ScopeRoot; + name: string; + command?: string; + args?: string[]; + url?: string; + headers?: string[]; + env?: string[]; +} + +export async function runMcpAdd(opts: McpAddOptions): Promise { + const { scope, name, command, url } = opts; + + validateMcpName(name); + + if (command && url) { + throw new McpError("Cannot specify both --command and --url."); + } + if (!command && !url) { + throw new McpError("Must specify either --command or --url."); + } + + const config = await loadConfig(scope.configPath); + + if (config.mcp.some((m) => m.name === name)) { + throw new McpError(`MCP server "${name}" already exists in agents.toml. Remove it first.`); + } + + const entry: McpConfig = { + name, + ...(command ? { command, args: opts.args } : {}), + ...(url ? { url, headers: buildHeaders(opts.headers) } : {}), + env: opts.env ?? [], + }; + + await addMcpToConfig(scope.configPath, entry); + await runInstall({ scope }); +} + +function buildHeaders(raw?: string[]): Record | undefined { + if (!raw || raw.length === 0) return undefined; + const headers: Record = {}; + for (const h of raw) { + const [key, value] = parseHeader(h); + headers[key] = value; + } + return headers; +} + +export interface McpRemoveOptions { + scope: ScopeRoot; + name: string; +} + +export async function runMcpRemove(opts: McpRemoveOptions): Promise { + const { scope, name } = opts; + const config = await loadConfig(scope.configPath); + + if (!config.mcp.some((m) => m.name === name)) { + throw new McpError(`MCP server "${name}" not found in agents.toml.`); + } + + await removeMcpFromConfig(scope.configPath, name); + await runInstall({ scope }); +} + +export interface McpListEntry { + name: string; + transport: "stdio" | "http"; + target: string; + env: string[]; +} + +export function getMcpList(config: AgentsConfig): McpListEntry[] { + return config.mcp.map((m) => ({ + name: m.name, + transport: m.command ? "stdio" : "http", + target: (m.command ?? m.url)!, + env: m.env, + })); +} + +// --- CLI wrappers --- + +async function mcpAdd(args: string[], scope: ScopeRoot): Promise { + const { positionals, values } = parseArgs({ + args, + allowPositionals: true, + options: { + command: { type: "string" }, + args: { type: "string", multiple: true }, + url: { type: "string" }, + header: { type: "string", multiple: true }, + env: { type: "string", multiple: true }, + }, + strict: true, + }); + + const name = positionals[0]; + if (!name) { + console.error( + chalk.red("Usage: dotagents mcp add --command [--args ...] [--env ...]"), + ); + console.error( + chalk.red(" dotagents mcp add --url [--header ...] [--env ...]"), + ); + process.exitCode = 1; + return; + } + + await runMcpAdd({ + scope, + name, + command: values["command"], + args: values["args"], + url: values["url"], + headers: values["header"], + env: values["env"], + }); + console.log(chalk.green(`Added MCP server: ${name}`)); +} + +async function mcpRemove(args: string[], scope: ScopeRoot): Promise { + const { positionals } = parseArgs({ + args, + allowPositionals: true, + strict: true, + }); + + const name = positionals[0]; + if (!name) { + console.error(chalk.red("Usage: dotagents mcp remove ")); + process.exitCode = 1; + return; + } + + await runMcpRemove({ scope, name }); + console.log(chalk.green(`Removed MCP server: ${name}`)); +} + +async function mcpList(args: string[], scope: ScopeRoot): Promise { + const { values } = parseArgs({ + args, + options: { + json: { type: "boolean" }, + }, + strict: true, + }); + + const config = await loadConfig(scope.configPath); + const entries = getMcpList(config); + + if (entries.length === 0) { + console.log(chalk.dim("No MCP servers declared in agents.toml.")); + return; + } + + if (values["json"]) { + console.log(JSON.stringify(entries, null, 2)); + return; + } + + console.log(chalk.bold("MCP servers:")); + for (const e of entries) { + const env = e.env.length > 0 ? chalk.dim(` env=[${e.env.join(",")}]`) : ""; + console.log(` ${e.name} ${chalk.dim(e.transport)} ${chalk.dim(e.target)}${env}`); + } +} + +function printMcpUsage(): void { + console.error(`Usage: dotagents mcp + +Subcommands: + add Add an MCP server declaration + remove Remove an MCP server declaration + list Show declared MCP servers`); +} + +export default async function mcp(args: string[], flags?: { user?: boolean }): Promise { + const sub = args[0]; + + if (!sub || sub === "--help" || sub === "-h") { + printMcpUsage(); + return; + } + + let scope: ScopeRoot; + try { + scope = flags?.user ? resolveScope("user") : resolveDefaultScope(resolve(".")); + } catch (err) { + if (err instanceof ScopeError) { + console.error(chalk.red(err.message)); + process.exitCode = 1; + return; + } + throw err; + } + + const subArgs = args.slice(1); + + try { + switch (sub) { + case "add": + await mcpAdd(subArgs, scope); + break; + case "remove": + await mcpRemove(subArgs, scope); + break; + case "list": + await mcpList(subArgs, scope); + break; + default: + console.error(chalk.red(`Unknown mcp subcommand: ${sub}`)); + printMcpUsage(); + process.exitCode = 1; + } + } catch (err) { + if (err instanceof ScopeError || err instanceof McpError) { + console.error(chalk.red(err.message)); + process.exitCode = 1; + return; + } + throw err; + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 2f56ea8..c9580b0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -6,7 +6,7 @@ const require = createRequire(import.meta.url); const { version } = require("../../package.json") as { version: string }; export { version }; -const COMMANDS = ["init", "install", "add", "remove", "update", "sync", "list"] as const; +const COMMANDS = ["init", "install", "add", "remove", "update", "sync", "list", "mcp"] as const; type Command = (typeof COMMANDS)[number]; function printUsage(): void { @@ -23,6 +23,7 @@ Commands: update Update skills to latest versions sync Reconcile gitignore, symlinks, verify state list Show installed skills + mcp Manage MCP server declarations Options: --user Operate on user-scope (~/.agents/) instead of project diff --git a/src/config/writer.test.ts b/src/config/writer.test.ts index 78e0549..d266fe8 100644 --- a/src/config/writer.test.ts +++ b/src/config/writer.test.ts @@ -7,6 +7,8 @@ import { addWildcardToConfig, addExcludeToWildcard, removeSkillFromConfig, + addMcpToConfig, + removeMcpFromConfig, generateDefaultConfig, } from "./writer.js"; import { loadConfig } from "./loader.js"; @@ -281,4 +283,110 @@ describe("writer", () => { expect(isWildcardDep(anthropics!) && anthropics!.exclude).toEqual([]); }); }); + + describe("addMcpToConfig", () => { + it("adds a stdio server", async () => { + await addMcpToConfig(configPath, { + name: "github", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + env: ["GITHUB_TOKEN"], + }); + + const config = await loadConfig(configPath); + expect(config.mcp).toHaveLength(1); + const mcp = config.mcp[0]!; + expect(mcp.name).toBe("github"); + expect(mcp.command).toBe("npx"); + expect(mcp.args).toEqual(["-y", "@modelcontextprotocol/server-github"]); + expect(mcp.env).toEqual(["GITHUB_TOKEN"]); + }); + + it("adds an http server with headers", async () => { + await addMcpToConfig(configPath, { + name: "remote", + url: "https://mcp.example.com/sse", + headers: { Authorization: "Bearer tok" }, + env: [], + }); + + const config = await loadConfig(configPath); + expect(config.mcp).toHaveLength(1); + const mcp = config.mcp[0]!; + expect(mcp.name).toBe("remote"); + expect(mcp.url).toBe("https://mcp.example.com/sse"); + expect(mcp.headers).toEqual({ Authorization: "Bearer tok" }); + }); + + it("adds multiple servers", async () => { + await addMcpToConfig(configPath, { + name: "a", + command: "cmd-a", + env: [], + }); + await addMcpToConfig(configPath, { + name: "b", + url: "https://b.example.com", + env: [], + }); + + const config = await loadConfig(configPath); + expect(config.mcp).toHaveLength(2); + }); + }); + + describe("removeMcpFromConfig", () => { + it("removes an existing server", async () => { + await addMcpToConfig(configPath, { + name: "github", + command: "npx", + env: [], + }); + await removeMcpFromConfig(configPath, "github"); + + const config = await loadConfig(configPath); + expect(config.mcp).toHaveLength(0); + }); + + it("preserves other servers when removing one", async () => { + await addMcpToConfig(configPath, { + name: "a", + command: "cmd-a", + env: [], + }); + await addMcpToConfig(configPath, { + name: "b", + command: "cmd-b", + env: [], + }); + await removeMcpFromConfig(configPath, "a"); + + const config = await loadConfig(configPath); + expect(config.mcp).toHaveLength(1); + expect(config.mcp[0]!.name).toBe("b"); + }); + + it("is a no-op for non-existent server", async () => { + const before = await readFile(configPath, "utf-8"); + await removeMcpFromConfig(configPath, "nope"); + const after = await readFile(configPath, "utf-8"); + expect(after).toBe(before); + }); + + it("does not affect skills with same name", async () => { + await addSkillToConfig(configPath, "github", { + source: "org/github-skill", + }); + await addMcpToConfig(configPath, { + name: "github", + command: "npx", + env: [], + }); + await removeMcpFromConfig(configPath, "github"); + + const config = await loadConfig(configPath); + expect(config.mcp).toHaveLength(0); + expect(config.skills.find((s) => s.name === "github")).toBeDefined(); + }); + }); }); diff --git a/src/config/writer.ts b/src/config/writer.ts index 30ae469..281af29 100644 --- a/src/config/writer.ts +++ b/src/config/writer.ts @@ -1,6 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { stringify } from "smol-toml"; -import type { WildcardSkillDependency, TrustConfig } from "./schema.js"; +import type { WildcardSkillDependency, TrustConfig, McpConfig } from "./schema.js"; export interface DefaultConfigOptions { agents?: string[]; @@ -40,7 +40,7 @@ export async function removeSkillFromConfig( name: string, ): Promise { const content = await readFile(filePath, "utf-8"); - await writeFile(filePath, removeBlockByName(content, name), "utf-8"); + await writeFile(filePath, removeBlockByHeader(content, "[[skills]]", name), "utf-8"); } /** @@ -128,13 +128,52 @@ export async function addExcludeToWildcard( await writeFile(filePath, result.join("\n"), "utf-8"); } -function removeBlockByName(content: string, name: string): string { +/** + * Add an MCP server entry to agents.toml. + * Appends a [[mcp]] block at the end of the file. + */ +export async function addMcpToConfig( + filePath: string, + entry: McpConfig, +): Promise { + const content = await readFile(filePath, "utf-8"); + + const obj: Record = { name: entry.name }; + if (entry.command) { + obj["command"] = entry.command; + if (entry.args && entry.args.length > 0) obj["args"] = entry.args; + } + if (entry.url) { + obj["url"] = entry.url; + if (entry.headers && Object.keys(entry.headers).length > 0) obj["headers"] = entry.headers; + } + if (entry.env.length > 0) obj["env"] = entry.env; + + const section = stringify({ mcp: [obj] }); + + const newContent = content.trimEnd() + "\n\n" + section.trimEnd() + "\n"; + await writeFile(filePath, newContent, "utf-8"); +} + +/** + * Remove an MCP server entry from agents.toml. + * Removes the [[mcp]] block whose name field matches. + */ +export async function removeMcpFromConfig( + filePath: string, + name: string, +): Promise { + const content = await readFile(filePath, "utf-8"); + await writeFile(filePath, removeBlockByHeader(content, "[[mcp]]", name), "utf-8"); +} + +function removeBlockByHeader(content: string, header: string, name: string): string { const lines = content.split("\n"); const result: string[] = []; let i = 0; while (i < lines.length) { - if (lines[i]!.trim() === "[[skills]]") { + if (lines[i]!.trim() === header) { // Collect the entire block (header + key-value lines) const blockLines = [lines[i]!]; i++;