From af6207b4c106c05df0e0948268528e2998368933 Mon Sep 17 00:00:00 2001 From: coe0718 Date: Tue, 31 Mar 2026 17:45:07 -0400 Subject: [PATCH] Reload MCP tokens without restart --- docs/mcp.md | 20 +++++++- src/cli/__tests__/init.test.ts | 1 + src/cli/__tests__/token.test.ts | 2 + src/cli/init.ts | 5 +- src/cli/token.ts | 23 ++++++++- src/core/__tests__/trigger-auth.test.ts | 62 ++++++++++++++++++------ src/core/server.ts | 10 ++-- src/mcp/__tests__/auth.test.ts | 42 ++++++++++++++++ src/mcp/__tests__/server.test.ts | 30 +++++++++--- src/mcp/auth.ts | 64 +++++++++++++++++++++++-- src/mcp/server.ts | 2 +- 11 files changed, 227 insertions(+), 34 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 423afbd..cae52e6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 | @@ -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: @@ -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: " \ + -H "Accept: application/json, text/event-stream" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` diff --git a/src/cli/__tests__/init.test.ts b/src/cli/__tests__/init.test.ts index 83e9580..dee85aa 100644 --- a/src/cli/__tests__/init.test.ts +++ b/src/cli/__tests__/init.test.ts @@ -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 () => { diff --git a/src/cli/__tests__/token.test.ts b/src/cli/__tests__/token.test.ts index 37e686a..ba62e9f 100644 --- a/src/cli/__tests__/token.test.ts +++ b/src/cli/__tests__/token.test.ts @@ -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"); diff --git a/src/cli/init.ts b/src/cli/init.ts index db315a1..d49f505 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -305,6 +305,7 @@ export async function runInit(args: string[]): Promise { 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}`); } diff --git a/src/cli/token.ts b/src/cli/token.ts index 98d7112..2143ed5 100644 --- a/src/cli/token.ts +++ b/src/cli/token.ts @@ -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, @@ -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 { diff --git a/src/core/__tests__/trigger-auth.test.ts b/src/core/__tests__/trigger-auth.test.ts index 39f89ff..73b8ffe 100644 --- a/src/core/__tests__/trigger-auth.test.ts +++ b/src/core/__tests__/trigger-auth.test.ts @@ -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"; @@ -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; 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 = { @@ -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 @@ -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" }); @@ -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 { + 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"); +} diff --git a/src/core/server.ts b/src/core/server.ts index ebab6fb..96f1ceb 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -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"; @@ -71,9 +70,12 @@ export function setTriggerDeps(deps: TriggerDeps): void { let triggerAuth: AuthMiddleware | null = null; -export function startServer(config: PhantomConfig, startedAt: number): ReturnType { - const mcpConfig = loadMcpConfig(); - triggerAuth = new AuthMiddleware(mcpConfig); +export function startServer( + config: PhantomConfig, + startedAt: number, + mcpConfigPath = "config/mcp.yaml", +): ReturnType { + triggerAuth = new AuthMiddleware(mcpConfigPath); const server = Bun.serve({ port: config.port, diff --git a/src/mcp/__tests__/auth.test.ts b/src/mcp/__tests__/auth.test.ts index c902e04..cc94799 100644 --- a/src/mcp/__tests__/auth.test.ts +++ b/src/mcp/__tests__/auth.test.ts @@ -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"; @@ -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 }); + } + }); }); diff --git a/src/mcp/__tests__/server.test.ts b/src/mcp/__tests__/server.test.ts index f7f7d13..d4cba20 100644 --- a/src/mcp/__tests__/server.test.ts +++ b/src/mcp/__tests__/server.test.ts @@ -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"; @@ -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: [ @@ -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 }); }); @@ -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); diff --git a/src/mcp/auth.ts b/src/mcp/auth.ts index 85518a9..394a420 100644 --- a/src/mcp/auth.ts +++ b/src/mcp/auth.ts @@ -1,16 +1,34 @@ +import { statSync } from "node:fs"; +import { loadMcpConfig } from "./config.ts"; import type { AuthResult, McpConfig, McpScope } from "./types.ts"; +type TokenEntry = { + name: string; + scopes: McpScope[]; +}; + export class AuthMiddleware { - private tokenMap: Map; + private tokenMap: Map; + private configPath: string | null; + private lastConfigFingerprint: string | null; - constructor(config: McpConfig) { - this.tokenMap = new Map(); - for (const token of config.tokens) { - this.tokenMap.set(token.hash, { name: token.name, scopes: token.scopes }); + constructor(configOrPath: McpConfig | string = "config/mcp.yaml") { + if (typeof configOrPath === "string") { + const config = loadMcpConfig(configOrPath); + this.tokenMap = buildTokenMap(config); + this.configPath = configOrPath; + this.lastConfigFingerprint = getConfigFingerprint(configOrPath); + return; } + + this.tokenMap = buildTokenMap(configOrPath); + this.configPath = null; + this.lastConfigFingerprint = null; } async authenticate(req: Request): Promise { + this.reloadConfigIfNeeded(); + const authHeader = req.headers.get("Authorization"); if (!authHeader) { return { authenticated: false, error: "Missing Authorization header" }; @@ -52,6 +70,42 @@ export class AuthMiddleware { .join(""); return `sha256:${hex}`; } + + private reloadConfigIfNeeded(): void { + if (!this.configPath) return; + + const fingerprint = getConfigFingerprint(this.configPath); + if (!fingerprint || fingerprint === this.lastConfigFingerprint) { + return; + } + + try { + const config = loadMcpConfig(this.configPath); + this.tokenMap = buildTokenMap(config); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[mcp] Failed to reload auth config from ${this.configPath}: ${msg}`); + } finally { + this.lastConfigFingerprint = fingerprint; + } + } +} + +function buildTokenMap(config: McpConfig): Map { + const tokenMap = new Map(); + for (const token of config.tokens) { + tokenMap.set(token.hash, { name: token.name, scopes: token.scopes }); + } + return tokenMap; +} + +function getConfigFingerprint(path: string): string | null { + try { + const stats = statSync(path); + return `${stats.mtimeMs}:${stats.size}`; + } catch { + return null; + } } // Scope requirements for each tool/method diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 837ee8e..f76f464 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -55,7 +55,7 @@ export class PhantomMcpServer { // Run tasks migration deps.db.run(TASKS_MIGRATION); - this.auth = new AuthMiddleware(mcpConfig); + this.auth = new AuthMiddleware(mcpConfigPath ?? "config/mcp.yaml"); this.rateLimiter = new RateLimiter(mcpConfig.rate_limit); this.audit = new AuditLogger(deps.db); this.roleId = deps.roleId ?? deps.config.role;