Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> --command <cmd> [--args <a>...] [--env <VAR>...]
dotagents mcp add <name> --url <url> [--header <Key:Value>...] [--env <VAR>...]
```

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 <cmd>` | Command to execute (stdio transport) |
| `--args <arg>` | Command argument (repeatable) |
| `--url <url>` | Server URL (HTTP transport) |
| `--header <Key:Value>` | HTTP header (repeatable, url servers only) |
| `--env <VAR>` | Environment variable name to pass through (repeatable) |

### mcp remove

```
dotagents mcp remove <name>
```

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

```
Expand Down
71 changes: 71 additions & 0 deletions docs/src/app/cli/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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."
/>

<CliCommand
name="mcp add"
synopsis="dotagents mcp add <name> --command <cmd> [--args <a>...] [--env <VAR>...]
dotagents mcp add <name> --url <url> [--header <Key:Value>...] [--env <VAR>...]"
description={
<>
Add an MCP server declaration to <code>agents.toml</code> and run{" "}
<code>install</code> to generate agent configs. Specify exactly one
transport: <code>--command</code> for stdio or <code>--url</code>{" "}
for HTTP.
</>
}
options={[
{
flag: "--command <cmd>",
description: "Command to execute (stdio transport)",
},
{
flag: "--args <arg>",
description: "Command argument (repeatable)",
},
{
flag: "--url <url>",
description: "Server URL (HTTP transport)",
},
{
flag: "--header <Key:Value>",
description: "HTTP header (repeatable, url servers only)",
},
{
flag: "--env <VAR>",
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",
]}
/>

<CliCommand
name="mcp remove"
synopsis="dotagents mcp remove <name>"
description={
<>
Remove an MCP server declaration from <code>agents.toml</code> and
run <code>install</code> to regenerate agent configs.
</>
}
examples={["dotagents mcp remove github"]}
/>

<CliCommand
name="mcp list"
synopsis="dotagents mcp list [--json]"
description={
<>
Show declared MCP servers. Use <code>--json</code> for
machine-readable output.
</>
}
examples={[
"dotagents mcp list",
"dotagents mcp list --json",
]}
/>

<CliCommand
name="list"
synopsis="dotagents list [--json]"
Expand Down
188 changes: 188 additions & 0 deletions src/cli/commands/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { runMcpAdd, runMcpRemove, getMcpList, McpError, validateMcpName, parseHeader } from "./mcp.js";
import { loadConfig } from "../../config/loader.js";
import type { ScopeRoot } from "../../scope.js";

describe("mcp", () => {
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: [],
});
});
});
});
Loading