diff --git a/README.md b/README.md index 406a51d..667a874 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Copy `.env.example` to a local `.env` file and fill only local values. Do not co See [docs/LOCAL_SETUP.md](docs/LOCAL_SETUP.md) for the current local setup notes and [docs/SELF_HOSTING.md](docs/SELF_HOSTING.md) for self-hosting guidance. +To use the Revit bridge from Codex, Claude Code, or another MCP-compatible agent without the Operator pane, see [docs/EXTERNAL_AGENT_HARNESS.md](docs/EXTERNAL_AGENT_HARNESS.md). The public helper generates host configuration, diagnoses the live bridge, and can issue deliberately bounded pane-free write grants. + ## Validation From the repository root, run the backend build and test suite with: diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json index 612b544..d923e56 100644 --- a/apps/mcp-server/package.json +++ b/apps/mcp-server/package.json @@ -12,6 +12,7 @@ "start": "node dist/server.js", "dev": "ts-node src/server.ts", "test": "npm run build && node --test dist/**/*.test.js", + "external-agent": "node dist/cli/externalAgent.js", "acceptance:mep": "ts-node src/scripts/run_mep_continuity_acceptance.ts" }, "dependencies": { diff --git a/apps/mcp-server/src/cli/externalAgent.ts b/apps/mcp-server/src/cli/externalAgent.ts new file mode 100644 index 0000000..72a0208 --- /dev/null +++ b/apps/mcp-server/src/cli/externalAgent.ts @@ -0,0 +1,224 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { renderClaudeAddCommand, renderClaudeMcpConfig, renderCodexMcpConfig } from "../lib/externalAgentConfig.js"; +import { createExternalWriteGrant, type ExternalWriteGrantMode, writeExternalWriteGrant } from "../lib/externalWriteGrant.js"; +import { getOperatorToken, getWorkspaceRoot } from "../lib/workspace.js"; + +type ParsedArgs = Record & { _: string[] }; + +function parseArgs(argv: string[]): ParsedArgs { + const out: ParsedArgs = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const item = argv[i] ?? ""; + if (!item.startsWith("--")) { + out._.push(item); + continue; + } + const key = item.slice(2); + const next = argv[i + 1]; + if (next && !next.startsWith("--")) { + out[key] = next; + i += 1; + } else { + out[key] = true; + } + } + return out; +} + +function serverEntryPath(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "server.js"); +} + +function configOptions() { + return { serverEntryPath: serverEntryPath(), workspaceRoot: getWorkspaceRoot() }; +} + +function serverWorkingDirectory(entry: string): string { + const entryDirectory = path.dirname(entry); + return path.basename(entryDirectory).toLowerCase() === "dist" + ? path.dirname(entryDirectory) + : entryDirectory; +} + +function inheritedEnvironment(): Record { + return Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string") + ); +} + +function resultText(result: unknown): string { + if (!result || typeof result !== "object" || !("content" in result) || !Array.isArray((result as any).content)) return ""; + return (result as any).content.map((item: any) => typeof item?.text === "string" ? item.text : "").filter(Boolean).join("\n"); +} + +async function smoke(): Promise { + const entry = serverEntryPath(); + if (!fs.existsSync(entry)) throw new Error(`MCP server is not built: ${entry}`); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entry], + cwd: serverWorkingDirectory(entry), + env: { + ...inheritedEnvironment(), + OPERATOR_WORKSPACE_ROOT: getWorkspaceRoot() + }, + stderr: "pipe" + }); + const stderr: string[] = []; + transport.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk.toString("utf8"))); + const client = new Client({ name: "revit-operator-external-agent-smoke", version: "1.0.0" }, { capabilities: {} }); + try { + await client.connect(transport); + const tools = await client.listTools(); + const ping = await client.callTool({ name: "revit_ping", arguments: {} }); + const search = await client.callTool({ + name: "revit_search_tools", + arguments: { query: "current Revit context", method: "GET", limit: 5 } + }); + const context = await client.callTool({ + name: "revit_call_tool", + arguments: { method: "GET", path: "/revit/context", requireKnownPath: true } + }); + const writeGrantStatus = await client.callTool({ name: "revit_write_grant_status", arguments: {} }); + const report = { + ready: true, + transport: "stdio", + childProcess: entry, + toolCount: tools.tools.length, + requiredToolsPresent: ["revit_ping", "revit_search_tools", "revit_call_tool", "revit_write_grant_status"] + .every(name => tools.tools.some(tool => tool.name === name)), + bridgePing: resultText(ping), + registrySearch: resultText(search), + revitContext: resultText(context), + writeGrantStatus: resultText(writeGrantStatus) + }; + process.stdout.write(JSON.stringify(report, null, 2) + "\n"); + if (!report.requiredToolsPresent) process.exitCode = 1; + } catch (error) { + const details = stderr.join("").trim(); + throw new Error(`${String(error)}${details ? `\nMCP child stderr:\n${details}` : ""}`); + } finally { + try { + await client.close(); + } catch { + await transport.close(); + } + } +} + +async function doctor(): Promise { + const entry = serverEntryPath(); + const workspaceRoot = getWorkspaceRoot(); + const operatorToken = getOperatorToken(); + const bridgeUrl = (process.env.REVIT_BRIDGE_URL || "http://localhost:5000").replace(/\/+$/, ""); + let ping: unknown = null; + let writeGrantStatus: unknown = null; + let bridgeError = ""; + if (operatorToken) { + try { + const headers = { "X-Operator-Token": operatorToken }; + const pingResponse = await fetch(`${bridgeUrl}/revit/ping`, { headers }); + if (!pingResponse.ok) { + const details = (await pingResponse.text()).trim(); + throw new Error(`ping returned HTTP ${pingResponse.status}${details ? `: ${details}` : ""}`); + } + ping = await pingResponse.json(); + const grantResponse = await fetch(`${bridgeUrl}/revit/write-grant-status`, { headers }); + if (grantResponse.ok) writeGrantStatus = await grantResponse.json(); + } catch (error) { + bridgeError = String(error); + } + } + const report = { + ready: fs.existsSync(entry) && !!operatorToken && !!ping && !bridgeError, + node: process.version, + mcpServerEntry: entry, + mcpServerBuilt: fs.existsSync(entry), + workspaceRoot, + operatorTokenPresent: !!operatorToken, + bridgeUrl, + bridgePing: ping, + writeGrantStatus, + bridgeError: bridgeError || null + }; + process.stdout.write(JSON.stringify(report, null, 2) + "\n"); + if (!report.ready) process.exitCode = 1; +} + +function printHelp(): void { + process.stdout.write([ + "Revit Operator external-agent helper", + "", + " config --host codex|claude|all [--claude-scope local|project|user]", + " doctor", + " smoke", + " grant --mode once|session --acknowledge-writes [--ttl-minutes N]", + "", + "The grant command intentionally does not expose YOLO mode." + ].join("\n") + "\n"); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const command = args._[0] ?? "help"; + if (command === "help" || command === "--help" || command === "-h") { + printHelp(); + return; + } + if (command === "doctor") { + await doctor(); + return; + } + if (command === "smoke") { + await smoke(); + return; + } + if (command === "config") { + const host = String(args.host ?? "all").toLowerCase(); + const options = configOptions(); + if (host === "codex" || host === "all") { + process.stdout.write("# Codex config.toml\n" + renderCodexMcpConfig(options)); + } + if (host === "claude" || host === "all") { + const scope = String(args["claude-scope"] ?? "local") as "local" | "project" | "user"; + if (!(["local", "project", "user"] as string[]).includes(scope)) throw new Error("--claude-scope must be local, project, or user."); + process.stdout.write("# Claude Code CLI (PowerShell)\n" + renderClaudeAddCommand(options, scope) + "\n\n"); + process.stdout.write("# Claude Code .mcp.json\n" + renderClaudeMcpConfig(options)); + } + if (!(["codex", "claude", "all"] as string[]).includes(host)) throw new Error("--host must be codex, claude, or all."); + return; + } + if (command === "grant") { + if (args["acknowledge-writes"] !== true) { + throw new Error("Refusing to issue a write grant without --acknowledge-writes."); + } + const mode = String(args.mode ?? "once").toLowerCase() as ExternalWriteGrantMode; + const ttlRaw = args["ttl-minutes"]; + const ttlMinutes = typeof ttlRaw === "string" ? Number(ttlRaw) : undefined; + const grant = createExternalWriteGrant({ + operatorToken: getOperatorToken(), + mode, + ttlMinutes + }); + const grantPath = writeExternalWriteGrant(getWorkspaceRoot(), grant); + process.stdout.write(JSON.stringify({ + issued: true, + mode: grant.mode, + expires_at_utc: grant.expires_at_utc, + uses_remaining: grant.uses_remaining, + grant_path: grantPath, + note: "The grant token is intentionally omitted from output." + }, null, 2) + "\n"); + return; + } + throw new Error(`Unknown command: ${command}`); +} + +main().catch(error => { + process.stderr.write(`External-agent helper failed: ${String(error)}\n`); + process.exitCode = 1; +}); diff --git a/apps/mcp-server/src/lib/externalAgentConfig.test.ts b/apps/mcp-server/src/lib/externalAgentConfig.test.ts new file mode 100644 index 0000000..fea59b4 --- /dev/null +++ b/apps/mcp-server/src/lib/externalAgentConfig.test.ts @@ -0,0 +1,34 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { renderClaudeAddCommand, renderClaudeMcpConfig, renderCodexMcpConfig } from "./externalAgentConfig.js"; + +const options = { + serverEntryPath: path.resolve("apps/mcp-server/dist/server.js"), + workspaceRoot: path.resolve("tmp/operator workspace") +}; + +test("renders one canonical Codex stdio MCP server", () => { + const config = renderCodexMcpConfig(options); + assert.match(config, /\[mcp_servers\.revit_operator\]/); + assert.equal((config.match(/\[mcp_servers\./g) ?? []).length, 1); + assert.match(config, /command = "node"/); + assert.match(config, /OPERATOR_WORKSPACE_ROOT/); + assert.match(config, new RegExp(`cwd = ${JSON.stringify(path.dirname(path.dirname(options.serverEntryPath))).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); + assert.doesNotMatch(config, /cwd = .*dist/); + assert.match(config, /tool_timeout_sec = 240/); + assert.match(config, /required = true/); +}); + +test("renders Claude project JSON and a correctly separated stdio CLI command", () => { + const parsed = JSON.parse(renderClaudeMcpConfig(options)); + const server = parsed.mcpServers["revit-operator"]; + assert.equal(server.type, "stdio"); + assert.equal(server.command, "node"); + assert.deepEqual(server.args, [options.serverEntryPath]); + assert.equal(server.env.OPERATOR_WORKSPACE_ROOT, options.workspaceRoot); + + const command = renderClaudeAddCommand(options, "user"); + assert.match(command, /^claude mcp add --env /); + assert.match(command, /--transport stdio --scope user revit-operator -- node /); +}); diff --git a/apps/mcp-server/src/lib/externalAgentConfig.ts b/apps/mcp-server/src/lib/externalAgentConfig.ts new file mode 100644 index 0000000..ec3fac1 --- /dev/null +++ b/apps/mcp-server/src/lib/externalAgentConfig.ts @@ -0,0 +1,69 @@ +import path from "node:path"; + +export type ExternalAgentConfigOptions = { + serverEntryPath: string; + workspaceRoot: string; +}; + +function tomlString(value: string): string { + return JSON.stringify(path.normalize(value)); +} + +function serverWorkingDirectory(serverEntryPath: string): string { + const entryDirectory = path.dirname(serverEntryPath); + return path.basename(entryDirectory).toLowerCase() === "dist" + ? path.dirname(entryDirectory) + : entryDirectory; +} + +export function renderCodexMcpConfig(options: ExternalAgentConfigOptions): string { + const serverEntryPath = path.resolve(options.serverEntryPath); + const workspaceRoot = path.resolve(options.workspaceRoot); + const serverCwd = serverWorkingDirectory(serverEntryPath); + return [ + "[mcp_servers.revit_operator]", + 'command = "node"', + `args = [${tomlString(serverEntryPath)}]`, + `cwd = ${tomlString(serverCwd)}`, + `env = { OPERATOR_WORKSPACE_ROOT = ${tomlString(workspaceRoot)} }`, + "startup_timeout_sec = 20", + "tool_timeout_sec = 240", + "required = true", + "" + ].join("\n"); +} + +export function renderClaudeMcpConfig(options: ExternalAgentConfigOptions): string { + const serverEntryPath = path.resolve(options.serverEntryPath); + const workspaceRoot = path.resolve(options.workspaceRoot); + return JSON.stringify({ + mcpServers: { + "revit-operator": { + type: "stdio", + command: "node", + args: [serverEntryPath], + env: { + OPERATOR_WORKSPACE_ROOT: workspaceRoot + } + } + } + }, null, 2) + "\n"; +} + +export function renderClaudeAddCommand(options: ExternalAgentConfigOptions, scope: "local" | "project" | "user" = "local"): string { + const serverEntryPath = path.resolve(options.serverEntryPath); + const workspaceRoot = path.resolve(options.workspaceRoot); + return [ + "claude mcp add", + `--env ${quotePowerShellArg(`OPERATOR_WORKSPACE_ROOT=${workspaceRoot}`)}`, + "--transport stdio", + `--scope ${scope}`, + "revit-operator", + "-- node", + quotePowerShellArg(serverEntryPath) + ].join(" "); +} + +function quotePowerShellArg(value: string): string { + return `'${String(value).replace(/'/g, "''")}'`; +} diff --git a/apps/mcp-server/src/lib/externalWriteGrant.test.ts b/apps/mcp-server/src/lib/externalWriteGrant.test.ts new file mode 100644 index 0000000..42c2f57 --- /dev/null +++ b/apps/mcp-server/src/lib/externalWriteGrant.test.ts @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createExternalWriteGrant, writeExternalWriteGrant } from "./externalWriteGrant.js"; + +test("creates a Revit-compatible short-lived once grant", () => { + const operatorToken = "operator-test-token"; + const grant = createExternalWriteGrant({ + operatorToken, + mode: "once", + ttlMinutes: 10, + now: new Date("2026-07-13T16:00:00.000Z"), + grantToken: "granttesttoken" + }); + const key = crypto.createHash("sha256").update(`write_grant|${operatorToken}`, "utf8").digest(); + const payload = "1|granttesttoken|once|2026-07-13T16:00:00.000Z|2026-07-13T16:10:00.000Z|1"; + const expected = crypto.createHmac("sha256", key).update(payload, "utf8").digest("base64"); + + assert.equal(grant.uses_remaining, 1); + assert.equal(grant.sig, expected); +}); + +test("caps pane-free grants and writes only the workspace grant file", () => { + assert.throws( + () => createExternalWriteGrant({ operatorToken: "token", mode: "session", ttlMinutes: 16 }), + /between 1 and 15/ + ); + assert.throws( + () => createExternalWriteGrant({ operatorToken: "token", mode: "yolo" as any }), + /only 'once' or 'session'/ + ); + + const root = fs.mkdtempSync(path.join(os.tmpdir(), "revit-operator-grant-")); + try { + const grant = createExternalWriteGrant({ operatorToken: "token", mode: "session", grantToken: "sessiontoken" }); + const grantPath = writeExternalWriteGrant(root, grant); + assert.equal(grantPath, path.join(root, "write_grant.json")); + assert.equal(JSON.parse(fs.readFileSync(grantPath, "utf8")).token, "sessiontoken"); + assert.deepEqual(fs.readdirSync(root), ["write_grant.json"]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/apps/mcp-server/src/lib/externalWriteGrant.ts b/apps/mcp-server/src/lib/externalWriteGrant.ts new file mode 100644 index 0000000..f460dfa --- /dev/null +++ b/apps/mcp-server/src/lib/externalWriteGrant.ts @@ -0,0 +1,61 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +export type ExternalWriteGrantMode = "once" | "session"; + +export type ExternalWriteGrantFile = { + version: 1; + token: string; + mode: ExternalWriteGrantMode; + issued_at_utc: string; + expires_at_utc: string; + uses_remaining: number | null; + sig: string; +}; + +export function createExternalWriteGrant(options: { + operatorToken: string; + mode: ExternalWriteGrantMode; + ttlMinutes?: number; + now?: Date; + grantToken?: string; +}): ExternalWriteGrantFile { + const operatorToken = options.operatorToken.trim(); + if (!operatorToken) throw new Error("An existing Operator token is required. Start Revit with the Revit Operator add-in first."); + const mode = options.mode; + if (mode !== "once" && mode !== "session") throw new Error("External write grants support only 'once' or 'session'."); + + const maxTtlMinutes = mode === "once" ? 10 : 15; + const ttlMinutes = options.ttlMinutes ?? maxTtlMinutes; + if (!Number.isFinite(ttlMinutes) || ttlMinutes < 1 || ttlMinutes > maxTtlMinutes) { + throw new Error(`${mode} grants require ttlMinutes between 1 and ${maxTtlMinutes}.`); + } + + const now = options.now ?? new Date(); + if (!Number.isFinite(now.getTime())) throw new Error("Grant issue time is invalid."); + const expires = new Date(now.getTime() + Math.round(ttlMinutes * 60_000)); + const grant: ExternalWriteGrantFile = { + version: 1, + token: (options.grantToken ?? crypto.randomUUID().replace(/-/g, "")).trim(), + mode, + issued_at_utc: now.toISOString(), + expires_at_utc: expires.toISOString(), + uses_remaining: mode === "once" ? 1 : null, + sig: "" + }; + if (!grant.token) throw new Error("Grant token is invalid."); + + const key = crypto.createHash("sha256").update(`write_grant|${operatorToken}`, "utf8").digest(); + const payload = `${grant.version}|${grant.token}|${grant.mode}|${grant.issued_at_utc}|${grant.expires_at_utc}|${grant.uses_remaining ?? ""}`; + grant.sig = crypto.createHmac("sha256", key).update(payload, "utf8").digest("base64"); + return grant; +} + +export function writeExternalWriteGrant(workspaceRoot: string, grant: ExternalWriteGrantFile): string { + const root = path.resolve(workspaceRoot); + fs.mkdirSync(root, { recursive: true }); + const grantPath = path.join(root, "write_grant.json"); + fs.writeFileSync(grantPath, JSON.stringify(grant, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); + return grantPath; +} diff --git a/apps/mcp-server/src/lib/mcp_stdio_smoke.test.ts b/apps/mcp-server/src/lib/mcp_stdio_smoke.test.ts index c2dcf3c..f9f2299 100644 --- a/apps/mcp-server/src/lib/mcp_stdio_smoke.test.ts +++ b/apps/mcp-server/src/lib/mcp_stdio_smoke.test.ts @@ -44,7 +44,54 @@ test("MCP stdio server registers repaired tools and rejects semantic write contr res.end("unexpected backend request"); }); const backendPort = await listen(backend); + const bridgeRequests: Array<{ method: string; path: string; token: string; grant: string }> = []; + const bridge = http.createServer((req, res) => { + const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1"); + const token = String(req.headers["x-operator-token"] ?? ""); + const grant = String(req.headers["x-operator-write-grant"] ?? ""); + bridgeRequests.push({ method: req.method ?? "", path: requestUrl.pathname, token, grant }); + res.setHeader("Content-Type", "application/json"); + if (token !== "mcp-stdio-smoke-token") { + res.statusCode = 401; + res.end(JSON.stringify({ error: "bad token" })); + return; + } + if (requestUrl.pathname === "/revit/ping") { + res.end(JSON.stringify({ status: "ok", source: "stdio-smoke" })); + return; + } + if (requestUrl.pathname === "/revit/tool-registry") { + res.end(JSON.stringify({ + version: "operator.tool_registry.v1", + tools: [ + { method: "GET", path: "/revit/context", group: "Core", risk: "low", title: "Context", description: "Current context" }, + { method: "POST", path: "/revit/test-write", group: "Test", risk: "medium", title: "Test Write", description: "Smoke write" } + ] + })); + return; + } + if (requestUrl.pathname === "/revit/context") { + res.end(JSON.stringify({ document: "Snowdon", view: "L4 - Power" })); + return; + } + if (requestUrl.pathname === "/revit/test-write") { + if (grant !== "grant-token") { + res.statusCode = 403; + res.end(JSON.stringify({ error: "missing grant" })); + return; + } + res.end(JSON.stringify({ applied: true, source: "stdio-smoke" })); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ error: "not found" })); + }); + const bridgePort = await listen(bridge); const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "revit-operator-mcp-stdio-")); + fs.writeFileSync(path.join(workspace, "write_grant.json"), JSON.stringify({ + token: "grant-token", + expires_at_utc: new Date(Date.now() + 60_000).toISOString() + }), "utf8"); const env = Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string")); const transport = new StdioClientTransport({ command: process.execPath, @@ -53,6 +100,7 @@ test("MCP stdio server registers repaired tools and rejects semantic write contr env: { ...env, OPERATOR_API_BASE_URL: `http://127.0.0.1:${backendPort}`, + REVIT_BRIDGE_URL: `http://127.0.0.1:${bridgePort}`, OPERATOR_TOKEN: "mcp-stdio-smoke-token", OPERATOR_WORKSPACE_ROOT: workspace }, @@ -68,6 +116,7 @@ test("MCP stdio server registers repaired tools and rejects semantic write contr } finally { await withTimeout(transport.close(), "closing MCP child transport", 5_000); await closeServer(backend); + await closeServer(bridge); fs.rmSync(workspace, { recursive: true, force: true }); } }); @@ -90,6 +139,29 @@ test("MCP stdio server registers repaired tools and rejects semantic write contr assert.equal(names.has(name), true, `Missing MCP tool: ${name}`); } + const ping = await withTimeout(client.callTool({ name: "revit_ping", arguments: {} }), "calling Revit ping over stdio"); + assert.match((ping as any).content[0].text, /stdio-smoke/); + + const search = await withTimeout(client.callTool({ + name: "revit_search_tools", + arguments: { query: "context", method: "GET" } + }), "searching bridge tools over stdio"); + assert.match((search as any).content[0].text, /\/revit\/context/); + + const context = await withTimeout(client.callTool({ + name: "revit_call_tool", + arguments: { method: "GET", path: "/revit/context", requireKnownPath: true } + }), "calling a generic bridge read over stdio"); + assert.match((context as any).content[0].text, /L4 - Power/); + + const write = await withTimeout(client.callTool({ + name: "revit_call_tool", + arguments: { method: "POST", path: "/revit/test-write", body: { apply: true }, requireKnownPath: true } + }), "calling a grant-backed generic bridge write over stdio"); + assert.match((write as any).content[0].text, /"applied": true/); + assert.equal(bridgeRequests.every(request => request.token === "mcp-stdio-smoke-token"), true); + assert.equal(bridgeRequests.some(request => request.path === "/revit/test-write" && request.grant === "grant-token"), true); + for (const writeControl of ["apply", "write"] as const) { const result = await withTimeout(client.callTool({ name: "operator_plan_semantic_mep_route", diff --git a/apps/operator-backend/src/codex/config.ts b/apps/operator-backend/src/codex/config.ts index 8eda7d4..59e39f5 100644 --- a/apps/operator-backend/src/codex/config.ts +++ b/apps/operator-backend/src/codex/config.ts @@ -10,14 +10,19 @@ function ensureDir(p: string): void { } } +function resolveAppsRoot(candidate: string): string | null { + const direct = path.resolve(candidate); + if (fs.existsSync(path.join(direct, "operator-backend")) && fs.existsSync(path.join(direct, "mcp-server"))) return direct; + const nested = path.join(direct, "apps"); + if (fs.existsSync(path.join(nested, "operator-backend")) && fs.existsSync(path.join(nested, "mcp-server"))) return nested; + return null; +} + function findRepoRoot(startDir: string): string { let cur = path.resolve(startDir); for (let i = 0; i < 8; i++) { - const operatorBackend = path.join(cur, "operator-backend"); - const mcpServer = path.join(cur, "mcp-server"); - const prompts = path.join(cur, "prompts"); - const skills = path.join(cur, "skills"); - if (fs.existsSync(operatorBackend) && fs.existsSync(mcpServer) && (fs.existsSync(prompts) || fs.existsSync(skills))) return cur; + const appsRoot = resolveAppsRoot(cur); + if (appsRoot) return appsRoot; const parent = path.dirname(cur); if (!parent || parent === cur) break; cur = parent; @@ -34,6 +39,7 @@ function normalizePathForTomlArg(p: string): string { function renderMcpServerBlock(opts: { repoRoot: string; workspaceRoot: string; codexHome: string }): string { const serverJs = normalizePathForTomlArg(path.join(opts.repoRoot, "mcp-server", "dist", "server.js")); + const serverCwd = normalizePathForTomlArg(path.dirname(path.dirname(serverJs))); const workspaceRoot = normalizePathForTomlArg(opts.workspaceRoot); const codexHome = normalizePathForTomlArg(opts.codexHome); const envInline = `env = { OPERATOR_WORKSPACE_ROOT = ${JSON.stringify(workspaceRoot)}, CODEX_HOME = ${JSON.stringify(codexHome)} }`; @@ -42,15 +48,11 @@ function renderMcpServerBlock(opts: { repoRoot: string; workspaceRoot: string; c "[mcp_servers.revit_operator]", "command = \"node\"", `args = [${JSON.stringify(serverJs)}]`, + `cwd = ${JSON.stringify(serverCwd)}`, envInline, + "startup_timeout_sec = 20", // Codex defaults tool_timeout_sec=60. Revit exports + family reloads can exceed that. "tool_timeout_sec = 240", - // Keep a hyphenated alias as well; some prompts/models refer to this server name. - "[mcp_servers.\"revit-operator\"]", - "command = \"node\"", - `args = [${JSON.stringify(serverJs)}]`, - envInline, - "tool_timeout_sec = 240", "# Inherit environment (OPERATOR_TOKEN is read from the Workspace token file as well).", "# END RevitOperator (managed)", "" @@ -76,7 +78,8 @@ export function ensureCodexHomeConfig(opts: { codexHome: string; repoRoot?: stri const codexHome = path.resolve(opts.codexHome); ensureDir(codexHome); - const repoRoot = opts.repoRoot ? path.resolve(opts.repoRoot) : findRepoRoot(process.cwd()); + const requestedRoot = opts.repoRoot ? path.resolve(opts.repoRoot) : findRepoRoot(process.cwd()); + const repoRoot = resolveAppsRoot(requestedRoot) ?? requestedRoot; const workspaceRoot = path.resolve(codexHome, ".."); const configPath = path.join(codexHome, "config.toml"); diff --git a/apps/operator-backend/test/codex_config.test.ts b/apps/operator-backend/test/codex_config.test.ts index 64c6d87..74bb0f3 100644 --- a/apps/operator-backend/test/codex_config.test.ts +++ b/apps/operator-backend/test/codex_config.test.ts @@ -22,9 +22,11 @@ test("codex config writer upserts managed MCP block", () => { const configPath = path.join(codexHome, "config.toml"); const txt = fs.readFileSync(configPath, "utf8"); assert.match(txt, /\[mcp_servers\.revit_operator\]/); - assert.match(txt, /\[mcp_servers\."revit-operator"\]/); + assert.equal((txt.match(/\[mcp_servers\./g) || []).length, 1); assert.match(txt, /env = \{ OPERATOR_WORKSPACE_ROOT = /); assert.match(txt, /CODEX_HOME = /); + assert.match(txt, /startup_timeout_sec = 20/); + assert.doesNotMatch(txt, /cwd = .*dist/); assert.match(txt, /BEGIN RevitOperator/); assert.match(txt, /END RevitOperator/); if (process.platform === "win32") assert.match(txt, /mcp-server\\\\dist\\\\server\.js/); @@ -34,3 +36,17 @@ test("codex config writer upserts managed MCP block", () => { assert.equal(beginCount, 1); }); +test("codex config resolves the public apps layout from the repository root", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "revitoperator-public-codexcfg-")); + const publicRoot = path.join(tmp, "public"); + const codexHome = path.join(tmp, "codexhome"); + fs.mkdirSync(path.join(publicRoot, "apps", "operator-backend"), { recursive: true }); + fs.mkdirSync(path.join(publicRoot, "apps", "mcp-server", "dist"), { recursive: true }); + fs.writeFileSync(path.join(publicRoot, "apps", "mcp-server", "dist", "server.js"), "// stub", "utf8"); + + ensureCodexHomeConfig({ codexHome, repoRoot: publicRoot }); + const txt = fs.readFileSync(path.join(codexHome, "config.toml"), "utf8"); + if (process.platform === "win32") assert.match(txt, /apps\\\\mcp-server\\\\dist\\\\server\.js/); + else assert.match(txt, /apps\/mcp-server\/dist\/server\.js/); +}); + diff --git a/apps/revit-bridge-addin/RevitBridge/Server/RevitHttpServer.cs b/apps/revit-bridge-addin/RevitBridge/Server/RevitHttpServer.cs index 2bf42fe..4b5c4eb 100644 --- a/apps/revit-bridge-addin/RevitBridge/Server/RevitHttpServer.cs +++ b/apps/revit-bridge-addin/RevitBridge/Server/RevitHttpServer.cs @@ -420,7 +420,7 @@ private async Task ProcessRequest(HttpListenerContext ctx) { error = "Write requires approval (missing/invalid X-Operator-Write-Grant).", details = err, - hint = "In the Operator pane, set Writes -> 'Allow this session' (or 'YOLO'), then retry." + hint = "Approve writes in the Operator pane, or explicitly issue a short-lived pane-free grant with the public MCP external-agent helper, then retry." }); byte[] denied = Encoding.UTF8.GetBytes(responseText); diff --git a/docs/AGENT_VISIBILITY_AND_SKILLS.md b/docs/AGENT_VISIBILITY_AND_SKILLS.md index 9b36f38..975e15d 100644 --- a/docs/AGENT_VISIBILITY_AND_SKILLS.md +++ b/docs/AGENT_VISIBILITY_AND_SKILLS.md @@ -33,6 +33,8 @@ The public MCP server entrypoint is: - `apps/mcp-server/dist/server.js` +This stdio server is also the supported external-harness boundary for Codex, Claude Code, and other MCP hosts. It exposes live tool discovery plus generic and typed Revit calls without requiring the Operator pane. See `docs/EXTERNAL_AGENT_HARNESS.md` for configuration, diagnostics, and bounded write approval. + ## Quick checks ```powershell diff --git a/docs/EXTERNAL_AGENT_HARNESS.md b/docs/EXTERNAL_AGENT_HARNESS.md new file mode 100644 index 0000000..a887ac9 --- /dev/null +++ b/docs/EXTERNAL_AGENT_HARNESS.md @@ -0,0 +1,124 @@ +# External Agent Harness + +Revit Operator can expose its public Revit bridge and tool registry to Codex, Claude Code, or any other MCP-compatible local agent without opening or using the Operator pane. + +The external harness still requires: + +- Revit running with the Revit Operator add-in loaded; +- Node.js 20 or later; +- the public MCP server built locally; +- the same machine-local Revit Operator workspace used by the add-in. + +The Operator UI and its chat/agent loop are optional. The Revit add-in is not optional because it owns the in-process Revit API execution context. + +## Build and diagnose + +From the public repository root: + +```powershell +npm --prefix apps/mcp-server install +npm --prefix apps/mcp-server run build +npm --prefix apps/mcp-server run external-agent -- doctor +npm --prefix apps/mcp-server run external-agent -- smoke +``` + +`doctor` checks the built stdio entrypoint, shared workspace token, live bridge ping, and current write-grant state. It does not modify the model. + +`smoke` launches the built MCP server as a separate stdio child process, discovers its tools, and performs live read-only ping, registry-search, context, and write-grant-status calls. This verifies the same transport boundary an external agent host uses; it also does not modify the model. + +## Generate host configuration + +Print machine-correct Codex and Claude Code snippets with absolute paths: + +```powershell +npm --prefix apps/mcp-server run external-agent -- config --host all +``` + +Use `--host codex` or `--host claude` for one host. The helper prints configuration; it does not edit user-level agent files. + +### Codex + +Add the generated `[mcp_servers.revit_operator]` block to `~/.codex/config.toml` or a trusted project `.codex/config.toml`, then start a new Codex task. The generated block uses one canonical server registration, stdio transport, the built `dist/server.js`, the shared workspace, and a four-minute Revit tool timeout. + +Codex's current MCP configuration reference documents `command`, `args`, `cwd`, and `env` for stdio servers: . + +### Claude Code + +The helper prints both a PowerShell-safe `claude mcp add` command and a `.mcp.json` entry. For example, the generated command follows this shape: + +```powershell +claude mcp add --env 'OPERATOR_WORKSPACE_ROOT=C:\path\to\Workspace' --transport stdio --scope local revit-operator -- node 'C:\path\to\apps\mcp-server\dist\server.js' +``` + +Claude Code requires `--` before the stdio command and supports local, project, and user scopes: . + +## Use the tools + +The external agent can begin with: + +- `revit_ping` and `revit_get_context` for connection/context; +- `revit_search_tools` to search the live Revit tool registry; +- `revit_call_tool` to call any known `/revit/*` primitive by method and path; +- typed tools such as `operator_plan_semantic_mep_route` where a bounded semantic planner exists. + +Ask the host agent to search before guessing endpoint names. For a generic call, prefer `requireKnownPath: true` so a stale or invented path fails closed. + +## Pane-free write approval + +Read-only tools work without the Operator pane. Medium/high-risk bridge writes still require an explicit, short-lived write grant in addition to the shared Operator token. + +Issue one deliberate write approval from a terminal: + +```powershell +# One medium/high-risk request, valid for at most 10 minutes +npm --prefix apps/mcp-server run external-agent -- grant --mode once --acknowledge-writes + +# Multiple requests in a bounded work session, valid for at most 15 minutes +npm --prefix apps/mcp-server run external-agent -- grant --mode session --acknowledge-writes +``` + +The helper intentionally: + +- requires the explicit `--acknowledge-writes` flag; +- supports only `once` and `session` modes; +- caps grant lifetime at 10 or 15 minutes; +- does not expose YOLO mode; +- omits the secret grant token from terminal output. + +The add-in validates and, for `once`, consumes the same signed workspace grant used by the Operator pane. This is a local consent barrier against accidental writes, not a remote security boundary. Keep the bridge on loopback, protect the workspace, and do not share `operator_token.txt` or `write_grant.json`. + +## Architecture boundary + +```text +Codex / Claude Code / other MCP host + | + | stdio MCP + v +apps/mcp-server/dist/server.js + | + | authenticated loopback HTTP + v +Revit Operator add-in bridge in Revit + | + v +Revit API + active model +``` + +The Operator backend is not required for generic bridge discovery and calls. It is required for tools that intentionally route through backend semantic planners, such as the semantic MEP planner. Hosted/commercial services are not required for local use. + +## Verification + +The MCP test suite starts the built server as a real child stdio process and verifies: + +- tool discovery; +- authenticated bridge ping; +- registry search; +- generic read execution; +- grant-backed generic write execution; +- rejection of unsupported semantic write controls before backend execution. + +Run it with: + +```powershell +npm --prefix apps/mcp-server test +```