Skip to content

feat(daemon): add od mcp - expose Open Design as an MCP server - #399

Merged
lefarcen merged 33 commits into
nexu-io:mainfrom
emilneander:feat/od-mcp-subcommand
May 4, 2026
Merged

feat(daemon): add od mcp - expose Open Design as an MCP server#399
lefarcen merged 33 commits into
nexu-io:mainfrom
emilneander:feat/od-mcp-subcommand

Conversation

@emilneander

@emilneander emilneander commented May 3, 2026

Copy link
Copy Markdown
Contributor

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 at offset (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-file content-length pre-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/active on every route change. When an MCP tool is called without a project argument, 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.1 by default (since #365). LAN exposure requires an explicit OD_BIND_HOST opt-in, which applies to all daemon routes, not just the MCP endpoints. /api/active uses isLocalSameOrigin (also from #365) to reject requests whose Host header 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-nocheck in mcp.ts because the SDK's setRequestHandler infers 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/main at 653c506. Three conflicts resolved:

  • apps/daemon/package.json -- took main's workspace:0.3.0 versions, kept our @modelcontextprotocol/sdk dep.
  • apps/daemon/src/server.ts -- took main's isLocalSameOrigin and rewriteSkillAssetUrls verbatim, dropped our loopback-prefix relaxation from ae13094.
  • pnpm-lock.yaml -- took main's version, re-added the SDK via pnpm install.

Changes since first round of review

  • P1.1 (0.0.0.0 binding): Resolved by merge. Daemon now defaults to 127.0.0.1 via fix(security): bind daemon to localhost by default, add origin validation #365. Our loopback-prefix relaxation from ae13094 is dropped; isLocalSameOrigin from fix(security): bind daemon to localhost by default, add origin validation #365 is in verbatim.
  • P1.2 (security model): New Security model section in README and this PR body.
  • P1.3 (substring resolution risk): Kept substring matching. Slug and substring matches now echo resolvedProject: {id, name} on every response.
  • P1.4 (unbounded artifact size): get_artifact now has a MAX_FILES = 200 cap and a per-file content-length pre-check.
  • P2.5 (TTL undocumented): TTL note added to PROJECT_ARG, entry arg, path arg, and get_active_context description. get_active_context also returns a hint field when no project is active.
  • P2.6 (recovery path): Recovery note added to README and the Settings panel copy.
  • P2.7 (why MCP): New Why MCP section in README and this PR body.
  • P2.8 (threat model): New Security model section in README and this PR body.
  • P3.9 (no zip export claim): Softened in README.
  • P3.10 (@ts-nocheck undocumented): Added a 6-line comment block in mcp.ts explaining the SDK Zod/JSON Schema mismatch.
  • Codex P1 (install-info args): Snippets now bake in --daemon-url http://127.0.0.1:<resolvedPort>.
  • Codex P2 (parent-relative refs): Replaced .includes('..') rejection with a normalize-and-bound traversal. New unit tests cover the nested case and escape rejection.
  • Extra (get_file pagination): Added offset/limit args to get_file so agents can page through large files. Defaults match Claude Code's Read tool (offset 0, limit 2000).

Tested

pnpm typecheck, pnpm test (398 tests across 29 files), pnpm build, and pnpm check:residual-js all pass. New unit tests: extractRelativeRefs (10 cases), resolveProjectId + withActiveEcho (9 cases), getArtifact caps (4 cases), fetchProjectFile pre-check (2 cases), getFile pagination (5 cases).

Manual smoke: Cursor deeplink and Claude Code add-json one-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.

emilneander added 21 commits May 3, 2026 15:27
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.
@lefarcen

lefarcen commented May 3, 2026

Copy link
Copy Markdown
Contributor

Hi @emilneander! 🎉
Thanks for the contribution — this MCP server integration looks like a well-thought-out addition for coding agent workflows.
I will run a deep review and get back to you within 24h.

Thanks for making open-design better!
— open-design team

@lefarcen lefarcen added the feature New feature or enhancement label May 3, 2026
@emilneander
emilneander marked this pull request as draft May 3, 2026 20:38
@emilneander

Copy link
Copy Markdown
Contributor Author

Will fix merge conflict -> switched to draft

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/daemon/src/server.ts Outdated
Comment on lines +755 to +756
args: [cliPath, 'mcp'],
daemonUrl: `http://127.0.0.1:${resolvedPort}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread apps/daemon/src/mcp.ts Outdated
: '';
const resolved = raw.startsWith('/') ? raw.slice(1) : dir + raw;
const clean = resolved.replace(/[?#].*$/, '').replace(/^\.\//, '');
if (!clean || clean.includes('..')) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 lefarcen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.0 by 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 Host header check only validates 127.0.0.1|localhost|[::1], but the request can come from 192.168.x.x or 10.x.x.x when 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_file without 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-projects if 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/active before 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

Comment thread apps/daemon/src/server.ts
// callers pass the check.
app.post('/api/active', (req, res) => {
if (!isLocalSameOrigin(req, resolvedPort)) {
return res.status(403).json({ error: 'cross-origin request rejected' });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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).

Comment thread apps/daemon/src/mcp.ts
const opts = subs.map((p) => `${p.name} (${p.id})`).join(', ');
throw new Error(
`multiple projects match "${arg}": ${opts}. Pass the UUID instead.`,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ P1: Substring project matching is ambiguous. If user has "recapture-client" and "recapture-internal", 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.

Comment thread apps/daemon/src/mcp.ts
: `${note} (active file: ${active.fileName ?? 'none'})`;
}

const VALID_INCLUDE_MODES = new Set(['auto', 'all', 'shallow']);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ P1: 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.

Comment thread apps/daemon/src/mcp.ts
@@ -0,0 +1,854 @@
// @ts-nocheck

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: @ts-nocheck without explanation. Is MCP SDK's type defs broken? Document why or remove and fix types.

Comment thread README.md Outdated

## 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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."

@Naassoonaguiarr Naassoonaguiarr left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legal mano

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.
- 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.
@emilneander

Copy link
Copy Markdown
Contributor Author

@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 127.0.0.1 (your #365) and isLocalSameOrigin rejects unknown Hosts. The remaining LAN exposure window is an explicit OD_BIND_HOST opt-in, which has always applied to all routes. /api/active is not special-cased.

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 resolvedProject: {id, name} echo on slug and substring matches so the agent sees what was resolved. UUID and exact matches don't echo.

P1.4 -- get_artifact now has a MAX_FILES = 200 hard cap and a per-file content-length pre-check that skips the body read when a file would exceed the remaining byte budget. Bundle response carries truncated: true when either cap is hit.

P2.5 -- TTL note added to the project arg description (shared across all tools), the entry and path arg descriptions, and the get_active_context description. get_active_context also returns {active: false, hint: "..."} with a recovery note when the cache is stale, so the agent can prompt the user to interact with Open Design rather than failing silently.

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 mcp.ts explaining the SDK Zod/JSON Schema mismatch. Removing @ts-nocheck entirely would require migrating tool definitions to Zod; filed that as a follow-up.

@emilneander

Copy link
Copy Markdown
Contributor Author

@chatgpt-codex-connector

P1 (install-info args): fixed. The install snippet now bakes --daemon-url http://127.0.0.1:<resolvedPort> into the args so the agent always hits the right port. Test updated to assert the full args array.

P2 (parent-relative refs): fixed. Replaced the .includes('..') rejection with a normalize-and-bound path traversal that resolves ../ segments correctly and only rejects refs that escape the project root. New unit tests in mcp-extract-refs.test.ts cover flat refs, parent-relative refs, deeply nested refs, escape attempts, and srcset entries.

@emilneander

Copy link
Copy Markdown
Contributor Author

@Naassoonaguiarr Thanks!

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@emilneander
emilneander marked this pull request as ready for review May 4, 2026 11:05

@lefarcen lefarcen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 explicit OD_BIND_HOST opt-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 = 200 hard cap added (mcp.ts:635) + pre-check on content-length to enforce maxBytes during fetch.

Design (P2)

  • ✅ P2.5: TTL (5 minutes) documented in tool arg descriptions + get_active_context returns {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-nocheck now 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 lefarcen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 explicit OD_BIND_HOST opt-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 = 200 hard cap added (mcp.ts:635) + pre-check on content-length to enforce maxBytes during fetch.

Design (P2)

  • ✅ P2.5: TTL (5 minutes) documented in tool arg descriptions + get_active_context returns {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-nocheck now 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/daemon/src/server.ts
// 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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread apps/daemon/src/mcp.ts
(r) => !visited.has(r),
);
}
outer: for (let depth = 1; depth < MAX_DEPTH && frontier.length > 0; depth++) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 mrcfps left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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

Comment thread apps/daemon/src/mcp.ts Outdated
try {
const remaining = maxBytes - totalTextBytes(fetched);
fetched.push(await fetchProjectFile(baseUrl, id, f.name, remaining));
} catch {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. 🙂

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@lefarcen
lefarcen dismissed Naassoonaguiarr’s stale review May 4, 2026 14:33

Dismissed: review appears to be non-technical feedback.

@lefarcen lefarcen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved.

@lefarcen
lefarcen merged commit 33c3b94 into nexu-io:main May 4, 2026
@lefarcen

lefarcen commented May 4, 2026

Copy link
Copy Markdown
Contributor

Merged! 🎉 Thanks @emilneander for the contribution to open-design. Looking forward to the next one!

kuku-work added a commit to kuku-work/raccoonui that referenced this pull request May 5, 2026
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.
lefarcen added a commit that referenced this pull request May 5, 2026
- 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>
lefarcen added a commit that referenced this pull request May 5, 2026
- 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>
lefarcen added a commit that referenced this pull request May 5, 2026
- 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>
diginex-jeff-li pushed a commit to diginex-jeff-li/open-design that referenced this pull request May 30, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants