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
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Copy to .env and fill in your paths.
# Local-development reference only. The published `npx openroad-mcp` package
# does not load this file. PATH and ORFS_FLOW_PATH are inherited/auto-detected
# at startup; set these only to override an unusual install prefix.

# Directory containing the openroad binary (find with: dirname $(which openroad))
# Directory containing the openroad binary (find with: dirname "$(command -v openroad)")
PATH=/path/to/openroad/bin:$PATH

# flow/ directory inside your ORFS checkout (only needed for report image tools)
Expand Down
49 changes: 22 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,27 +43,9 @@ To use this MCP server, you need the server runtime, plus the underlying OpenROA

For platform-specific Node.js and C++ toolchain setup instructions, see the **[Cross-Platform Build Guide](docs/CROSS_PLATFORM.md)**.

Before configuring your MCP client, you must provide the server with your local paths to OpenROAD and ORFS.
You do **not** need to clone this repo or pass path environment variables in the common case. The published `npx` package does not read a `.env` file.

### 1. Find Your Paths
Run these commands in your terminal to locate the necessary directories:

```bash
# Get your OpenROAD binary directory:
dirname $(which openroad)

# Check for the default ORFS flow directory:
ls ~/OpenROAD-flow-scripts/flow
# (If not found, search with: find ~ -maxdepth 4 -type d -name flow -path "*/OpenROAD-flow-scripts/*" 2>/dev/null)
```

### 2. Set Up Environment Variables
Copy the example environment file and fill in the paths you just found:

```bash
cp .env.example .env
```
Edit `.env` to ensure `PATH` includes your OpenROAD binary directory, and `ORFS_FLOW_PATH` points to your ORFS checkout. Pass these values into your MCP client's configuration via the `env` block.
On startup the server inherits the MCP client's environment, then fills `PATH` the same way `which openroad` would: current `PATH`, then your login-shell `PATH`, then common install locations (`/opt/homebrew/bin`, conda, local OpenROAD builds). `ORFS_FLOW_PATH` defaults to `~/OpenROAD-flow-scripts/flow`, and is also detected when ORFS sits next to the `openroad` binary.

## Supported MCP Clients

Expand All @@ -76,16 +58,27 @@ Here is the standard base configuration used across most clients:
}
```

Find your specific client below for the exact configuration snippet and file location.

*(Note: If your client supports environment variables in the config, inject your `PATH` and `ORFS_FLOW_PATH` directly in the snippet).*
Find your specific client below for the exact configuration snippet and file location.

<details><summary><b>Claude Code</b></summary>

```bash
claude mcp add --transport stdio openroad-mcp -- npx -y openroad-mcp
```
Or add the standard config to `.claude/settings.json`.

Or add the standard config to `.mcp.json` / `.claude/settings.json`.

If a GUI-launched client still cannot find `openroad`, pass an override. Use `command -v` so you do not hard-code paths:

```bash
claude mcp add \
--env PATH="$(dirname "$(command -v openroad)"):${PATH}" \
--env ORFS_FLOW_PATH="${HOME}/OpenROAD-flow-scripts/flow" \
--transport stdio openroad-mcp \
-- npx -y openroad-mcp
```

Put `--transport` between `--env` and the server name so the CLI does not treat the name as another `KEY=value` pair.
</details>

<details><summary><b>Claude Desktop</b></summary>
Expand Down Expand Up @@ -184,15 +177,17 @@ Once configured, your AI assistant will have access to the following tools. For
## Troubleshooting

- **The server fails to start**: Ensure you have Node.js 22+. Older versions will fail.
- **Session creation fails**: Confirm `openroad` is on your `PATH`. The server spawns it by name; if it's missing, session creation (not startup) will fail.
- **Session creation fails**: Confirm `command -v openroad` works in a terminal. The server inherits PATH and searches common install locations; if your prefix is unusual, pass `PATH` with `--env` as shown in the Claude Code section.
- **Commands rejected with CommandBlocked**: You sent a state-modifying command to `interactive_openroad_query`. Use `interactive_openroad_exec` instead.
- **Report images not found**: Make sure `ORFS_FLOW_PATH` points to your ORFS `flow/` directory.
- **Report images not found**: The server defaults to `~/OpenROAD-flow-scripts/flow`. If ORFS lives elsewhere, set `ORFS_FLOW_PATH` in the MCP client's `env` block (not a `.env` file).

To get more detail, set `LOG_LEVEL=DEBUG` in the server's environment.

## Development

Clone the repository and run:
Clone the repository. `.env.example` is a local-dev reference only; copy it to `.env` if you use direnv or similar. The server still reads `process.env` (the MCP client's `env` block), not the file.

Then run:
```bash
cd typescript
npm install
Expand Down
2 changes: 1 addition & 1 deletion docs/CROSS_PLATFORM.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ xcode-select --install

| Issue | Workaround |
|-------|------------|
| OpenROAD is not on `PATH` after Homebrew install | Add `/opt/homebrew/bin` to your shell's `PATH`, or set `OPENROAD_ALLOWED_COMMANDS=openroad` and use the full path in your session config. |
| OpenROAD is not on `PATH` after Homebrew install | The server prepends `/opt/homebrew/bin` automatically. If session creation still fails, pass `PATH="$(dirname "$(command -v openroad)"):${PATH}"` in the MCP client `env` block. |
| `node-pty` rebuild fails after a Node upgrade | `cd typescript && npm rebuild` |
| `sharp` fails with "dyld: Library not loaded" | `cd typescript && npm rebuild --update-binary` |

Expand Down
9 changes: 6 additions & 3 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,15 @@ All variables are read at startup by [`typescript/src/config/settings.ts`](../ty
| `OPENROAD_ALLOWED_COMMANDS` | string (comma-separated) | `openroad` | PTY spawn executable allowlist |
| `OPENROAD_ENABLE_COMMAND_VALIDATION` | bool | `true` | Enables/disables `PtyHandler.validateCommand` |
| `OPENROAD_WHITELIST_ENABLED` | bool | `true` | Enables/disables the Tcl command whitelist |
| `ORFS_FLOW_PATH` | path | `~/OpenROAD-flow-scripts/flow` | Root for ORFS reports; tilde-expanded at runtime |
| `ORFS_FLOW_PATH` | path | `~/OpenROAD-flow-scripts/flow` (auto-detected if unset) | Root for ORFS reports; tilde-expanded at runtime |
| `LOG_LEVEL` | string | `INFO` | Root pino logger level (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`) |
| `LOG_FORMAT` | string | (N/A) | Unused; logging uses pino with a fixed JSON format |

CLI flags `--verbose` and `--log-level` override `LOG_LEVEL` after the settings are initialised.
No other CLI flag overrides a Settings field.

`PATH` is not a Settings field. At startup the server inherits the client's `PATH`, then (only if `openroad` is not already found) merges the login-shell `PATH` and common install locations. An explicit `PATH` in the MCP client `env` block still wins when it already contains `openroad`.

---

## Report Image Path Containment
Expand Down Expand Up @@ -199,8 +201,9 @@ under the base directory. A symlink that points outside the base is caught here.

**Additional constraints:**

- Only `.webp` files are served — the listing skips all other extensions and `read_report_image`
rejects any `image_name` that does not end in `.webp`.
- Only report-image extensions are served — `.webp`, `.png`, and the doubled `.webp.png` form
some ORFS builds emit. The listing skips everything else and `read_report_image` rejects any
`image_name` that does not end in one of them.
- Symlinks are skipped during directory listing.
- On-disk file size is capped at 50 MB before any decoding.
- The base64-encoded payload is targeted at 15 KB; larger images are downscaled using sharp
Expand Down
2 changes: 1 addition & 1 deletion typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ The server reads configuration from environment variables. Key variables:
|---|---|---|
| `OPENROAD_MAX_SESSIONS` | `50` | Maximum concurrent sessions |
| `OPENROAD_COMMAND_TIMEOUT` | `30.0` | Default per-command timeout in seconds |
| `ORFS_FLOW_PATH` | `~/OpenROAD-flow-scripts/flow` | Path to ORFS flow directory |
| `ORFS_FLOW_PATH` | `~/OpenROAD-flow-scripts/flow` (auto-detected if unset) | Path to ORFS flow directory |
| `OPENROAD_WHITELIST_ENABLED` | `true` | Enable Tcl command whitelist |

Full list: [docs/SECURITY.md#environment-variable-reference](https://github.com/The-OpenROAD-Project/openroad-mcp/blob/main/docs/SECURITY.md#environment-variable-reference)
Expand Down
184 changes: 184 additions & 0 deletions typescript/__tests__/config/path_env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
mergePathDirs,
findExecutable,
parseLoginShellPath,
enrichInheritedEnv,
defaultExtraBinDirs,
defaultOrfsFlowCandidates,
type PathEnvDeps,
} from "../../src/config/path_env.js";

function makeExecutable(dir: string, name: string): string {
fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, name);
fs.writeFileSync(filePath, "#!/bin/sh\n");
fs.chmodSync(filePath, 0o755);
return filePath;
}

function makeOrfsFlow(root: string): string {
const flow = path.join(root, "flow");
fs.mkdirSync(path.join(flow, "designs"), { recursive: true });
fs.mkdirSync(path.join(flow, "platforms"), { recursive: true });
return flow;
}

describe("path_env", () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openroad-path-env-"));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

describe("mergePathDirs", () => {
it("deduplicates and skips empty segments while preserving first-seen order", () => {
expect(mergePathDirs(
["/a", "/b"].join(path.delimiter),
["/b", "/c"],
"",
` ${path.delimiter}/a`,
)).toBe(["/a", "/b", "/c"].join(path.delimiter));
});
});

describe("findExecutable", () => {
it("returns the first executable named on PATH", () => {
const bin = path.join(tmpDir, "bin");
const openroad = makeExecutable(bin, "openroad");
expect(findExecutable("openroad", ["/usr/bin", bin].join(path.delimiter))).toBe(openroad);
});

it("returns undefined when the binary is missing", () => {
expect(findExecutable("openroad", "/usr/bin")).toBeUndefined();
});
});

describe("parseLoginShellPath", () => {
it("reads the marker even when the shell printed a banner first", () => {
expect(parseLoginShellPath("Welcome!\n__OPENROAD_MCP_PATH__=/opt/homebrew/bin:/usr/bin\n")).toBe(
"/opt/homebrew/bin:/usr/bin",
);
});

it("returns undefined when the marker is absent", () => {
expect(parseLoginShellPath("/opt/homebrew/bin")).toBeUndefined();
});
});

describe("default location lists", () => {
it("puts Homebrew first on macOS", () => {
expect(defaultExtraBinDirs("/Users/me", "darwin")[0]).toBe("/opt/homebrew/bin");
});

it("includes the default ORFS checkout", () => {
expect(defaultOrfsFlowCandidates("/home/me")[0]).toBe(
path.join("/home/me", "OpenROAD-flow-scripts", "flow"),
);
});
});

describe("enrichInheritedEnv", () => {
function deps(overrides: Partial<PathEnvDeps> & { env: NodeJS.ProcessEnv }): PathEnvDeps {
return {
isExecutable: (p) => {
try {
fs.accessSync(p, fs.constants.X_OK);
return fs.statSync(p).isFile();
} catch {
return false;
}
},
dirExists: (p) => {
try {
return fs.statSync(p).isDirectory();
} catch {
return false;
}
},
extraBinDirs: () => [],
extraOrfsDirs: () => [],
readLoginShellPath: () => undefined,
...overrides,
};
}

it("leaves PATH unchanged when openroad is already on it", () => {
const bin = path.join(tmpDir, "already");
makeExecutable(bin, "openroad");
const current = [bin, "/usr/bin"].join(path.delimiter);
const env: NodeJS.ProcessEnv = { PATH: current };
let loginCalled = false;
const result = enrichInheritedEnv(
deps({
env,
extraBinDirs: () => ["/opt/homebrew/bin"],
readLoginShellPath: () => {
loginCalled = true;
return "/opt/homebrew/bin";
},
}),
);
expect(env.PATH).toBe(current);
expect(result.openroad).toBe(path.join(bin, "openroad"));
expect(loginCalled).toBe(false);
});

it("prepends the login-shell directory that contains openroad", () => {
const loginBin = path.join(tmpDir, "login-bin");
makeExecutable(loginBin, "openroad");
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin:/bin" };
const result = enrichInheritedEnv(
deps({
env,
readLoginShellPath: () => [loginBin, "/usr/bin"].join(path.delimiter),
}),
);
expect(result.openroad).toBe(path.join(loginBin, "openroad"));
expect(env.PATH?.split(path.delimiter)[0]).toBe(loginBin);
});

it("finds openroad in extra bin dirs used by Homebrew/conda/local builds", () => {
const brewBin = path.join(tmpDir, "homebrew");
makeExecutable(brewBin, "openroad");
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
const result = enrichInheritedEnv(deps({ env, extraBinDirs: () => [brewBin] }));
expect(result.openroad).toBe(path.join(brewBin, "openroad"));
expect(env.PATH?.split(path.delimiter)[0]).toBe(brewBin);
});

it("does not overwrite an explicit ORFS_FLOW_PATH", () => {
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin", ORFS_FLOW_PATH: "/custom/flow" };
const flow = makeOrfsFlow(path.join(tmpDir, "OpenROAD-flow-scripts"));
const result = enrichInheritedEnv(deps({ env, extraOrfsDirs: () => [flow] }));
expect(result.orfsFlowPath).toBe("/custom/flow");
expect(env.ORFS_FLOW_PATH).toBe("/custom/flow");
});

it("sets ORFS_FLOW_PATH when a candidate flow tree exists", () => {
const flow = makeOrfsFlow(path.join(tmpDir, "OpenROAD-flow-scripts"));
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
const result = enrichInheritedEnv(deps({ env, extraOrfsDirs: () => [flow] }));
expect(result.orfsFlowPath).toBe(flow);
expect(env.ORFS_FLOW_PATH).toBe(flow);
});

it("detects ORFS next to a local openroad install", () => {
const root = path.join(tmpDir, "OpenROAD-flow-scripts");
const flow = makeOrfsFlow(root);
const bin = path.join(root, "tools", "install", "OpenROAD", "bin");
makeExecutable(bin, "openroad");
const env: NodeJS.ProcessEnv = { PATH: bin };
const result = enrichInheritedEnv(deps({ env, extraOrfsDirs: () => [] }));
expect(result.orfsFlowPath).toBe(flow);
expect(env.ORFS_FLOW_PATH).toBe(flow);
});
});
});
Loading
Loading