Skip to content
Open
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
20 changes: 19 additions & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ bun run phantom token list
bun run phantom token revoke --client claude-code
```

Running Phantom reloads token changes from `config/mcp.yaml` automatically, so new tokens do not require a restart.

Three scopes:

| Scope | Permissions |
Expand All @@ -35,6 +37,20 @@ Three scopes:
| `operator` | Everything in read + ask questions, create tasks |
| `admin` | Everything in operator + register/unregister dynamic tools |

## Validating a Token

MCP uses a session-based streamable HTTP transport. A bare authenticated `curl` to `/mcp` is not enough; start with an `initialize` request:

```bash
curl -i -X POST https://your-phantom/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
```

The response includes an `Mcp-Session-Id` header. Use that header on follow-up `tools/list`, `resources/read`, and `tools/call` requests.

## Universal Tools

Available on every Phantom regardless of role:
Expand Down Expand Up @@ -78,9 +94,11 @@ Additional tools when running with the `swe` role:
The agent can register new tools at runtime. When a Phantom builds something (a database, a pipeline, a dashboard), it registers MCP tools so external clients can use it:

```bash
# List dynamically registered tools
# List dynamically registered tools after initialize
curl -X POST https://your-phantom/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Mcp-Session-Id: <session-id>" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
Expand Down
1 change: 1 addition & 0 deletions src/cli/__tests__/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ describe("phantom init", () => {
expect(logs.some((l) => l.includes("Admin:"))).toBe(true);
expect(logs.some((l) => l.includes("Operator:"))).toBe(true);
expect(logs.some((l) => l.includes("Read:"))).toBe(true);
expect(logs.some((l) => l.includes("docs/getting-started.md"))).toBe(true);
});

test("refuses to reinitialize if config exists", async () => {
Expand Down
2 changes: 2 additions & 0 deletions src/cli/__tests__/token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ describe("phantom token", () => {

expect(logs.some((l) => l.includes("Token created for 'claude-code'"))).toBe(true);
expect(logs.some((l) => l.includes("Token (save this"))).toBe(true);
expect(logs.some((l) => l.includes('"method":"initialize"'))).toBe(true);
expect(logs.some((l) => l.includes("Mcp-Session-Id"))).toBe(true);

// Verify the config file was updated
const raw = readFileSync(`${TEST_DIR}/config/mcp.yaml`, "utf-8");
Expand Down
5 changes: 3 additions & 2 deletions src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ export async function runInit(args: string[]): Promise<void> {
console.log(" 1. Set ANTHROPIC_API_KEY in your environment");
console.log(" 2. Start Docker services: docker compose up -d");
console.log(" 3. Start Phantom: phantom start");
console.log(" 4. Connect from Claude Code:");
console.log(` claude mcp add phantom -- curl -H "Authorization: Bearer ${mcp.adminToken}" https://your-host/mcp`);
console.log(" 4. Add the streamableHttp MCP config from docs/getting-started.md:");
console.log(" URL: https://your-host/mcp");
console.log(` Header: Authorization: Bearer ${mcp.operatorToken}`);
}
23 changes: 22 additions & 1 deletion src/cli/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,27 @@ function createToken(): { token: string; hash: string } {
return { token, hash };
}

function printInitializeExample(token: string): void {
const requestBody = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "curl", version: "1.0" },
},
});

console.log("\nUse with an MCP client or initialize with:");
console.log(" curl -X POST https://your-phantom/mcp \\");
console.log(` -H "Authorization: Bearer ${token}" \\`);
console.log(' -H "Accept: application/json, text/event-stream" \\');
console.log(' -H "Content-Type: application/json" \\');
console.log(` -d '${requestBody}'`);
console.log("\nThe response will include an Mcp-Session-Id header for follow-up MCP requests.");
}

function runCreate(args: string[]): void {
const { values } = parseArgs({
args,
Expand Down Expand Up @@ -70,7 +91,7 @@ function runCreate(args: string[]): void {

console.log(`Token created for '${values.client}' with scope '${scopeStr}'`);
console.log(`\nToken (save this, it will not be shown again):\n ${token}`);
console.log(`\nUse with curl:\n curl -H "Authorization: Bearer ${token}" https://your-phantom/mcp`);
printInitializeExample(token);
}

function runList(): void {
Expand Down
62 changes: 48 additions & 14 deletions src/core/__tests__/trigger-auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import YAML from "yaml";
import { hashTokenSync } from "../../mcp/config.ts";
import type { McpConfig } from "../../mcp/types.ts";
Expand All @@ -13,17 +14,17 @@ describe("/trigger endpoint auth", () => {
const adminToken = "test-trigger-admin-token";
const readToken = "test-trigger-read-token";
const operatorToken = "test-trigger-operator-token";
const lateOperatorToken = "test-trigger-operator-late";

const mcpConfigPath = "config/mcp.yaml";
let originalMcpYaml: string | null = null;
let testDir: string;
let mcpConfigPath: string;
let server: ReturnType<typeof Bun.serve>;
let baseUrl: string;

beforeAll(() => {
// Back up the existing mcp.yaml so we can restore it after tests
if (existsSync(mcpConfigPath)) {
originalMcpYaml = readFileSync(mcpConfigPath, "utf-8");
}
testDir = join(import.meta.dir, "tmp-trigger-auth");
mcpConfigPath = join(testDir, "mcp.yaml");
if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });

// Write test tokens to mcp.yaml so loadMcpConfig picks them up
const mcpConfig: McpConfig = {
Expand All @@ -35,11 +36,10 @@ describe("/trigger endpoint auth", () => {
rate_limit: { requests_per_minute: 60, burst: 10 },
};

mkdirSync("config", { recursive: true });
mkdirSync(testDir, { recursive: true });
writeFileSync(mcpConfigPath, YAML.stringify(mcpConfig), "utf-8");

// Start server with a random port
server = startServer({ name: "test", port: 0, role: "base" } as never, Date.now());
server = startTestServer(mcpConfigPath);
baseUrl = `http://localhost:${server.port}`;

// Wire trigger deps with a mock runtime
Expand All @@ -56,10 +56,7 @@ describe("/trigger endpoint auth", () => {

afterAll(() => {
server?.stop(true);
// Restore the original mcp.yaml
if (originalMcpYaml !== null) {
writeFileSync(mcpConfigPath, originalMcpYaml, "utf-8");
}
if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
});

const triggerBody = JSON.stringify({ task: "hello" });
Expand Down Expand Up @@ -126,4 +123,41 @@ describe("/trigger endpoint auth", () => {
});
expect(res.status).toBe(200);
});

test("accepts token added after server startup", async () => {
const updatedConfig: McpConfig = {
tokens: [
{ name: "admin", hash: hashTokenSync(adminToken), scopes: ["read", "operator", "admin"] },
{ name: "reader", hash: hashTokenSync(readToken), scopes: ["read"] },
{ name: "operator", hash: hashTokenSync(operatorToken), scopes: ["read", "operator"] },
{ name: "late-operator", hash: hashTokenSync(lateOperatorToken), scopes: ["read", "operator"] },
],
rate_limit: { requests_per_minute: 60, burst: 10 },
};
writeFileSync(mcpConfigPath, YAML.stringify(updatedConfig), "utf-8");

const res = await fetch(`${baseUrl}/trigger`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${lateOperatorToken}`,
},
body: triggerBody,
});
expect(res.status).toBe(200);
});
});

function startTestServer(mcpConfigPath: string): ReturnType<typeof Bun.serve> {
let lastError: unknown = null;
for (let attempt = 0; attempt < 10; attempt++) {
const port = 40_000 + Math.floor(Math.random() * 20_000);
try {
return startServer({ name: "test", port, role: "base" } as never, Date.now(), mcpConfigPath);
} catch (err: unknown) {
lastError = err;
}
}

throw lastError instanceof Error ? lastError : new Error("Failed to start test server");
}
10 changes: 6 additions & 4 deletions src/core/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { AgentRuntime } from "../agent/runtime.ts";
import type { SlackChannel } from "../channels/slack.ts";
import type { PhantomConfig } from "../config/types.ts";
import { AuthMiddleware } from "../mcp/auth.ts";
import { loadMcpConfig } from "../mcp/config.ts";
import type { PhantomMcpServer } from "../mcp/server.ts";
import type { MemoryHealth } from "../memory/types.ts";
import { handleUiRequest } from "../ui/serve.ts";
Expand Down Expand Up @@ -71,9 +70,12 @@ export function setTriggerDeps(deps: TriggerDeps): void {

let triggerAuth: AuthMiddleware | null = null;

export function startServer(config: PhantomConfig, startedAt: number): ReturnType<typeof Bun.serve> {
const mcpConfig = loadMcpConfig();
triggerAuth = new AuthMiddleware(mcpConfig);
export function startServer(
config: PhantomConfig,
startedAt: number,
mcpConfigPath = "config/mcp.yaml",
): ReturnType<typeof Bun.serve> {
triggerAuth = new AuthMiddleware(mcpConfigPath);

const server = Bun.serve({
port: config.port,
Expand Down
42 changes: 42 additions & 0 deletions src/mcp/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import YAML from "yaml";
import { AuthMiddleware } from "../auth.ts";
import { hashTokenSync } from "../config.ts";
import type { McpConfig, McpScope } from "../types.ts";
Expand Down Expand Up @@ -108,4 +112,42 @@ describe("AuthMiddleware", () => {
const noAuth = { authenticated: false as const, error: "nope" };
expect(auth.hasScope(noAuth, "read")).toBe(false);
});

test("reloads file-backed tokens when config changes", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "phantom-auth-test-"));
const configPath = join(tempDir, "mcp.yaml");
const alphaToken = "alpha-token";
const betaToken = "beta-token";

const writeConfig = (tokens: McpConfig["tokens"]) => {
writeFileSync(
configPath,
YAML.stringify({ tokens, rate_limit: { requests_per_minute: 60, burst: 10 } }),
"utf-8",
);
};

try {
writeConfig([{ name: "alpha", hash: hashTokenSync(alphaToken), scopes: ["read"] }]);
const fileAuth = new AuthMiddleware(configPath);

const before = await fileAuth.authenticate(
new Request("http://localhost/mcp", { headers: { Authorization: `Bearer ${betaToken}` } }),
);
expect(before.authenticated).toBe(false);

writeConfig([{ name: "beta", hash: hashTokenSync(betaToken), scopes: ["read", "operator"] }]);

const after = await fileAuth.authenticate(
new Request("http://localhost/mcp", { headers: { Authorization: `Bearer ${betaToken}` } }),
);
expect(after.authenticated).toBe(true);
if (after.authenticated) {
expect(after.clientName).toBe("beta");
expect(after.scopes).toContain("operator");
}
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
30 changes: 24 additions & 6 deletions src/mcp/__tests__/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Database } from "bun:sqlite";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import YAML from "yaml";
import { runMigrations } from "../../db/migrate.ts";
import { hashTokenSync } from "../config.ts";
import { PhantomMcpServer } from "../server.ts";
Expand Down Expand Up @@ -92,18 +95,15 @@ describe("PhantomMcpServer", () => {
const adminToken = "test-admin-for-mcp-server";
const readToken = "test-read-for-mcp-server";
let tmpDir: string;
let configPath: string;

beforeAll(async () => {
const { mkdirSync, writeFileSync, existsSync } = await import("node:fs");
const { join } = await import("node:path");
const YAML = (await import("yaml")).default;

db = new Database(":memory:");
runMigrations(db);

tmpDir = join(import.meta.dir, "tmp-mcp-server-test");
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true });
const configPath = join(tmpDir, "mcp.yaml");
configPath = join(tmpDir, "mcp.yaml");

const mcpConfig = {
tokens: [
Expand Down Expand Up @@ -140,7 +140,6 @@ describe("PhantomMcpServer", () => {
afterAll(async () => {
await mcpServer.close();
db.close();
const { rmSync, existsSync } = await import("node:fs");
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true });
});

Expand All @@ -159,6 +158,25 @@ describe("PhantomMcpServer", () => {
expect(res.status).toBe(401);
});

test("accepts tokens added after server startup", async () => {
const operatorToken = "test-operator-added-later";
writeFileSync(
configPath,
YAML.stringify({
tokens: [
{ name: "admin", hash: hashTokenSync(adminToken), scopes: ["read", "operator", "admin"] },
{ name: "reader", hash: hashTokenSync(readToken), scopes: ["read"] },
{ name: "operator", hash: hashTokenSync(operatorToken), scopes: ["read", "operator"] },
],
rate_limit: { requests_per_minute: 60, burst: 10 },
}),
"utf-8",
);

const res = await mcpServer.handleRequest(mcpRequest(operatorToken, initBody("late-token")));
expect(res.status).toBe(200);
});

test("handles MCP initialize with valid token", async () => {
const res = await mcpServer.handleRequest(mcpRequest(adminToken, initBody("init-test")));
expect(res.status).toBe(200);
Expand Down
Loading
Loading