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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions apps/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
224 changes: 224 additions & 0 deletions apps/mcp-server/src/cli/externalAgent.ts
Original file line number Diff line number Diff line change
@@ -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, string | boolean | string[]> & { _: 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<string, string> {
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<void> {
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<void> {
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<void> {
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;
});
34 changes: 34 additions & 0 deletions apps/mcp-server/src/lib/externalAgentConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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 /);
});
69 changes: 69 additions & 0 deletions apps/mcp-server/src/lib/externalAgentConfig.ts
Original file line number Diff line number Diff line change
@@ -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, "''")}'`;
}
46 changes: 46 additions & 0 deletions apps/mcp-server/src/lib/externalWriteGrant.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
Loading
Loading