feat(daemon): add od mcp - expose Open Design as an MCP server - #399
Conversation
Lets a coding agent in a different repo (Claude Code, Cursor, Zed)
pull files from a locally-running OD project over the Model Context
Protocol — no export/import zip dance.
The MCP server is a thin stdio process that proxies read-only tool
calls to the daemon's existing HTTP API; no daemon-side changes
required. Exposes 8 tools:
list_projects, get_project,
list_files, get_file,
list_skills, get_skill,
list_design_systems, get_design_system
Wired exactly like `od media`: a hoisted flag set, a SUBCOMMAND_MAP
entry, a thin handler that resolves OD_DAEMON_URL and hands off to
src/mcp.ts. Tool dispatch is a switch over the tool name; each branch
fetches the matching daemon route and surfaces the response as MCP
text content. Binary mimes return a clear error pending phase-2
support.
Lifecycle gotcha worth flagging: Server.connect(transport) only
*starts* the stdio reader; the promise resolves immediately. Without
holding the function awaiting until transport/stdin close, cli.ts's
top-level process.exit(0) kills the server before the first request
arrives. The fix in src/mcp.ts holds until onclose / stdin EOF.
Wire-up example for a consuming repo:
{
"mcpServers": {
"open-design": {
"command": "od",
"args": ["mcp"],
"env": { "OD_DAEMON_URL": "http://127.0.0.1:7456" }
}
}
}
New dep: @modelcontextprotocol/sdk (MIT, official Anthropic SDK).
Hand the consuming LLM a system-prompt-style overview of the OD workflow so it picks the right tool without prompt-engineering on the user's side. Mentions get_artifact and project-name resolution ahead of their actual implementation; both ship in the same batch.
Lets a consuming agent say `project: "recaptr"` instead of pasting a UUID. Match order: exact id → exact name (case-insensitive) → slug-normalized name (strips trailing " (N)", normalizes whitespace) → substring (errors if multiple). UUID inputs short-circuit and never hit the daemon.
Promote metadata.entryFile and metadata.kind to top-level fields so consumers (including get_artifact in this branch) can find the entry without digging through nested metadata blobs.
A design rarely lives in a single file. get_artifact pulls the entry
HTML/JSX plus every sibling it references (tokens CSS, JSX modules,
imported components) in one call, so a consuming agent doesn't need
to parse HTML and round-trip per file.
Three modes:
auto (default): BFS over relative <script src>, <link href>,
<img src>, <source/video src>, JSX import/from, CSS url(), with
depth cap 3 and a visited set. CDN, data:, mailto:, anchors, and
paths containing .. are skipped.
all: every textual file in the project (mirror of /archive
minus binaries).
shallow: just the entry file (same as get_file).
Output is a structured JSON blob with name/mime/size/content per
file and the project's manifest metadata at the top.
Server-side substring search across textual project files. Returns file, 1-indexed line, and snippet, capped at 1000 matches. Exposed through the MCP layer as search_files(project, query, pattern?, max?). Treats the query as a literal substring (regex chars escaped) to avoid catastrophic-backtracking attacks from LLM-supplied input. Honors the project dir's existing path-safety guards via listFiles.
Lets a consumer poll for "what's changed since I last looked" without re-walking every file. Daemon-side: parse since= as ms, filter listFiles output by mtime. MCP-side: forward as URL query.
Catalog reads are stable reference material — they fit MCP's resources surface (LLM-passive) better than tools (LLM-active). Skills and design systems each become resources at od://skills/<id>/SKILL.md and od://design-systems/<id>/DESIGN.md; existing list_skills / get_skill / list_design_systems / get_design_system tools remain as fallbacks for clients that don't handle resources cleanly.
Several silent-failure paths and minor footguns the first pass missed:
- get_artifact auto: the entry's own fetch now raises a clear
error instead of returning files: []. Previously a typo in
`entry:` looked like an empty project.
- get_artifact: invalid `include` value returns a clear error
listing the valid modes instead of silently behaving as auto.
- get_artifact all: includes binary files as metadata stubs to
match auto's behavior. Both modes are now strict supersets of
shallow.
- extractRelativeRefs: gate JS-only patterns (import/from/require/
dynamic-import) by file mime/extension so prose in markdown or
HTML doesn't generate spurious 404 round-trips on words like
"imported from 'X'".
- extractRelativeRefs: cover <iframe>, <audio>, srcset, and
CSS @import — common in real OD output.
- resources/list descriptions are collapsed to a single line
(newlines + repeated whitespace -> one space) so MCP UIs that
don't normalize whitespace render cleanly.
- fetchProjectFile: 0-byte binary files no longer report size: null
due to falsy short-circuit on Number(content-length).
A typical agent session calls list_files/get_file/get_artifact several times in a row, each with a project name argument. Each previously re-fetched /api/projects. Cache the list in module scope with a 5s TTL so back-to-back lookups are local; renames in the OD UI still propagate within a few seconds.
…axBytes
Three changes well-behaved MCP clients pick up automatically:
- Tool ordering. list_projects + get_artifact are now first; LLMs
that weight earlier entries surface the bundle path before
per-file fetching. Catalog tools (list_skills, get_skill,
list_design_systems, get_design_system) sit at the bottom; they
are also exposed as MCP resources.
- readOnlyHint / idempotentHint / openWorldHint annotations on
every tool so clients can skip confirmation prompts on safe
tools and let the LLM know re-running is fine. Per-tool `title`
annotations give clients a friendlier display name than the
snake_case tool id.
- get_artifact gains a `maxBytes` arg (default 1.5MB). Once the
accumulated textual content crosses the cap, remaining files
are dropped and `truncated: true` is set on the bundle so the
consumer knows to use list_files / get_file for the rest.
The "what file are you on?" round-trip the agent had to do every
session is now answered automatically. Three pieces:
- Daemon: in-memory active-context slot with 5-minute TTL.
POST /api/active sets {projectId, fileName}; GET /api/active
returns the current value enriched with projectName, or
{active:false} when the slot is empty/stale. Cleared on
daemon restart.
- Web: a small useEffect in App.tsx posts the active project +
file to the daemon on every route change. Best-effort fire-
and-forget; a missing daemon doesn't surface an error.
- MCP: get_active_context tool (no args) and a matching MCP
resource at od://focus/active. The tool is listed second,
right after list_projects, so an LLM picks it up before
asking for ids. Server instructions tell the model to call
it FIRST when the user says "this file" / "the design I have
open" / "what I'm looking at."
End to end: user opens a project in OD, agent in another repo
calls get_active_context() → gets {projectName: "recaptr",
fileName: "recaptr-onboarding-4.html"}, then immediately calls
get_artifact(project: "recaptr") with no further user input.
…ontext
get_artifact, get_project, get_file, search_files, and list_files now
accept project as optional. When omitted, the MCP resolves project
from /api/active so an agent in another repo can call
search_files({ query: "Polaroid" })
without first asking the user "which project?". get_file and
get_artifact also default their path/entry to the active file, so
get_file({}) returns whatever the user is currently looking at.
The implicit path stamps `usedActiveContext` on JSON responses (or a
separate `[od:active-context …]` content block on get_file) so the
agent can see exactly which project/file got chosen. Explicit
project args pass through with zero added overhead.
Cuts the common case from two MCP round trips
(get_active_context → search_files) to one. Server instructions and
get_active_context's own description are updated to point at the
new default.
The active-context endpoint was added without isLocalSameOrigin guard. Since the daemon binds 0.0.0.0 by default, a LAN peer could GET it to learn what file the user has open, or POST it to redirect the MCP fallback to a project of their choice. Same-origin only is the right scope: the web app proxies its requests through Next.js on the daemon port, and the MCP runs over loopback in-process, so both legitimate callers pass. Pattern matches the existing /api/app-config etc. guards.
…ippets The Settings -> MCP server panel needs absolute paths to node and the daemon's built cli.js so it can render snippets that work on a fresh source clone (where `od` is not on PATH) and dodge the /usr/bin/od octal-dump tool that ships on macOS/Linux and would otherwise shadow ours. Endpoint returns: - command: process.execPath (the node binary running the daemon) - args: [<absolute path to dist/cli.js>, "mcp"] - daemonUrl: http://127.0.0.1:<port> - platform: process.platform (so the panel can localize ~/.cursor vs %USERPROFILE%\.cursor and Cmd vs Ctrl shortcuts) - cliExists / nodeExists: existsSync checks on both binaries - buildHint: human-readable build/reinstall instructions when either path is missing isLocalSameOrigin guard same as /api/active. Cached for 5s because the panel may re-fetch on every open and the paths cannot change without a daemon restart. Test file covers the happy path, cross-origin rejection, two allowed-Origin variants, and the cache by counting fresh resolves across rapid calls. 5/5 pass.
Three intertwined cleanups that all live in mcp.ts + cli.ts: 1. Drop catalog tools from MCP. list_skills / get_skill / list_design_systems / get_design_system are removed. The audience is a coding agent in a separate repo consuming Open Design's output; it cannot run skills (those are recipes Open Design uses to generate) and design-system DESIGN.md is reference material that already ships as an MCP resource. Keeping the catalog as tools cost ~350 token-overhead per turn for capabilities the agent could not act on. Tool count: 11 -> 7. 2. Trim tool descriptions. The active-context fallback explanation was repeated in 5 separate tool descriptions; hoisted into PROJECT_ARG and explained once in the server `instructions` block instead. Saves ~150-200 tokens per tools/list response. 3. User-facing branding pass. Tool titles, tool descriptions, resource names, error messages, comments, and `od mcp --help` now consistently use "Open Design" rather than "OD". Internal abbreviation `OD` is retained only inside the server instructions block where it is introduced inline as "Open Design (OD)" for compactness across multi-paragraph guidance. Em dashes replaced with hyphens throughout, per project style.
New "MCP server" section in the Settings dialog, surfacing
copy-paste install snippets for the major MCP-compatible coding
agents (Claude Code, Cursor, VS Code, Antigravity, Zed, Windsurf).
Highlights:
- In-brand custom dropdown (reuses the existing .ds-picker
pattern from the design-system / prompt-template pickers, click
outside / Escape to close, chevron animates) instead of a
native <select>.
- Per-client snippet that uses absolute paths to node + cli.js
fetched from /api/mcp/install-info on mount, so it works even
when `od` is not on PATH.
- Cursor gets a one-click "Install in Cursor" deeplink
(cursor://anysphere.cursor-deeplink/mcp/install) that pops an
approval dialog and writes the config for the user. UTF-8-safe
base64 so paths with accented characters do not throw.
- Per-OS path hints (~/.cursor on POSIX, %USERPROFILE%\.cursor
on Windows) and keyboard shortcuts (Cmd vs Ctrl).
- Build-required warning card when cli.js or the node binary
does not exist on disk; deeplink button disables in that state.
- Prominent "restart your client to pick up the new server"
callout below the snippet, with per-client guidance.
- Capability list ("what your agent can do") instead of a tool-
name dump, so non-developer designers can also tell what is
possible without reading MCP docs.
README adds a short "Use Open Design from your coding agent"
section that points at the panel and summarizes the per-client
flow (one-click for Cursor, JSON merge elsewhere). Read-only by
design; the daemon must be running locally.
The "Use Open Design from your coding agent" section had drifted
from what the panel actually emits and lists.
- Add Antigravity to the supported-client list (previously missing).
- Drop the "(GitHub Copilot)" parenthetical from VS Code so the
label matches the panel.
- Fix the Claude Code line: we no longer emit a single
`claude mcp add ...` shell command. The snippet is JSON; the
panel additionally suggests `claude mcp add-json` as the safer
way to apply it instead of hand-editing ~/.claude.json.
- Swap the "find the Polaroid section" example for two more
universal phrases ("build this in my app", "match these
styles") that match what the panel surfaces.
- Add a one-line "restart or reload your client after install"
note - this was prominent in the panel and absent from the
README.
- Trim the /usr/bin/od octal-dump aside; it was technical detail
that did not earn its space at the README intro level.
Codex is a first-class supported coding agent (listed alongside Claude Code, Cursor, etc. in the README's PATH-detected agent table) but the install panel was missing it. Codex stores MCP server config at ~/.codex/config.toml (TOML, not JSON) under an `[mcp_servers.<name>]` table, and the same file is shared between the Codex CLI and the Codex IDE extension - so one install covers both. Added a 7th client entry that emits the right TOML snippet, expanded the snippet-lang union to include 'toml' (behaves like 'json' for whitespace handling, just a different syntax-highlight hint). For our minimal payload (just command + args), JSON.stringify happens to produce valid TOML literal values since TOML basic strings use the same double-quote escape rules as JSON, and TOML inline arrays match JSON array syntax. No new TOML serializer needed. README updated to list Codex among the supported clients. Schema verified against https://developers.openai.com/codex/mcp.
The previous port-pinned check required the request's Origin to match
either the daemon's own port or OD_WEB_PORT. tools-dev does not pass
OD_WEB_PORT to the daemon process, so any browser POST to /api/active
proxied through the dev web (port 17573 etc.) was rejected with 403,
and get_active_context always returned {active: false}.
Relax to a loopback-prefix match: any http://127.0.0.1:*,
http://localhost:*, or http://[::1]:* origin passes regardless of
port. Cross-origin (https://evil.com) is still rejected. The
trade-off is that another local web app on a different loopback port
could now CSRF the daemon; same-origin checks are inherently a CSRF
defense, not a network ACL.
claude mcp add-json open-design '<json>' takes only the inner
server-config object, not the full {"mcpServers": ...} wrapper, and
rejected the wrapped shape with "Invalid configuration: : Invalid
input". Pass only the inner config, and inline the JSON into the
command itself so the snippet is a real one-liner the user can copy
and paste, no template substitution.
|
Hi @emilneander! 🎉 Thanks for making open-design better! |
|
Will fix merge conflict -> switched to draft |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 87c1416225
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| args: [cliPath, 'mcp'], | ||
| daemonUrl: `http://127.0.0.1:${resolvedPort}`, |
There was a problem hiding this comment.
Include daemon URL in generated MCP CLI args
The install payload hard-codes args to [cliPath, 'mcp'], but od mcp falls back to http://127.0.0.1:7456 when --daemon-url is absent. This makes every copied MCP config point at the wrong daemon whenever Open Design runs on a non-default port (for example tools-dev --daemon-port ... or concurrent namespaces), so tool calls fail even though install succeeded. The generated args should include the resolved daemon URL (or equivalent env) so the snippet is valid for the current runtime port.
Useful? React with 👍 / 👎.
| : ''; | ||
| const resolved = raw.startsWith('/') ? raw.slice(1) : dir + raw; | ||
| const clean = resolved.replace(/[?#].*$/, '').replace(/^\.\//, ''); | ||
| if (!clean || clean.includes('..')) continue; |
There was a problem hiding this comment.
Preserve parent-relative refs when bundling artifacts
The artifact reference resolver drops any candidate path containing .., which excludes valid parent-relative imports like ../tokens.css or ../shared/Button.tsx from nested files. In projects with directory trees, get_artifact(include="auto") therefore omits legitimate dependencies and returns incomplete bundles, reducing MCP accuracy for downstream code generation. Normalize and bound-check the resolved path instead of blanket-rejecting all .. segments.
Useful? React with 👍 / 👎.
lefarcen
left a comment
There was a problem hiding this comment.
Hey @emilneander, thanks for the PR! 👋
This is a well-architected MCP integration with solid read-only tool design and clean active-context tracking. The code is production-quality, but there are 4 critical security/correctness issues that need addressing before merge, plus several important design gaps.
P1 (Must-fix)
1. Active context endpoint lacks authentication - daemon binds 0.0.0.0 by default
The /api/active endpoint uses isLocalSameOrigin (loopback host + origin regex checks) as its sole guard, but:
- Daemon binds
0.0.0.0by default (server.ts:654), meaning it's accessible to any device on the local network - An attacker on the same network can bypass the loopback check by connecting directly to the machine's LAN IP (e.g.,
http://192.168.1.100:7456/api/active) - The
Hostheader check only validates127.0.0.1|localhost|[::1], but the request can come from192.168.x.xor10.x.x.xwhen daemon binds all interfaces - This leaks the user's active project ID and file paths to local network attackers
Fix: Add daemon-level authentication (token in header/cookie) OR bind daemon to 127.0.0.1 by default when MCP is enabled OR add explicit same-machine-only validation (check connecting IP is loopback, not just Host header).
2. MCP server exposes active context without authentication
MCP tools (get_active_context, project-defaulting in get_file/get_artifact) proxy /api/active with zero authentication. If a malicious MCP client is installed (or a compromised coding agent), it can:
- Read the user's current project ID and file paths passively
- Exfiltrate file contents by calling
get_filewithout project arguments (defaults to active) - The stdio transport itself is secure (local process), but any MCP client the user installs can exploit this
Fix: Document threat model explicitly: MCP tools trust the client (user must vet MCP clients they install). OR add a first-run consent dialog in Settings → MCP server: "MCP clients will see your active project. Trust this client?"
3. Substring project matching is ambiguous and could leak unintended projects
resolveProjectId (mcp.ts:501) matches project names by substring:
const subs = list.filter((p) => norm(p.name).toLowerCase().includes(target));
if (subs.length === 1) return subs[0].id;Scenario:
- User has projects: "recapture-client", "recapture-internal"
- Agent calls
get_file(project="recapture", path="...") subs.length === 2→ throws ambiguity error (good)- But if "recapture-internal" is deleted, suddenly
project="recapture"silently resolves to "recapture-client" (or vice versa) - Exfiltration risk if the agent is malicious or confused
Fix: Remove substring fallback for ambiguous cases OR require exact match / UUID when multiple projects contain the substring. Substring matching is fine for convenience but should fail-closed on ambiguity.
4. get_artifact bundling depth-3 has no size validation before fetch
maxBytes is a soft cap applied after fetching all referenced files (default 1.5MB). If a malicious project has circular references or deep import chains:
- The MCP server will fetch unbounded files before hitting the cap
- DoS risk: agent asks for artifact on a project with 100MB of nested imports
- The bundler stops at depth 3, but that's still
O(branches^3)files
Fix: Add a hard file-count cap (e.g., max 200 files per get_artifact call) OR enforce maxBytes during fetch (streaming cut-off) rather than after.
P2 (Should-fix)
5. Active context TTL (5 minutes) is not documented in tool descriptions
When the agent calls get_file() without project, it defaults to active context. But if the user switched projects 6 minutes ago, the MCP server returns {active:false} and the tool call fails.
The tool descriptions say "defaults to active project" but don't mention the TTL.
Fix: Add to tool descriptions: "Active context expires after 5 minutes of no Open Design activity."
6. No recovery path when daemon is not running
PR body says: "Spawn the MCP server with no daemon running and tool calls return a clear 'daemon not reachable' error."
But what if:
- User starts MCP client (Cursor) before starting Open Design
- MCP server launches, tool calls fail forever with fetch errors
- User then starts Open Design → MCP client already initialized with stale
daemonUrl
Fix: Document: "If tool calls fail with connection errors, restart your coding agent after starting Open Design." OR add retry logic with exponential backoff in MCP fetch.
7. Design gap: Why MCP instead of LSP or direct file access?
The PR doesn't explain why MCP is the right choice over:
- LSP (Language Server Protocol) - standard for IDE integrations, supports file watching
- Direct filesystem access - coding agents can already read
~/open-design-projectsif the daemon writes files there - HTTP API exposure - why stdio MCP instead of just documenting the daemon's existing HTTP API?
Fix: Add to PR body or README: "Why MCP? (a) Coding agents already support MCP out of the box. (b) Active context fallback is stateful, doesn't work with bare HTTP API. (c) Skills/design-systems as resources saves tool slots."
8. Threat model unstated
The PR body doesn't address:
- What if a malicious MCP client is installed? (answer: user must vet clients, MCP is trust-based)
- What if local network attacker hits
/api/active? (answer: current guard insufficient, see P1.1) - What if a project contains malicious HTML that the agent executes? (answer: read-only, agent's sandbox problem)
Fix: Add "## Security model" section to PR body or README: "MCP clients run as local processes and inherit full read access to projects. Users must trust the MCP clients they install. The MCP server is read-only by design; write operations are not exposed."
P3 (Nit)
9. README claims "no zip export, no copy-paste" but doesn't quantify the improvement
Claim: "No zip export, no copy-paste. When the agent calls search_files, get_file, or get_artifact..."
But:
- How often does export-zip-import happen in typical workflows?
- How much time/friction does MCP save?
- Is there a "before/after" user study or metric?
Fix: Quantify OR soften the claim: "Reduces the export-zip-import friction for iterative design workflows."
10. @ts-nocheck on mcp.ts without explanation
Line 1 of mcp.ts: // @ts-nocheck
Why? Is the MCP SDK's type defs broken? If so, document in a comment. If not, remove the directive and fix the type errors.
Fix: Remove @ts-nocheck and fix types OR add comment: // @ts-nocheck - MCP SDK type defs don't match runtime (v1.0.0), remove when fixed
Summary
- Security: P1.1-P1.3 are real attack vectors on local networks or with malicious MCP clients. Fix the authentication/binding issue on
/api/activebefore merge. - Design: P2.7-P2.8 are reasoning gaps - the PR doesn't explain why MCP or what the threat model is. These should be documented even if the code is correct.
- Correctness: P1.4 (unbounded fetch in get_artifact) is a DoS risk.
Happy to iterate on the security model - this is a great feature, just needs the auth story tightened up before external coding agents can safely use it. Let me know if you'd like to discuss any of these offline.
— open-design team
| // callers pass the check. | ||
| app.post('/api/active', (req, res) => { | ||
| if (!isLocalSameOrigin(req, resolvedPort)) { | ||
| return res.status(403).json({ error: 'cross-origin request rejected' }); |
There was a problem hiding this comment.
🔒 P1: Active context endpoint bypasses same-origin guard when daemon binds 0.0.0.0 (default). An attacker on the local network can connect via http://192.168.1.100:7456/api/active and bypass the isLocalSameOrigin check (which only validates Host header, not connecting IP). This leaks active project ID and file paths.
Fix: Add daemon-level auth (token in header) OR bind to 127.0.0.1 when MCP is enabled OR validate connecting IP is loopback (not just Host header).
| const opts = subs.map((p) => `${p.name} (${p.id})`).join(', '); | ||
| throw new Error( | ||
| `multiple projects match "${arg}": ${opts}. Pass the UUID instead.`, | ||
| ); |
There was a problem hiding this comment.
project="recapture" matches both → fails. But if one is deleted, it suddenly resolves to the other. Exfiltration risk if agent is confused or malicious.
Fix: Fail-closed on ambiguity: require exact match or UUID when multiple projects contain substring. Or return all matches and let agent choose.
| : `${note} (active file: ${active.fileName ?? 'none'})`; | ||
| } | ||
|
|
||
| const VALID_INCLUDE_MODES = new Set(['auto', 'all', 'shallow']); |
There was a problem hiding this comment.
maxBytes is soft cap applied after fetching all files. If project has deep import chains, MCP server fetches unbounded bytes before hitting cap. DoS risk.
Fix: Add hard file-count cap (e.g., 200 files max) OR enforce maxBytes during fetch (streaming cut-off) rather than after.
| @@ -0,0 +1,854 @@ | |||
| // @ts-nocheck | |||
There was a problem hiding this comment.
P3: @ts-nocheck without explanation. Is MCP SDK's type defs broken? Document why or remove and fix types.
|
|
||
| ## Use Open Design from your coding agent | ||
|
|
||
| Open Design ships a stdio MCP server. Wire it into Claude Code, Codex, Cursor, VS Code, Antigravity, Zed, Windsurf, or any MCP-compatible client and the agent in another repo can read files from your local Open Design projects directly. No zip export, no copy-paste. When the agent calls `search_files`, `get_file`, or `get_artifact` without a project argument, the MCP defaults to whatever project (and file) you have open in Open Design right now, so prompts like *"build this in my app"* or *"match these styles"* just work. |
There was a problem hiding this comment.
P2: Claim "No zip export, no copy-paste" - how much time/friction does MCP actually save? Quantify with a metric or user study OR soften to "Reduces export-zip-import friction for iterative workflows."
Conflicts resolved: - apps/daemon/package.json: kept @modelcontextprotocol/sdk ^1.0.0, took main's workspace:0.3.0 for all @open-design/* deps. - apps/daemon/src/server.ts: took main's rewriteSkillAssetUrls (from nexu-io#366 Atelier Zero) and main's port-pinned isLocalSameOrigin (from nexu-io#365/#a719f02). Dropped our loopback-prefix relaxation; main's web sidecar proxy now rewrites Origin before forwarding, making the relaxation dead weight. - pnpm-lock.yaml: took main's verbatim, re-added SDK entry via pnpm install.
…origin policy The two proxy-flow allow tests were added in ae13094 to cover our relaxed isLocalSameOrigin. Main's port-pinned implementation (from nexu-io#365) now handles the dev-flow via the web sidecar proxy origin rewrite (#a719f02), making the relaxation -- and these tests -- unnecessary. Also replace the inline LOOPBACK_*_RE / isLocalSameOrigin replica in mcp-install-info.test.ts with a direct import from server.ts so both test files stay in sync with the production guard automatically.
The install panel snippet previously emitted `od mcp` with no daemon URL, so the MCP server always fell back to the hardcoded default port 7456. When tools-dev starts the daemon on a non-default port the snippet silently targets the wrong daemon. Fix: include --daemon-url http://127.0.0.1:<port> as the third arg so the generated snippet is always tied to the running daemon's actual port. Update the matching mini-app and assertion in the install-info test.
- extractRelativeRefs: replace blanket `includes('..')` rejection with
proper POSIX-style path normalization. `../tokens.css` in a nested
project layout now resolves to `tokens.css` instead of being
silently dropped.
- getArtifact: add MAX_FILES=200 cap to BFS auto and include=all modes.
Pass `remainingBytes` to fetchProjectFile so it can bail early when
the server-advertised content-length would already exceed the budget.
- resolveProjectId: return {id, name, source} instead of a bare id.
Callers echo `resolvedProject` in the response when the match was by
slug or substring, letting the agent confirm which project was
chosen without an extra round-trip.
- getFile: thread `resolved` through so substring matches surface
the same `[od:resolved-project ...]` annotation.
- @ts-nocheck: add a comment explaining the Zod-vs-JSON-Schema SDK
mismatch so future contributors don't remove it accidentally.
- get_active_context description: note the ~5-minute cache TTL.
Dropped accidentally when replacing the import header. The directive suppresses expected test-file noise (baseUrl pre-assignment and res.json() unknown return type); keeping it avoids littering the test body with `as any` casts for zero real safety benefit.
…covery note - Soften "No zip export, no copy-paste" to "Replaces the export-then-attach loop" per reviewer feedback. - Add "Why MCP?" paragraph explaining the structured-API benefit over zip exports. - Add daemon-not-running recovery note (clear error, not a crash; start with pnpm tools-dev and retry). - Add security model callout: read-only, loopback-only, Host/Origin guard rejects non-loopback requests.
8.3: Expand README security model to include stdio child process context, trust framing (treat like a VS Code extension), and OD_BIND_HOST opt-in for LAN exposure. 8.4: Replace terse "daemon not running" note in README with a full recovery sentence covering the start-agent-before-Open-Design case. Add the same recovery note as a footer paragraph in IntegrationsSection so users see it in the Settings panel without needing to read the README.
…echo resolvedProject
- Export extractRelativeRefs, resolveProjectId, resolveProjectArg, withActiveEcho, fetchProjectFile, getArtifact for testing - mcp-extract-refs.test.ts: 10 cases covering flat, nested, deep, escape attempts, external/data/anchor/mailto URLs, srcset - mcp-get-artifact.test.ts: MAX_FILES=200 cap, maxBytes cap, per-file content-length pre-check via fetchProjectFile - mcp-resolve-project.test.ts: uuid/exact/slug/substring source values, ambiguity error, withActiveEcho resolvedProject stamping - get_artifact maxBytes description now mentions the 200-file cap - Instructions block now mentions resolvedProject field and when it appears (slug or substring match)
Address PR nexu-io#399 review item P2.5 (active-context TTL undocumented) plus the related UX gap where the agent had no way to tell the user that clicking around in Open Design refreshes the cache. - PROJECT_ARG, get_artifact entry, get_file path: append TTL note to argument descriptions so agents see the ~5-minute fallback window. - get_active_context: when /api/active reports active:false, return an explicit hint string explaining the recovery action ("ask the user to click into a project") instead of a bare {active:false} the agent can't act on. - get_active_context tool description: mention the new hint payload. - resolveProjectArg error: extend the missing-active-context message with the same TTL + recovery wording for tool calls that omit project= and have no fallback.
Real-world MCP usage hit a wall on large files: get_file returned the full body, the agent decided the result was too large for its context budget, and recovered by spawning a sub-agent that ran Python with manual brace-matching for several minutes. That defeats the value proposition of skipping zip-export. Mirror Claude Code's Read tool semantics: get_file now accepts optional offset (0-indexed line) and limit (default 2000) args, slices the file in mcp.ts after fetching from the daemon, and stamps an [od:file-window offset=.. returnedLines=.. totalLines=..] marker on sliced or truncated responses so the agent can page by re-calling with the next offset. - Tool definition: add offset/limit args, expand description. - getFile helper: line-split, slice, marker, range clamp at EOF. - Instructions block: mention pagination in the get_file bullet. - Binary rejection unchanged. - New tests in mcp-get-file.test.ts cover default behavior, limit truncation, mid-file offset, offset past EOF, and binary rejection.
|
@lefarcen Thanks for the thorough review, and for #365 and #a719f02 -- the rebase folded both in and resolved most of the security concerns directly. Going through your items: P1.1 -- after the rebase, the daemon defaults to P1.2 -- documented in a new Security model section in the README and PR body. The model is: trust the MCP client you install, same as a VS Code extension. P1.3 -- kept substring matching but added a P1.4 -- P2.5 -- TTL note added to the P2.6 -- recovery note added to the README MCP section and the Settings panel copy. P2.7 -- new Why MCP section in README and PR body, addressing LSP, direct filesystem access, and just documenting the HTTP API as alternatives. P2.8 -- new Security model section in README and PR body. P3.9 -- softened the README phrasing. P3.10 -- added a comment block at line 1 of |
|
P1 (install-info args): fixed. The install snippet now bakes P2 (parent-relative refs): fixed. Replaced the |
|
@Naassoonaguiarr Thanks! |
|
To use Codex here, create an environment for this repo. |
lefarcen
left a comment
There was a problem hiding this comment.
Thanks for the comprehensive fixes! 🎉
I've verified all 10 findings from the previous review have been addressed:
Security (P1)
- ✅ P1.1: Daemon now defaults to
127.0.0.1(line 632 in server.ts). LAN exposure requires explicitOD_BIND_HOSTopt-in. - ✅ P1.2: Security model documented in README (line 354): read-only by design, trust MCP clients like VS Code extensions.
- ✅ P1.3: Substring project matching now echoes
resolvedProject: {id, name}so agents can confirm what was resolved. - ✅ P1.4:
MAX_FILES = 200hard cap added (mcp.ts:635) + pre-check on content-length to enforcemaxBytesduring fetch.
Design (P2)
- ✅ P2.5: TTL (5 minutes) documented in tool arg descriptions +
get_active_contextreturns{active: false, hint: "..."}with recovery guidance. - ✅ P2.6: Recovery note added to README + Settings panel.
- ✅ P2.7: "Why MCP?" section added (README:348), addresses LSP/filesystem/HTTP API alternatives.
- ✅ P2.8: Security model documented (README:354).
Nits (P3)
- ✅ P3.9: README phrasing softened ("Reduces export-zip-import friction").
- ✅ P3.10:
@ts-nochecknow has a detailed comment block explaining the SDK Zod/JSON Schema mismatch + follow-up plan.
Test coverage: 5 new test files (mcp-get-artifact.test.ts, mcp-get-file.test.ts, mcp-resolve-project.test.ts, mcp-extract-refs.test.ts, mcp-install-info.test.ts) covering the core MCP server logic.
All original security concerns are resolved. The rebase incorporated #365 (daemon binding fix) and commit a719f02 seamlessly. This MCP integration is production-ready from a security and design perspective.
Looks good to me; deferring final approval to a maintainer.
— open-design team
lefarcen
left a comment
There was a problem hiding this comment.
Thanks for the comprehensive fixes! 🎉
I've verified all 10 findings from the previous review have been addressed:
Security (P1)
- ✅ P1.1: Daemon now defaults to
127.0.0.1(line 632 in server.ts). LAN exposure requires explicitOD_BIND_HOSTopt-in. - ✅ P1.2: Security model documented in README (line 354): read-only by design, trust MCP clients like VS Code extensions.
- ✅ P1.3: Substring project matching now echoes
resolvedProject: {id, name}so agents can confirm what was resolved. - ✅ P1.4:
MAX_FILES = 200hard cap added (mcp.ts:635) + pre-check on content-length to enforcemaxBytesduring fetch.
Design (P2)
- ✅ P2.5: TTL (5 minutes) documented in tool arg descriptions +
get_active_contextreturns{active: false, hint: "..."}with recovery guidance. - ✅ P2.6: Recovery note added to README + Settings panel.
- ✅ P2.7: "Why MCP?" section added (README:348), addresses LSP/filesystem/HTTP API alternatives.
- ✅ P2.8: Security model documented (README:354).
Nits (P3)
- ✅ P3.9: README phrasing softened.
- ✅ P3.10:
@ts-nochecknow has a detailed comment block explaining the SDK Zod/JSON Schema mismatch + follow-up plan.
Test coverage: 5 new test files covering the core MCP server logic.
All original security concerns are resolved. The rebase incorporated #365 (daemon binding fix) and commit a719f02 seamlessly. This MCP integration is production-ready from a security and design perspective.
Looks good to me; deferring final approval to a maintainer.
— open-design team
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 45e4c64476
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // and the MCP runs in-process via 127.0.0.1, so both legitimate | ||
| // callers pass the check. | ||
| app.post('/api/active', (req, res) => { | ||
| if (!isLocalSameOrigin(req, resolvedPort)) { |
There was a problem hiding this comment.
Enforce local-client checks on /api/active writes
/api/active depends on isLocalSameOrigin(req, resolvedPort) for local-only protection, but that check trusts Host/Origin header values and allows requests with no Origin; since Host is client-controlled, a remote peer that can reach the daemon when it is bound to a non-loopback host can spoof an allowed Host (for example 127.0.0.1:<port>) and pass this guard. That allows off-machine callers to read or overwrite active context and influence MCP fallback resolution.
Useful? React with 👍 / 👎.
| (r) => !visited.has(r), | ||
| ); | ||
| } | ||
| outer: for (let depth = 1; depth < MAX_DEPTH && frontier.length > 0; depth++) { |
There was a problem hiding this comment.
Traverse full declared depth in auto artifact bundling
get_artifact(include="auto") claims to include referenced siblings up to depth 3, but the BFS loop is for (let depth = 1; depth < MAX_DEPTH; depth++) with MAX_DEPTH = 3, so only depth-1 and depth-2 dependencies are fetched. Any file first reachable at depth 3 (entry -> dep1 -> dep2 -> dep3) is silently omitted, yielding incomplete bundles for deeper import chains.
Useful? React with 👍 / 👎.
mrcfps
left a comment
There was a problem hiding this comment.
@emilneander I completed another implementation pass over the MCP changes and found one merge-safe correctness follow-up around how capped artifact bundles report completeness. Thanks for the careful security/documentation iteration here — the feature is coming together nicely.
Generated by Looper 0.5.4 · runner=reviewer · agent=opencode| try { | ||
| const remaining = maxBytes - totalTextBytes(fetched); | ||
| fetched.push(await fetchProjectFile(baseUrl, id, f.name, remaining)); | ||
| } catch { |
There was a problem hiding this comment.
When fetchProjectFile rejects because the next file exceeds the remaining maxBytes budget, this catch skips the file but leaves truncated unchanged. For example, after fetching two small files, a third referenced file with content-length > remaining will be omitted here, and if the loop later finishes without hitting totalTextBytes(fetched) >= maxBytes or MAX_FILES, okBundle can return truncated: false even though the bundle is incomplete. That matters because MCP consumers will trust truncated: false as a complete artifact graph and may miss required CSS/JS/assets. Please set truncated = true when a file is dropped for the remaining-budget cap (and consider adding a fixture where one oversized middle file is skipped without the accumulated total already reaching maxBytes). The same handling should apply to the auto-mode catch below as well. 🙂
There was a problem hiding this comment.
Good catch! Both the include=all loop and the auto BFS loop were silently swallowing the budget throw without setting truncated: true, so the bundle could report it was complete when files were actually dropped.
Fixed in 8881259 -- added a BudgetExceededError sentinel so the catch blocks can tell the difference between a budget rejection (sets truncated: true) and a real fetch failure like a 404 (skip silently). Also added a regression test for the exact path: 5 files with explicit content-length where totalTextBytes never hits the cap but the pre-check fires on files 1-4 😊
…ck fires When fetchProjectFile throws because a file's advertised content-length exceeds the remaining byte budget, both the include=all loop and the auto BFS loop silently skipped the file without setting truncated: true. The bundle could then report truncated: false even though files were dropped. Introduce BudgetExceededError as a sentinel so callers can distinguish a budget rejection (truncated: true) from a genuine fetch failure (404, network) that should just be skipped. Both getArtifact call sites now check instanceof BudgetExceededError and set truncated accordingly. Adds a regression test: 5 files of 250 bytes with explicit content-length, maxBytes=400. Only file 0 fits; files 1-4 each exceed the remaining 150 bytes. totalTextBytes never reaches maxBytes, so only the new path sets truncated=true. Previously the bundle reported truncated: false.
Dismissed: review appears to be non-technical feedback.
|
Merged! 🎉 Thanks @emilneander for the contribution to open-design. Looking forward to the next one! |
Major features: - feat(preview): live-reload iframes via chokidar+SSE (nexu-io#409). Daemon watches project dir, surfaces file-changed events on /api/projects/:id/events; web bumps file list so FileViewer iframes reload via mtime cache-bust. - feat(daemon): add `od mcp` — stdio MCP server exposing projects/files/skills/design-systems/artifacts via 10 tools and 2 resources; adds /api/active for "what is the user looking at" fallback (nexu-io#399). - feat: Critique Theater foundation — contracts + streaming parser, Phases 0-2 (nexu-io#387). Foundation only; orchestrator/Theater UI/SQLite columns ship in later phases. - feat(daemon): add Kilo CLI (ACP) code agent adapter (nexu-io#480). - feat(daemon): add DeepSeek TUI code agent adapter (nexu-io#439). - feat(daemon): expose OD_MEDIA_CONFIG_DIR to relocate media config independently of the data dir (nexu-io#411). - feat(daemon): expose skill resources via cwd-relative aliases (nexu-io#435). - feat(design-files): batch ZIP download with multi-select (nexu-io#405). - feat(editorial-collage): introduce Atelier Zero landing skill (nexu-io#366). - feat(skills): open-design-landing rename, add kami skills, landing OG (nexu-io#428). Security / correctness: - fix(web): normalize daemon proxy origins. - fix(daemon): strip ANTHROPIC_API_KEY when spawning Claude Code (nexu-io#400). - fix(web): isolate preview blob export paths (nexu-io#429). - Modernize multi-provider API proxy routing. - fix(daemon): support nested paths in project file serve route (nexu-io#401). - fix: cap htmlPreviewSlideState Map to prevent unbounded growth (nexu-io#488). - Refactor RUNTIME_DATA_DIR resolution logic (nexu-io#391). - Update Codex sandbox invocation (nexu-io#477). - [codex] Fix Gemini CLI trust handling (nexu-io#352). i18n: - Russian, French, Ukrainian, Brazilian Portuguese complete locales. - Russian gallery metadata; French QUICKSTART; pt-BR README/CONTRIBUTING. UI polish: - ws-tabs / artifact preview / language option button scrollbar & height fixes; FileViewer code-block copy buttons; aspect-ratio card layout; entry-tab layout; execution-mode tabs split; design-system showcase color picker; copilot prompt format fix. Conflict resolution: - apps/web/src/components/SettingsDialog.tsx — SettingsSection union kept both ours ('raigc') and upstream ('integrations'). Audit follow-up (non-blocking): - edited_image.png (2.3 MB) at repo root introduced by nexu-io#366 looks like an accidental commit; left in place per fork rule nexu-io#6. Per fork policy rules nexu-io#5/nexu-io#6: merge into fork only; never PR back.
- bump all 13 monorepo package.json files (apps/{web,daemon,desktop,
packaged,landing-page}, packages/{contracts,platform,sidecar,
sidecar-proto}, tools/{dev,pack}, e2e, root) to 0.4.0
- update internal workspace:0.3.x specifiers to workspace:0.4.0
- refresh pnpm-lock.yaml
- add CHANGELOG.md [0.4.0] - 2026-05-05 entry covering 64 merged PRs:
- Added: od mcp server (#399), Critique Theater Phase 4 (#481),
Linux x64 AppImage (#369), live-reload + Tweaks-mode previews
(#384, #409, #513), live artifacts + Composio (#381), link code
folder (#455), Kilo CLI + DeepSeek TUI agents (#439, #480),
Atelier Zero + kami skills (#366, #428), 5 locale pushes
- Changed: RUNTIME_DATA_DIR refactor (#391), Codex sandbox
invocation (#477)
- Fixed: security hardening trio (#365, #392, #400, #404, #514),
daemon hardening (#410, #435, #440, #492, #537), Web UI polish
(#412, #418, #429, #447, #448, #453, #471, #476, #488, #523)
- Documentation: Discord badge, README downloads, Running the
Project, Arabic / pt-BR README translations
Local verify dry-run passed: install + daemon build + desktop build +
web build:sidecar + workspace typecheck (exit 0) + pnpm guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- bump all 13 monorepo package.json files (apps/{web,daemon,desktop,
packaged,landing-page}, packages/{contracts,platform,sidecar,
sidecar-proto}, tools/{dev,pack}, e2e, root) to 0.4.0
- update internal workspace:0.3.x specifiers to workspace:0.4.0
- refresh pnpm-lock.yaml
- add CHANGELOG.md [0.4.0] - 2026-05-05 entry covering 64 merged PRs:
- Added: od mcp server (#399), Critique Theater Phase 4 (#481),
Linux x64 AppImage (#369), live-reload + Tweaks-mode previews
(#384, #409, #513), live artifacts + Composio (#381), link code
folder (#455), Kilo CLI + DeepSeek TUI agents (#439, #480),
Atelier Zero + kami skills (#366, #428), 5 locale pushes
- Changed: RUNTIME_DATA_DIR refactor (#391), Codex sandbox
invocation (#477)
- Fixed: security hardening trio (#365, #392, #400, #404, #514),
daemon hardening (#410, #435, #440, #492, #537), Web UI polish
(#412, #418, #429, #447, #448, #453, #471, #476, #488, #523)
- Documentation: Discord badge, README downloads, Running the
Project, Arabic / pt-BR README translations
Local verify dry-run passed: install + daemon build + desktop build +
web build:sidecar + workspace typecheck (exit 0) + pnpm guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- bump all 13 monorepo package.json files (apps/{web,daemon,desktop,
packaged,landing-page}, packages/{contracts,platform,sidecar,
sidecar-proto}, tools/{dev,pack}, e2e, root) to 0.4.0
- update internal workspace:0.3.x specifiers to workspace:0.4.0
- refresh pnpm-lock.yaml
- add CHANGELOG.md [0.4.0] - 2026-05-05 entry covering 64 merged PRs:
- Added: od mcp server (#399), Critique Theater Phase 4 (#481),
Linux x64 AppImage (#369), live-reload + Tweaks-mode previews
(#384, #409, #513), live artifacts + Composio (#381), link code
folder (#455), Kilo CLI + DeepSeek TUI agents (#439, #480),
Atelier Zero + kami skills (#366, #428), 5 locale pushes
- Changed: RUNTIME_DATA_DIR refactor (#391), Codex sandbox
invocation (#477)
- Fixed: security hardening trio (#365, #392, #400, #404, #514),
daemon hardening (#410, #435, #440, #492, #537), Web UI polish
(#412, #418, #429, #447, #448, #453, #471, #476, #488, #523)
- Documentation: Discord badge, README downloads, Running the
Project, Arabic / pt-BR README translations
Local verify dry-run passed: install + daemon build + desktop build +
web build:sidecar + workspace typecheck (exit 0) + pnpm guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…io#399) * feat(daemon): add `od mcp` subcommand for stdio MCP server Lets a coding agent in a different repo (Claude Code, Cursor, Zed) pull files from a locally-running OD project over the Model Context Protocol — no export/import zip dance. The MCP server is a thin stdio process that proxies read-only tool calls to the daemon's existing HTTP API; no daemon-side changes required. Exposes 8 tools: list_projects, get_project, list_files, get_file, list_skills, get_skill, list_design_systems, get_design_system Wired exactly like `od media`: a hoisted flag set, a SUBCOMMAND_MAP entry, a thin handler that resolves OD_DAEMON_URL and hands off to src/mcp.ts. Tool dispatch is a switch over the tool name; each branch fetches the matching daemon route and surfaces the response as MCP text content. Binary mimes return a clear error pending phase-2 support. Lifecycle gotcha worth flagging: Server.connect(transport) only *starts* the stdio reader; the promise resolves immediately. Without holding the function awaiting until transport/stdin close, cli.ts's top-level process.exit(0) kills the server before the first request arrives. The fix in src/mcp.ts holds until onclose / stdin EOF. Wire-up example for a consuming repo: { "mcpServers": { "open-design": { "command": "od", "args": ["mcp"], "env": { "OD_DAEMON_URL": "http://127.0.0.1:7456" } } } } New dep: @modelcontextprotocol/sdk (MIT, official Anthropic SDK). * feat(daemon): add MCP server instructions for zero-shot LLM context Hand the consuming LLM a system-prompt-style overview of the OD workflow so it picks the right tool without prompt-engineering on the user's side. Mentions get_artifact and project-name resolution ahead of their actual implementation; both ship in the same batch. * feat(daemon): resolve MCP project args by UUID, name, or substring Lets a consuming agent say `project: "recaptr"` instead of pasting a UUID. Match order: exact id → exact name (case-insensitive) → slug-normalized name (strips trailing " (N)", normalizes whitespace) → substring (errors if multiple). UUID inputs short-circuit and never hit the daemon. * feat(daemon): surface entryFile and kind on MCP get_project response Promote metadata.entryFile and metadata.kind to top-level fields so consumers (including get_artifact in this branch) can find the entry without digging through nested metadata blobs. * feat(daemon): add MCP get_artifact tool for bundle retrieval A design rarely lives in a single file. get_artifact pulls the entry HTML/JSX plus every sibling it references (tokens CSS, JSX modules, imported components) in one call, so a consuming agent doesn't need to parse HTML and round-trip per file. Three modes: auto (default): BFS over relative <script src>, <link href>, <img src>, <source/video src>, JSX import/from, CSS url(), with depth cap 3 and a visited set. CDN, data:, mailto:, anchors, and paths containing .. are skipped. all: every textual file in the project (mirror of /archive minus binaries). shallow: just the entry file (same as get_file). Output is a structured JSON blob with name/mime/size/content per file and the project's manifest metadata at the top. * feat(daemon): add /api/projects/:id/search route + MCP search_files Server-side substring search across textual project files. Returns file, 1-indexed line, and snippet, capped at 1000 matches. Exposed through the MCP layer as search_files(project, query, pattern?, max?). Treats the query as a literal substring (regex chars escaped) to avoid catastrophic-backtracking attacks from LLM-supplied input. Honors the project dir's existing path-safety guards via listFiles. * feat(daemon): add since= filter to /files route + MCP list_files arg Lets a consumer poll for "what's changed since I last looked" without re-walking every file. Daemon-side: parse since= as ms, filter listFiles output by mtime. MCP-side: forward as URL query. * feat(daemon): expose skills and design systems as MCP resources Catalog reads are stable reference material — they fit MCP's resources surface (LLM-passive) better than tools (LLM-active). Skills and design systems each become resources at od://skills/<id>/SKILL.md and od://design-systems/<id>/DESIGN.md; existing list_skills / get_skill / list_design_systems / get_design_system tools remain as fallbacks for clients that don't handle resources cleanly. * fix(daemon): tighten MCP correctness in get_artifact and resources Several silent-failure paths and minor footguns the first pass missed: - get_artifact auto: the entry's own fetch now raises a clear error instead of returning files: []. Previously a typo in `entry:` looked like an empty project. - get_artifact: invalid `include` value returns a clear error listing the valid modes instead of silently behaving as auto. - get_artifact all: includes binary files as metadata stubs to match auto's behavior. Both modes are now strict supersets of shallow. - extractRelativeRefs: gate JS-only patterns (import/from/require/ dynamic-import) by file mime/extension so prose in markdown or HTML doesn't generate spurious 404 round-trips on words like "imported from 'X'". - extractRelativeRefs: cover <iframe>, <audio>, srcset, and CSS @import — common in real OD output. - resources/list descriptions are collapsed to a single line (newlines + repeated whitespace -> one space) so MCP UIs that don't normalize whitespace render cleanly. - fetchProjectFile: 0-byte binary files no longer report size: null due to falsy short-circuit on Number(content-length). * perf(daemon): cache MCP project list for 5s in resolveProjectId A typical agent session calls list_files/get_file/get_artifact several times in a row, each with a project name argument. Each previously re-fetched /api/projects. Cache the list in module scope with a 5s TTL so back-to-back lookups are local; renames in the OD UI still propagate within a few seconds. * feat(daemon): MCP UX polish — tool order, annotations, get_artifact maxBytes Three changes well-behaved MCP clients pick up automatically: - Tool ordering. list_projects + get_artifact are now first; LLMs that weight earlier entries surface the bundle path before per-file fetching. Catalog tools (list_skills, get_skill, list_design_systems, get_design_system) sit at the bottom; they are also exposed as MCP resources. - readOnlyHint / idempotentHint / openWorldHint annotations on every tool so clients can skip confirmation prompts on safe tools and let the LLM know re-running is fine. Per-tool `title` annotations give clients a friendlier display name than the snake_case tool id. - get_artifact gains a `maxBytes` arg (default 1.5MB). Once the accumulated textual content crosses the cap, remaining files are dropped and `truncated: true` is set on the bundle so the consumer knows to use list_files / get_file for the rest. * feat(daemon): expose user's active OD project/file via MCP The "what file are you on?" round-trip the agent had to do every session is now answered automatically. Three pieces: - Daemon: in-memory active-context slot with 5-minute TTL. POST /api/active sets {projectId, fileName}; GET /api/active returns the current value enriched with projectName, or {active:false} when the slot is empty/stale. Cleared on daemon restart. - Web: a small useEffect in App.tsx posts the active project + file to the daemon on every route change. Best-effort fire- and-forget; a missing daemon doesn't surface an error. - MCP: get_active_context tool (no args) and a matching MCP resource at od://focus/active. The tool is listed second, right after list_projects, so an LLM picks it up before asking for ids. Server instructions tell the model to call it FIRST when the user says "this file" / "the design I have open" / "what I'm looking at." End to end: user opens a project in OD, agent in another repo calls get_active_context() → gets {projectName: "recaptr", fileName: "recaptr-onboarding-4.html"}, then immediately calls get_artifact(project: "recaptr") with no further user input. * feat(daemon): make MCP project arg optional, fall back to active OD context get_artifact, get_project, get_file, search_files, and list_files now accept project as optional. When omitted, the MCP resolves project from /api/active so an agent in another repo can call search_files({ query: "Polaroid" }) without first asking the user "which project?". get_file and get_artifact also default their path/entry to the active file, so get_file({}) returns whatever the user is currently looking at. The implicit path stamps `usedActiveContext` on JSON responses (or a separate `[od:active-context …]` content block on get_file) so the agent can see exactly which project/file got chosen. Explicit project args pass through with zero added overhead. Cuts the common case from two MCP round trips (get_active_context → search_files) to one. Server instructions and get_active_context's own description are updated to point at the new default. * fix(daemon): require same-origin for /api/active POST and GET The active-context endpoint was added without isLocalSameOrigin guard. Since the daemon binds 0.0.0.0 by default, a LAN peer could GET it to learn what file the user has open, or POST it to redirect the MCP fallback to a project of their choice. Same-origin only is the right scope: the web app proxies its requests through Next.js on the daemon port, and the MCP runs over loopback in-process, so both legitimate callers pass. Pattern matches the existing /api/app-config etc. guards. * feat(daemon): add /api/mcp/install-info for cross-platform install snippets The Settings -> MCP server panel needs absolute paths to node and the daemon's built cli.js so it can render snippets that work on a fresh source clone (where `od` is not on PATH) and dodge the /usr/bin/od octal-dump tool that ships on macOS/Linux and would otherwise shadow ours. Endpoint returns: - command: process.execPath (the node binary running the daemon) - args: [<absolute path to dist/cli.js>, "mcp"] - daemonUrl: http://127.0.0.1:<port> - platform: process.platform (so the panel can localize ~/.cursor vs %USERPROFILE%\.cursor and Cmd vs Ctrl shortcuts) - cliExists / nodeExists: existsSync checks on both binaries - buildHint: human-readable build/reinstall instructions when either path is missing isLocalSameOrigin guard same as /api/active. Cached for 5s because the panel may re-fetch on every open and the paths cannot change without a daemon restart. Test file covers the happy path, cross-origin rejection, two allowed-Origin variants, and the cache by counting fresh resolves across rapid calls. 5/5 pass. * refactor(daemon): tighten MCP surface, trim descriptions, polish copy Three intertwined cleanups that all live in mcp.ts + cli.ts: 1. Drop catalog tools from MCP. list_skills / get_skill / list_design_systems / get_design_system are removed. The audience is a coding agent in a separate repo consuming Open Design's output; it cannot run skills (those are recipes Open Design uses to generate) and design-system DESIGN.md is reference material that already ships as an MCP resource. Keeping the catalog as tools cost ~350 token-overhead per turn for capabilities the agent could not act on. Tool count: 11 -> 7. 2. Trim tool descriptions. The active-context fallback explanation was repeated in 5 separate tool descriptions; hoisted into PROJECT_ARG and explained once in the server `instructions` block instead. Saves ~150-200 tokens per tools/list response. 3. User-facing branding pass. Tool titles, tool descriptions, resource names, error messages, comments, and `od mcp --help` now consistently use "Open Design" rather than "OD". Internal abbreviation `OD` is retained only inside the server instructions block where it is introduced inline as "Open Design (OD)" for compactness across multi-paragraph guidance. Em dashes replaced with hyphens throughout, per project style. * feat(web): add MCP server install panel in Settings New "MCP server" section in the Settings dialog, surfacing copy-paste install snippets for the major MCP-compatible coding agents (Claude Code, Cursor, VS Code, Antigravity, Zed, Windsurf). Highlights: - In-brand custom dropdown (reuses the existing .ds-picker pattern from the design-system / prompt-template pickers, click outside / Escape to close, chevron animates) instead of a native <select>. - Per-client snippet that uses absolute paths to node + cli.js fetched from /api/mcp/install-info on mount, so it works even when `od` is not on PATH. - Cursor gets a one-click "Install in Cursor" deeplink (cursor://anysphere.cursor-deeplink/mcp/install) that pops an approval dialog and writes the config for the user. UTF-8-safe base64 so paths with accented characters do not throw. - Per-OS path hints (~/.cursor on POSIX, %USERPROFILE%\.cursor on Windows) and keyboard shortcuts (Cmd vs Ctrl). - Build-required warning card when cli.js or the node binary does not exist on disk; deeplink button disables in that state. - Prominent "restart your client to pick up the new server" callout below the snippet, with per-client guidance. - Capability list ("what your agent can do") instead of a tool- name dump, so non-developer designers can also tell what is possible without reading MCP docs. README adds a short "Use Open Design from your coding agent" section that points at the panel and summarizes the per-client flow (one-click for Cursor, JSON merge elsewhere). Read-only by design; the daemon must be running locally. * docs(readme): align MCP server section with the Settings panel The "Use Open Design from your coding agent" section had drifted from what the panel actually emits and lists. - Add Antigravity to the supported-client list (previously missing). - Drop the "(GitHub Copilot)" parenthetical from VS Code so the label matches the panel. - Fix the Claude Code line: we no longer emit a single `claude mcp add ...` shell command. The snippet is JSON; the panel additionally suggests `claude mcp add-json` as the safer way to apply it instead of hand-editing ~/.claude.json. - Swap the "find the Polaroid section" example for two more universal phrases ("build this in my app", "match these styles") that match what the panel surfaces. - Add a one-line "restart or reload your client after install" note - this was prominent in the panel and absent from the README. - Trim the /usr/bin/od octal-dump aside; it was technical detail that did not earn its space at the README intro level. * feat(web): add Codex CLI to the MCP server install panel Codex is a first-class supported coding agent (listed alongside Claude Code, Cursor, etc. in the README's PATH-detected agent table) but the install panel was missing it. Codex stores MCP server config at ~/.codex/config.toml (TOML, not JSON) under an `[mcp_servers.<name>]` table, and the same file is shared between the Codex CLI and the Codex IDE extension - so one install covers both. Added a 7th client entry that emits the right TOML snippet, expanded the snippet-lang union to include 'toml' (behaves like 'json' for whitespace handling, just a different syntax-highlight hint). For our minimal payload (just command + args), JSON.stringify happens to produce valid TOML literal values since TOML basic strings use the same double-quote escape rules as JSON, and TOML inline arrays match JSON array syntax. No new TOML serializer needed. README updated to list Codex among the supported clients. Schema verified against https://developers.openai.com/codex/mcp. * fix(daemon): accept any loopback origin in same-origin guard The previous port-pinned check required the request's Origin to match either the daemon's own port or OD_WEB_PORT. tools-dev does not pass OD_WEB_PORT to the daemon process, so any browser POST to /api/active proxied through the dev web (port 17573 etc.) was rejected with 403, and get_active_context always returned {active: false}. Relax to a loopback-prefix match: any http://127.0.0.1:*, http://localhost:*, or http://[::1]:* origin passes regardless of port. Cross-origin (https://evil.com) is still rejected. The trade-off is that another local web app on a different loopback port could now CSRF the daemon; same-origin checks are inherently a CSRF defense, not a network ACL. * fix(web): make Claude Code MCP snippet a real copyable one-liner claude mcp add-json open-design '<json>' takes only the inner server-config object, not the full {"mcpServers": ...} wrapper, and rejected the wrapped shape with "Invalid configuration: : Invalid input". Pass only the inner config, and inline the JSON into the command itself so the snippet is a real one-liner the user can copy and paste, no template substitution. * test(daemon): drop loopback-prefix assertions superseded by upstream origin policy The two proxy-flow allow tests were added in ae13094 to cover our relaxed isLocalSameOrigin. Main's port-pinned implementation (from nexu-io#365) now handles the dev-flow via the web sidecar proxy origin rewrite (#71354f2), making the relaxation -- and these tests -- unnecessary. Also replace the inline LOOPBACK_*_RE / isLocalSameOrigin replica in mcp-install-info.test.ts with a direct import from server.ts so both test files stay in sync with the production guard automatically. * fix(daemon): bake daemon URL into MCP install-info args The install panel snippet previously emitted `od mcp` with no daemon URL, so the MCP server always fell back to the hardcoded default port 7456. When tools-dev starts the daemon on a non-default port the snippet silently targets the wrong daemon. Fix: include --daemon-url http://127.0.0.1:<port> as the third arg so the generated snippet is always tied to the running daemon's actual port. Update the matching mini-app and assertion in the install-info test. * fix(daemon): address MCP reviewer feedback - extractRelativeRefs: replace blanket `includes('..')` rejection with proper POSIX-style path normalization. `../tokens.css` in a nested project layout now resolves to `tokens.css` instead of being silently dropped. - getArtifact: add MAX_FILES=200 cap to BFS auto and include=all modes. Pass `remainingBytes` to fetchProjectFile so it can bail early when the server-advertised content-length would already exceed the budget. - resolveProjectId: return {id, name, source} instead of a bare id. Callers echo `resolvedProject` in the response when the match was by slug or substring, letting the agent confirm which project was chosen without an extra round-trip. - getFile: thread `resolved` through so substring matches surface the same `[od:resolved-project ...]` annotation. - @ts-nocheck: add a comment explaining the Zod-vs-JSON-Schema SDK mismatch so future contributors don't remove it accidentally. - get_active_context description: note the ~5-minute cache TTL. * test(daemon): restore @ts-nocheck on mcp-install-info test Dropped accidentally when replacing the import header. The directive suppresses expected test-file noise (baseUrl pre-assignment and res.json() unknown return type); keeping it avoids littering the test body with `as any` casts for zero real safety benefit. * docs(readme): expand MCP section with why-MCP, security model, and recovery note - Soften "No zip export, no copy-paste" to "Replaces the export-then-attach loop" per reviewer feedback. - Add "Why MCP?" paragraph explaining the structured-API benefit over zip exports. - Add daemon-not-running recovery note (clear error, not a crash; start with pnpm tools-dev and retry). - Add security model callout: read-only, loopback-only, Host/Origin guard rejects non-loopback requests. * docs: complete security model and daemon recovery notes for MCP section 8.3: Expand README security model to include stdio child process context, trust framing (treat like a VS Code extension), and OD_BIND_HOST opt-in for LAN exposure. 8.4: Replace terse "daemon not running" note in README with a full recovery sentence covering the start-agent-before-Open-Design case. Add the same recovery note as a footer paragraph in IntegrationsSection so users see it in the Settings panel without needing to read the README. * fix(daemon): pass resolved through get_artifact so substring matches echo resolvedProject * feat(daemon): add MCP unit tests and fill description/instructions gaps - Export extractRelativeRefs, resolveProjectId, resolveProjectArg, withActiveEcho, fetchProjectFile, getArtifact for testing - mcp-extract-refs.test.ts: 10 cases covering flat, nested, deep, escape attempts, external/data/anchor/mailto URLs, srcset - mcp-get-artifact.test.ts: MAX_FILES=200 cap, maxBytes cap, per-file content-length pre-check via fetchProjectFile - mcp-resolve-project.test.ts: uuid/exact/slug/substring source values, ambiguity error, withActiveEcho resolvedProject stamping - get_artifact maxBytes description now mentions the 200-file cap - Instructions block now mentions resolvedProject field and when it appears (slug or substring match) * docs(daemon): document MCP active-context TTL and surface wake-up hint Address PR nexu-io#399 review item P2.5 (active-context TTL undocumented) plus the related UX gap where the agent had no way to tell the user that clicking around in Open Design refreshes the cache. - PROJECT_ARG, get_artifact entry, get_file path: append TTL note to argument descriptions so agents see the ~5-minute fallback window. - get_active_context: when /api/active reports active:false, return an explicit hint string explaining the recovery action ("ask the user to click into a project") instead of a bare {active:false} the agent can't act on. - get_active_context tool description: mention the new hint payload. - resolveProjectArg error: extend the missing-active-context message with the same TTL + recovery wording for tool calls that omit project= and have no fallback. * feat(daemon): add offset/limit pagination to MCP get_file Real-world MCP usage hit a wall on large files: get_file returned the full body, the agent decided the result was too large for its context budget, and recovered by spawning a sub-agent that ran Python with manual brace-matching for several minutes. That defeats the value proposition of skipping zip-export. Mirror Claude Code's Read tool semantics: get_file now accepts optional offset (0-indexed line) and limit (default 2000) args, slices the file in mcp.ts after fetching from the daemon, and stamps an [od:file-window offset=.. returnedLines=.. totalLines=..] marker on sliced or truncated responses so the agent can page by re-calling with the next offset. - Tool definition: add offset/limit args, expand description. - getFile helper: line-split, slice, marker, range clamp at EOF. - Instructions block: mention pagination in the get_file bullet. - Binary rejection unchanged. - New tests in mcp-get-file.test.ts cover default behavior, limit truncation, mid-file offset, offset past EOF, and binary rejection. * fix(daemon): set truncated: true when per-file content-length pre-check fires When fetchProjectFile throws because a file's advertised content-length exceeds the remaining byte budget, both the include=all loop and the auto BFS loop silently skipped the file without setting truncated: true. The bundle could then report truncated: false even though files were dropped. Introduce BudgetExceededError as a sentinel so callers can distinguish a budget rejection (truncated: true) from a genuine fetch failure (404, network) that should just be skipped. Both getArtifact call sites now check instanceof BudgetExceededError and set truncated accordingly. Adds a regression test: 5 files of 250 bytes with explicit content-length, maxBytes=400. Only file 0 fits; files 1-4 each exceed the remaining 150 bytes. totalTextBytes never reaches maxBytes, so only the new path sets truncated=true. Previously the bundle reported truncated: false.
I didn't open a discussion first; sorry. CONTRIBUTING says I should have on a non-trivial PR. It's one concern (the MCP server) but it lands across daemon + web because the install UI and active-context bridge belong to that concern. Happy to split it if you'd rather take it in pieces.
What it does
Adds
od mcp, a stdio MCP server. Coding agents in other repos (Claude Code, Codex, Cursor, VS Code, Antigravity, Zed, Windsurf) can read files from local Open Design projects directly, including the project the user has open in the Open Design app right now.Why MCP
Three alternatives came up in review:
LSP -- LSP is the right protocol for editor integrations (hover, go-to-definition, rename). Coding agents like Claude Code, Codex, and Cursor don't speak LSP; they speak MCP. The protocols solve different problems.
Direct filesystem access -- Agents can read raw files if they know the path, but they don't know which file is the entry point, which tokens CSS it imports, what the active project is, or which file the user is currently looking at. The daemon owns that context; the filesystem does not.
Document the HTTP API -- The daemon's HTTP API is already there, but calling it from an external repo requires the agent to know the daemon URL and port and to write custom tool-use glue per agent. MCP is the standard way to give agents tools. One stdio server works with every client that speaks the spec.
The active-context fallback is also specific to the daemon: it knows what the user is looking at right now. A bare HTTP endpoint could expose that data, but the agent would have no convention for calling it -- MCP resources and tool defaults are the right hook for that pattern.
Tools
list_projects()-- lists everything on the daemon.get_project(project)-- returns metadata (name, kind, entry file).list_files(project)-- returns file metadata (paths, sizes, mtimes).get_file(project, path, offset, limit)-- reads a single file. Returns up to 2000 lines starting atoffset(default 0) and stamps an[od:file-window]marker when the file is longer, so the agent can page by re-calling with the next offset.get_artifact(project)-- returns the entry file plus the sibling files it references (tokens CSS, component JSX, imported modules) in one call. Bounded by a per-filecontent-lengthpre-check and a 200-file cap.search_files(project, query)-- substring search across project files.get_active_context()-- returns whatever project and file are open in the Open Design app. Returns{active: false, hint: "..."}with recovery instructions when no project is active, so the agent can prompt the user rather than failing silently.Project arguments accept UUID, exact name, slug, or substring. Slug and substring matches echo
resolvedProject: {id, name}on the response. All tools are read-only.Active context
The web posts
{projectId, fileName}to/api/activeon every route change. When an MCP tool is called without aprojectargument, the daemon falls back to that. In-memory, 5-minute TTL, cleared on daemon restart. Tool descriptions document the TTL so agents know to expect stale results after inactivity.Security model
The MCP server is read-only. It exposes file reads, file metadata, and search. It runs as a child of the coding agent over stdio, so any MCP client a user installs inherits read access to local Open Design projects. Treat this like installing a VS Code extension: only register MCP clients you trust.
The daemon binds to
127.0.0.1by default (since #365). LAN exposure requires an explicitOD_BIND_HOSTopt-in, which applies to all daemon routes, not just the MCP endpoints./api/activeusesisLocalSameOrigin(also from #365) to reject requests whoseHostheader does not match a known loopback+port combination.Install UI
Settings -> MCP server in the Open Design app. Pick a client, copy the snippet. Snippets bake in
--daemon-url http://127.0.0.1:<resolvedPort>so the agent hits the right port even when tools-dev uses a non-default port.Recovery
The daemon must be running locally for MCP tool calls to succeed. If you start your coding agent before Open Design, restart the agent after Open Design is up so it picks up the live daemon.
New dependency
@modelcontextprotocol/sdk(Apache-2.0). Official MCP server SDK. TypeScript types are suppressed with@ts-nocheckinmcp.tsbecause the SDK'ssetRequestHandlerinfers types from Zod schemas and we pass plain JSON Schema objects; the runtime contract is identical. The comment at line 1 explains this.Rebased on main
This branch was rebased onto
origin/mainat653c506. Three conflicts resolved:apps/daemon/package.json-- took main'sworkspace:0.3.0versions, kept our@modelcontextprotocol/sdkdep.apps/daemon/src/server.ts-- took main'sisLocalSameOriginandrewriteSkillAssetUrlsverbatim, dropped our loopback-prefix relaxation fromae13094.pnpm-lock.yaml-- took main's version, re-added the SDK viapnpm install.Changes since first round of review
127.0.0.1via fix(security): bind daemon to localhost by default, add origin validation #365. Our loopback-prefix relaxation fromae13094is dropped;isLocalSameOriginfrom fix(security): bind daemon to localhost by default, add origin validation #365 is in verbatim.resolvedProject: {id, name}on every response.get_artifactnow has aMAX_FILES = 200cap and a per-filecontent-lengthpre-check.PROJECT_ARG,entryarg,patharg, andget_active_contextdescription.get_active_contextalso returns ahintfield when no project is active.mcp.tsexplaining the SDK Zod/JSON Schema mismatch.--daemon-url http://127.0.0.1:<resolvedPort>..includes('..')rejection with a normalize-and-bound traversal. New unit tests cover the nested case and escape rejection.offset/limitargs toget_fileso agents can page through large files. Defaults match Claude Code'sReadtool (offset 0, limit 2000).Tested
pnpm typecheck,pnpm test(398 tests across 29 files),pnpm build, andpnpm check:residual-jsall pass. New unit tests:extractRelativeRefs(10 cases),resolveProjectId+withActiveEcho(9 cases),getArtifactcaps (4 cases),fetchProjectFilepre-check (2 cases),getFilepagination (5 cases).Manual smoke: Cursor deeplink and Claude Code
add-jsonone-liner on macOS. Confirmed active-context fallback, substring resolution echo, and get_file pagination.Open to feedback on scope, approach, or anything that should be cut. Happy to iterate.