mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(daemon): add od mcp - expose Open Design as an MCP server (#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
#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.
* 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 #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.
This commit is contained in:
parent
02d24c4fde
commit
33c3b94b42
15 changed files with 2733 additions and 52 deletions
12
README.md
12
README.md
|
|
@ -341,6 +341,18 @@ The daemon owns one hidden folder at the repo root. Everything in it is gitignor
|
|||
|
||||
Full file map, scripts, and troubleshooting → [`QUICKSTART.md`](QUICKSTART.md).
|
||||
|
||||
## 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. Replaces the export-then-attach loop. 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.
|
||||
|
||||
**Why MCP?** Exporting and re-attaching a zip every design iteration breaks flow. The MCP server exposes your design source directly -- tokens CSS, JSX components, entry HTML -- as a structured API the agent can query by name. The agent always sees the live file, not a stale copy from the last export.
|
||||
|
||||
Open **Settings → MCP server** in the Open Design app for a per-client install flow. The panel bakes the absolute path to your `node` binary and the daemon's built `cli.js` into every snippet, so it works on a fresh source clone where `od` is not on your PATH. Cursor gets a one-click deeplink; the rest get a copy-paste JSON snippet in the schema their config file expects (Claude Code includes a `claude mcp add-json` one-liner so you do not have to hand-edit `~/.claude.json`). Restart or reload your client after install for the server to show up.
|
||||
|
||||
The daemon must be running locally for MCP tool calls to succeed. If the agent was started before Open Design, restart the agent after Open Design is up so it can reach the live daemon. Tool calls made while the daemon is offline return a clear `"daemon not reachable"` error rather than a crash.
|
||||
|
||||
**Security model.** The MCP server is read-only; it exposes file reads, file metadata, and search -- nothing that writes to disk or calls an external service. It runs as a child process of the coding agent over stdio, so any MCP client you register inherits read access to your local Open Design projects. Treat it like installing a VS Code extension: only register clients you trust. The daemon binds to `127.0.0.1` by default; LAN-wide exposure requires an explicit `OD_BIND_HOST` opt-in.
|
||||
|
||||
## Repository structure
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.sidecar.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@open-design/contracts": "workspace:0.3.0",
|
||||
"@open-design/platform": "workspace:0.3.0",
|
||||
"@open-design/sidecar": "workspace:0.3.0",
|
||||
|
|
|
|||
|
|
@ -46,8 +46,17 @@ const MEDIA_GENERATE_BOOLEAN_FLAGS = new Set([
|
|||
'h',
|
||||
]);
|
||||
|
||||
const MCP_STRING_FLAGS = new Set([
|
||||
'daemon-url',
|
||||
]);
|
||||
const MCP_BOOLEAN_FLAGS = new Set([
|
||||
'help',
|
||||
'h',
|
||||
]);
|
||||
|
||||
const SUBCOMMAND_MAP = {
|
||||
media: runMedia,
|
||||
mcp: runMcp,
|
||||
};
|
||||
|
||||
const first = argv.find((a) => !a.startsWith('-'));
|
||||
|
|
@ -96,9 +105,16 @@ function printRootHelp() {
|
|||
|
||||
od media generate --surface <image|video|audio> --model <id> [opts]
|
||||
Generate a media artifact and write it into the active project.
|
||||
Designed to be invoked by a code agent — picks up OD_DAEMON_URL
|
||||
Designed to be invoked by a code agent - picks up OD_DAEMON_URL
|
||||
and OD_PROJECT_ID from the env that the daemon injected on spawn.
|
||||
|
||||
od mcp [--daemon-url <url>]
|
||||
Run a stdio MCP server that proxies read-only tool calls to a
|
||||
running Open Design daemon. Wire it into a coding agent
|
||||
(Claude Code, Cursor, VS Code, Zed, Windsurf) in another repo
|
||||
to pull files from a local Open Design project without
|
||||
exporting a zip.
|
||||
|
||||
Options:
|
||||
--port <n> Port to listen on (default: 7456, env: OD_PORT).
|
||||
--host <addr> Interface address to bind to (default: 127.0.0.1, env: OD_BIND_HOST).
|
||||
|
|
@ -345,7 +361,7 @@ function surfaceFetchError(err, daemonUrl) {
|
|||
console.error(
|
||||
'hint: outbound connect was denied by a sandbox. If you launched ' +
|
||||
'this command from a code agent, check the agent\'s sandbox / ' +
|
||||
'network policy. The OD daemon itself is unaffected — it can be ' +
|
||||
'network policy. The Open Design daemon itself is unaffected - it can be ' +
|
||||
'reached from a regular shell.',
|
||||
);
|
||||
}
|
||||
|
|
@ -429,3 +445,64 @@ Skills should call this and then reference the returned filename in their
|
|||
artifact / message body. The daemon writes the bytes into the project's
|
||||
files folder so the FileViewer can preview them immediately.`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subcommand: od mcp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runMcp(args) {
|
||||
let flags;
|
||||
try {
|
||||
flags = parseFlags(args, {
|
||||
string: MCP_STRING_FLAGS,
|
||||
boolean: MCP_BOOLEAN_FLAGS,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err.message);
|
||||
printMcpHelp();
|
||||
process.exit(2);
|
||||
}
|
||||
if (flags.help || flags.h) {
|
||||
printMcpHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const daemonUrl =
|
||||
flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456';
|
||||
|
||||
const { runMcpStdio } = await import('./mcp.js');
|
||||
await runMcpStdio({ daemonUrl });
|
||||
}
|
||||
|
||||
function printMcpHelp() {
|
||||
console.log(`Usage: od mcp [--daemon-url <url>]
|
||||
|
||||
Run a stdio MCP (Model Context Protocol) server that proxies read-only
|
||||
tool calls to a running Open Design daemon. Wire it into a coding agent
|
||||
in another repo so the agent can pull files from a local Open Design
|
||||
project without exporting a zip every iteration.
|
||||
|
||||
Options:
|
||||
--daemon-url <url> Open Design daemon HTTP base URL (default: env
|
||||
OD_DAEMON_URL, falling back to http://127.0.0.1:7456).
|
||||
|
||||
Tools exposed:
|
||||
list_projects list every Open Design project
|
||||
get_active_context what project/file the user has open right now
|
||||
get_artifact([project, entry]) bundle: entry file + every referenced sibling
|
||||
get_project([project]) single project metadata
|
||||
get_file([project, path]) file contents (textual mimes only for now)
|
||||
search_files(query[, project]) literal substring search across textual files
|
||||
list_files([project]) project files + artifactManifest sidecars
|
||||
|
||||
When project is omitted, get_artifact / get_project / get_file /
|
||||
search_files / list_files default to the project the user has open in
|
||||
Open Design; get_artifact and get_file additionally default to the
|
||||
active file. The response stamps usedActiveContext so callers can see
|
||||
which project/file got resolved.
|
||||
|
||||
For the copy-paste, per-client snippet (with absolute paths resolved
|
||||
for your machine, plus a one-click deeplink for Cursor), open Settings
|
||||
→ MCP server in the Open Design app. Read-only by design; the daemon
|
||||
must be running locally for tool calls to succeed.`);
|
||||
}
|
||||
|
|
|
|||
934
apps/daemon/src/mcp.ts
Normal file
934
apps/daemon/src/mcp.ts
Normal file
|
|
@ -0,0 +1,934 @@
|
|||
// @ts-nocheck
|
||||
// TypeScript is suppressed because @modelcontextprotocol/sdk@1.x expects
|
||||
// Zod schemas for tool definitions, but we pass plain JSON Schema objects.
|
||||
// The runtime contract is identical; there is no type-safety regression -
|
||||
// the nocheck just avoids a blanket of incorrect Zod-vs-object type errors
|
||||
// that would obscure real mistakes. Remove once the SDK adds a JSON Schema
|
||||
// overload or we migrate to a Zod-based schema builder.
|
||||
//
|
||||
// `od mcp` - stdio MCP server that proxies read-only tool calls to the
|
||||
// running daemon's HTTP API. Lets a coding agent in a *different* repo
|
||||
// (Claude Code, Cursor, Zed) pull files from a local Open Design
|
||||
// project without the export-zip-import dance.
|
||||
//
|
||||
// The server itself holds no state and never touches the filesystem;
|
||||
// every tool resolves to a fetch() against `OD_DAEMON_URL`. Spawn the
|
||||
// MCP server with no daemon running and tool calls return a clear
|
||||
// "daemon not reachable" error - the server itself still launches so
|
||||
// the client can list its tool schema.
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
const SERVER_NAME = 'open-design';
|
||||
const SERVER_VERSION = '0.2.0';
|
||||
|
||||
// Mimes whose body we surface as MCP `text` content. Everything else
|
||||
// returns a clear error directing the caller at list_files for
|
||||
// metadata, until phase 2 adds binary support.
|
||||
const TEXTUAL_MIME_PATTERNS = [
|
||||
/^text\//i,
|
||||
/^application\/json\b/i,
|
||||
/^application\/javascript\b/i,
|
||||
/^application\/typescript\b/i,
|
||||
/^application\/xml\b/i,
|
||||
/^application\/x-(yaml|toml|httpd-php|sh)\b/i,
|
||||
/\+json\b/i,
|
||||
/\+xml\b/i,
|
||||
/^image\/svg\+xml\b/i,
|
||||
];
|
||||
|
||||
// Every tool here is a read against a local daemon owned by the
|
||||
// current user, so they're all read-only, idempotent, and operate on
|
||||
// a closed (project-scoped) namespace. Pull these into one constant
|
||||
// so each tool def doesn't repeat them.
|
||||
const READ_ANNOTATIONS = {
|
||||
readOnlyHint: true,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
};
|
||||
|
||||
// Description style: short, one purpose-line per tool. Active-context
|
||||
// fallback is documented once in the server `instructions` block, so
|
||||
// per-tool descriptions just say "project optional" and don't repeat
|
||||
// the rationale - that saves ~150 tokens per tools/list response,
|
||||
// shipped to the model on every session.
|
||||
const PROJECT_ARG = {
|
||||
type: 'string',
|
||||
description: 'Project id (UUID) or name substring. Optional; defaults to the active project (expires after ~5 minutes of no Open Design activity).',
|
||||
} as const;
|
||||
|
||||
const TOOL_DEFS = [
|
||||
{
|
||||
name: 'list_projects',
|
||||
description: 'List every Open Design project on this daemon.',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
annotations: { ...READ_ANNOTATIONS, title: 'List Open Design projects' },
|
||||
},
|
||||
{
|
||||
name: 'get_active_context',
|
||||
description:
|
||||
'Project + file the user has open in Open Design right now. Returns {active:false, hint:"..."} when no project is active so the agent can ask the user to interact with Open Design (the active context expires ~5 minutes after the last user interaction). Most tools default to this when project is omitted, so you rarely need to call this directly.',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
annotations: { ...READ_ANNOTATIONS, title: 'What is the user looking at?' },
|
||||
},
|
||||
{
|
||||
name: 'get_artifact',
|
||||
description:
|
||||
'PREFER THIS over multiple get_file calls. Bundles the entry file plus every sibling it references (HTML <script>/<link>/<img>/srcset, JSX import/require, CSS url()/@import) up to depth 3, skipping CDN/data URLs. include="all" returns every file in the project; include="shallow" returns just the entry.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
project: PROJECT_ARG,
|
||||
entry: {
|
||||
type: 'string',
|
||||
description:
|
||||
"Entry file path relative to project root. Defaults to the active file or project's metadata.entryFile. Active-file fallback expires after ~5 minutes of no Open Design activity.",
|
||||
},
|
||||
include: {
|
||||
type: 'string',
|
||||
enum: ['auto', 'all', 'shallow'],
|
||||
description: 'auto (default) | all | shallow',
|
||||
},
|
||||
maxBytes: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Soft cap on total text bytes (default 1_500_000). Also capped at 200 files. Excess files are dropped and truncated:true is set.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: { ...READ_ANNOTATIONS, title: 'Pull design bundle' },
|
||||
},
|
||||
{
|
||||
name: 'get_project',
|
||||
description:
|
||||
'Single project metadata: name, active skill/design-system ids, entryFile, kind, timestamps.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { project: PROJECT_ARG },
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: { ...READ_ANNOTATIONS, title: 'Get Open Design project' },
|
||||
},
|
||||
{
|
||||
name: 'get_file',
|
||||
description:
|
||||
'Read one project file. Text mimes only (HTML, JSX, CSS, JSON, SVG, Markdown). Binary files return an error; use list_files for metadata. Returns up to `limit` lines starting at `offset` (defaults: offset=0, limit=2000), mirroring Claude Code\'s Read tool. For files longer than the slice, the response carries an `[od:file-window ...]` marker with totalLines so you can page by re-calling with the next offset. For multi-file designs prefer get_artifact.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
project: PROJECT_ARG,
|
||||
path: {
|
||||
type: 'string',
|
||||
description:
|
||||
'File path relative to project root, forward slashes. Optional; defaults to the active file when project is also omitted. Active-file fallback expires after ~5 minutes of no Open Design activity.',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: '0-indexed starting line of the slice to return. Defaults to 0.',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of lines to return. Defaults to 2000.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: { ...READ_ANNOTATIONS, title: 'Read project file' },
|
||||
},
|
||||
{
|
||||
name: 'search_files',
|
||||
description:
|
||||
'Case-insensitive literal-substring search across textual files in a project. Returns up to max matches with file, 1-indexed line, and snippet.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
project: PROJECT_ARG,
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Literal substring (not a regex), case-insensitive.',
|
||||
},
|
||||
pattern: {
|
||||
type: 'string',
|
||||
description: 'Optional glob on file name, e.g. "*.jsx".',
|
||||
},
|
||||
max: {
|
||||
type: 'number',
|
||||
description: 'Cap on matches (default 200, hard cap 1000).',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: { ...READ_ANNOTATIONS, title: 'Search project files' },
|
||||
},
|
||||
{
|
||||
name: 'list_files',
|
||||
description:
|
||||
'Project file metadata: name, path, mime, kind, size, mtime, optional artifactManifest. Pass since=<unix-ms> to cheap-poll for changes.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
project: PROJECT_ARG,
|
||||
since: {
|
||||
type: 'number',
|
||||
description: 'Unix-ms; only return files with mtime > since.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: { ...READ_ANNOTATIONS, title: 'List project files' },
|
||||
},
|
||||
// Catalog (skills, design systems) is intentionally NOT exposed as
|
||||
// MCP tools. Skills are recipes that Open Design itself uses to
|
||||
// generate artifacts; an external coding agent consuming Open
|
||||
// Design's output can't run them. Design systems are reference material a
|
||||
// user can opt into via the resource URIs (od://design-systems/...)
|
||||
// when they actually want them, instead of paying tool-description
|
||||
// tokens on every turn.
|
||||
];
|
||||
|
||||
export async function runMcpStdio({ daemonUrl }) {
|
||||
const baseUrl = String(daemonUrl).replace(/\/$/, '');
|
||||
|
||||
const server = new Server(
|
||||
{ name: SERVER_NAME, version: SERVER_VERSION },
|
||||
{
|
||||
capabilities: { tools: {}, resources: {} },
|
||||
instructions: [
|
||||
'Open Design (OD) is a local-first design workspace. The user typically',
|
||||
'has OD running on their machine; each project contains a rendered',
|
||||
'artifact (HTML/JSX/CSS) plus its source files.',
|
||||
'',
|
||||
'Active context: get_artifact, get_project, get_file, search_files,',
|
||||
'and list_files all accept project as OPTIONAL. When omitted, they',
|
||||
'default to the project the user has open in OD right now; get_file',
|
||||
'and get_artifact additionally default to the active file. So when',
|
||||
'the user says "this file" / "the design I have open" / "find X",',
|
||||
'just call the tool without project - no need to ask first. The',
|
||||
'response carries usedActiveContext so you can confirm which',
|
||||
'project/file you hit. Pass project explicitly to override.',
|
||||
'',
|
||||
'Pulling design context:',
|
||||
' - get_artifact() - entry file PLUS every referenced sibling',
|
||||
' (tokens CSS, JSX modules, imported assets) in one call.',
|
||||
' PREFER THIS over multiple get_file calls when the user',
|
||||
' wants to understand or extend a design.',
|
||||
' - get_file(path) for a single known file. Returns up to 2000',
|
||||
' lines starting at offset (default 0) and stamps a',
|
||||
' [od:file-window ...] marker when the file is longer; page',
|
||||
' by re-calling with the next offset.',
|
||||
' - search_files(query) to find a class/component/copy string',
|
||||
' without fetching every file.',
|
||||
' - list_files for metadata only.',
|
||||
' - list_projects to discover what is available on this daemon.',
|
||||
' - get_active_context() if you want the active project/file',
|
||||
' explicitly without making any other tool call.',
|
||||
'',
|
||||
'Project arguments accept either a UUID or a name substring',
|
||||
'(e.g. "recaptr"); the server resolves the latter. When a project',
|
||||
'is matched by slug or substring the response carries',
|
||||
'resolvedProject:{id,name} so you can confirm which project was',
|
||||
'resolved. Verify with the user if the match was unexpected.',
|
||||
'',
|
||||
'Reference material is exposed as MCP resources, not tools - read',
|
||||
'od://design-systems/<id>/DESIGN.md when you need the brand spec',
|
||||
'for a design (palette, typography, voice). Skills are similarly',
|
||||
'available at od://skills/<id>/SKILL.md but are mostly relevant',
|
||||
'when the user asks about how a particular artifact was generated.',
|
||||
'',
|
||||
'When extending an Open Design design in another codebase, pull',
|
||||
'the full bundle once with get_artifact and work from those files',
|
||||
'locally - do not fetch files one-by-one if you can avoid it.',
|
||||
].join('\n'),
|
||||
},
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: TOOL_DEFS,
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||
const [skillsData, dsData] = await Promise.all([
|
||||
getJson(`${baseUrl}/api/skills`).catch(() => ({ skills: [] })),
|
||||
getJson(`${baseUrl}/api/design-systems`).catch(() => ({ designSystems: [] })),
|
||||
]);
|
||||
const resources = [
|
||||
{
|
||||
uri: 'od://focus/active',
|
||||
name: 'Active Open Design context',
|
||||
description: 'The project/file the user has open in Open Design right now.',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
];
|
||||
for (const s of skillsData?.skills || []) {
|
||||
resources.push({
|
||||
uri: `od://skills/${encodeURIComponent(s.id)}/SKILL.md`,
|
||||
name: `Skill: ${s.name || s.id}`,
|
||||
description: oneLine(s.description),
|
||||
mimeType: 'text/markdown',
|
||||
});
|
||||
}
|
||||
for (const d of dsData?.designSystems || []) {
|
||||
resources.push({
|
||||
uri: `od://design-systems/${encodeURIComponent(d.id)}/DESIGN.md`,
|
||||
name: `Design system: ${d.title || d.name || d.id}`,
|
||||
description: oneLine(d.summary),
|
||||
mimeType: 'text/markdown',
|
||||
});
|
||||
}
|
||||
return { resources };
|
||||
});
|
||||
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
||||
const uri = req.params?.uri;
|
||||
if (uri === 'od://focus/active') {
|
||||
const data = await getJson(`${baseUrl}/api/active`);
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const m = String(uri || '').match(/^od:\/\/(skills|design-systems)\/([^/]+)\/(.+)$/);
|
||||
if (!m) {
|
||||
throw new Error(`unsupported resource URI: ${uri}`);
|
||||
}
|
||||
const [, kind, id] = m;
|
||||
const route = kind === 'skills' ? 'skills' : 'design-systems';
|
||||
const data = await getJson(
|
||||
`${baseUrl}/api/${route}/${encodeURIComponent(decodeURIComponent(id))}`,
|
||||
);
|
||||
const text =
|
||||
data?.skill?.body ??
|
||||
data?.skill?.content ??
|
||||
data?.designSystem?.body ??
|
||||
data?.designSystem?.content ??
|
||||
data?.body ??
|
||||
data?.content ??
|
||||
'';
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: 'text/markdown',
|
||||
text,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
||||
const name = req.params?.name;
|
||||
const args = req.params?.arguments ?? {};
|
||||
try {
|
||||
switch (name) {
|
||||
case 'list_projects':
|
||||
return ok(await getJson(`${baseUrl}/api/projects`));
|
||||
case 'get_active_context': {
|
||||
const data = await getJson(`${baseUrl}/api/active`);
|
||||
if (!data || data.active === false) {
|
||||
return ok({
|
||||
active: false,
|
||||
hint: 'Open Design has no active project right now. The active context expires about 5 minutes after the last user interaction with Open Design, so the user may need to click into a project (or switch tabs inside one) to wake it up. Alternatively, pass project="<id-or-name>" to other tools to bypass active context entirely.',
|
||||
});
|
||||
}
|
||||
return ok(data);
|
||||
}
|
||||
case 'get_project': {
|
||||
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
|
||||
const data = await getJson(`${baseUrl}/api/projects/${encodeURIComponent(id)}`);
|
||||
const project = data?.project ?? data;
|
||||
return ok(
|
||||
withActiveEcho(
|
||||
{
|
||||
...project,
|
||||
entryFile: project?.metadata?.entryFile ?? null,
|
||||
kind: project?.metadata?.kind ?? null,
|
||||
},
|
||||
active,
|
||||
resolved,
|
||||
),
|
||||
);
|
||||
}
|
||||
case 'list_files': {
|
||||
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
|
||||
const params = new URLSearchParams();
|
||||
if (Number.isFinite(args.since)) params.set('since', String(args.since));
|
||||
const qs = params.toString();
|
||||
const url = `${baseUrl}/api/projects/${encodeURIComponent(id)}/files${qs ? `?${qs}` : ''}`;
|
||||
return ok(withActiveEcho(await getJson(url), active, resolved));
|
||||
}
|
||||
case 'get_file': {
|
||||
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
|
||||
let path = typeof args.path === 'string' ? args.path : '';
|
||||
// When both project and path are omitted, fall back to the
|
||||
// active file. The agent saying "read this file" without
|
||||
// specifying anything is the most natural call site.
|
||||
if (!path && active && active.fileName) {
|
||||
path = active.fileName;
|
||||
}
|
||||
requireString(path, 'path');
|
||||
const offset = Number.isFinite(args.offset) ? Math.max(0, Math.floor(args.offset)) : 0;
|
||||
const limit = Number.isFinite(args.limit) ? Math.max(1, Math.floor(args.limit)) : 2000;
|
||||
return await getFile(baseUrl, id, path, active, resolved, offset, limit);
|
||||
}
|
||||
case 'get_artifact':
|
||||
return await getArtifact(
|
||||
baseUrl,
|
||||
args.project,
|
||||
args.entry,
|
||||
args.include,
|
||||
args.maxBytes,
|
||||
);
|
||||
case 'search_files': {
|
||||
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
|
||||
requireString(args.query, 'query');
|
||||
const params = new URLSearchParams({ q: String(args.query) });
|
||||
if (args.pattern) params.set('pattern', String(args.pattern));
|
||||
if (args.max) params.set('max', String(args.max));
|
||||
return ok(
|
||||
withActiveEcho(
|
||||
await getJson(
|
||||
`${baseUrl}/api/projects/${encodeURIComponent(id)}/search?${params.toString()}`,
|
||||
),
|
||||
active,
|
||||
resolved,
|
||||
),
|
||||
);
|
||||
}
|
||||
default:
|
||||
return errorResult(`unknown tool: ${name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
return errorResult(formatError(err, baseUrl));
|
||||
}
|
||||
});
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
// server.connect() only *starts* the transport; it resolves once the
|
||||
// stdio reader is wired up, not when the stream closes. Hold the
|
||||
// process open until the client disconnects (stdin EOF) so the cli.ts
|
||||
// top-level `process.exit(0)` doesn't kill us mid-handshake.
|
||||
await new Promise<void>((resolve) => {
|
||||
const done = () => resolve();
|
||||
transport.onclose = done;
|
||||
process.stdin.once('end', done);
|
||||
process.stdin.once('close', done);
|
||||
});
|
||||
}
|
||||
|
||||
function ok(payload) {
|
||||
const text =
|
||||
typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
|
||||
return { content: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
function errorResult(message) {
|
||||
return { isError: true, content: [{ type: 'text', text: message }] };
|
||||
}
|
||||
|
||||
function requireString(v, name) {
|
||||
if (typeof v !== 'string' || v.length === 0) {
|
||||
throw new Error(`${name} is required (string).`);
|
||||
}
|
||||
}
|
||||
|
||||
// Resource description renderers in some MCP UIs collapse whitespace
|
||||
// poorly; keep our descriptions on a single line so they don't break
|
||||
// the catalog list layout.
|
||||
function oneLine(s) {
|
||||
if (typeof s !== 'string') return undefined;
|
||||
return s.replace(/\s+/g, ' ').trim().slice(0, 200) || undefined;
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
// Short-lived cache for the project list. A typical agent session
|
||||
// makes several name-based lookups in quick succession; without this
|
||||
// each one re-fetches /api/projects. The TTL is short so a project
|
||||
// renamed in the Open Design UI shows up within a few seconds.
|
||||
const PROJECT_LIST_TTL_MS = 5000;
|
||||
let projectListCache = null;
|
||||
|
||||
async function fetchProjectList(baseUrl) {
|
||||
const now = Date.now();
|
||||
if (
|
||||
projectListCache &&
|
||||
projectListCache.baseUrl === baseUrl &&
|
||||
now - projectListCache.t < PROJECT_LIST_TTL_MS
|
||||
) {
|
||||
return projectListCache.list;
|
||||
}
|
||||
const data = await getJson(`${baseUrl}/api/projects`);
|
||||
const list = Array.isArray(data?.projects) ? data.projects : [];
|
||||
projectListCache = { baseUrl, t: now, list };
|
||||
return list;
|
||||
}
|
||||
|
||||
// When the agent omits `project`, fall back to whatever the user has
|
||||
// open in Open Design. Returns the resolved id plus, for echo-back to the
|
||||
// caller, the active-context payload that was used. Throws a clear
|
||||
// error when neither is available so the agent can prompt the user
|
||||
// rather than guessing.
|
||||
async function resolveProjectArg(baseUrl, arg) {
|
||||
if (typeof arg === 'string' && arg.length > 0) {
|
||||
const resolved = await resolveProjectId(baseUrl, arg);
|
||||
return { id: resolved.id, resolved, active: null };
|
||||
}
|
||||
let active;
|
||||
try {
|
||||
active = await getJson(`${baseUrl}/api/active`);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`project arg omitted and active context lookup failed: ${err && err.message ? err.message : err}. Pass project="<id-or-name>".`,
|
||||
);
|
||||
}
|
||||
if (!active || active.active === false || !active.projectId) {
|
||||
throw new Error(
|
||||
'project arg omitted and Open Design has no active project. The active context expires about 5 minutes after the last user interaction with Open Design - the user may need to click into a project to wake it up. Otherwise pass project="<id-or-name>".',
|
||||
);
|
||||
}
|
||||
return { id: active.projectId, resolved: null, active };
|
||||
}
|
||||
|
||||
async function resolveProjectId(baseUrl, arg) {
|
||||
if (typeof arg !== 'string' || !arg) {
|
||||
throw new Error('project is required (string).');
|
||||
}
|
||||
if (UUID_RE.test(arg)) return { id: arg, name: arg, source: 'uuid' as const };
|
||||
|
||||
const list = await fetchProjectList(baseUrl);
|
||||
if (list.length === 0) {
|
||||
throw new Error('no projects on this daemon');
|
||||
}
|
||||
|
||||
const lower = arg.toLowerCase();
|
||||
const norm = (s) =>
|
||||
String(s || '')
|
||||
.toLowerCase()
|
||||
.replace(/\s*\(\d+\)\s*$/, '')
|
||||
.replace(/[\s_-]+/g, '-');
|
||||
const target = norm(arg);
|
||||
|
||||
const exact = list.filter((p) => String(p.name || '').toLowerCase() === lower);
|
||||
if (exact.length === 1) return { id: exact[0].id, name: exact[0].name, source: 'exact' as const };
|
||||
|
||||
const slugged = list.filter((p) => norm(p.name) === target);
|
||||
if (slugged.length === 1) return { id: slugged[0].id, name: slugged[0].name, source: 'slug' as const };
|
||||
|
||||
const subs = list.filter((p) =>
|
||||
String(p.name || '').toLowerCase().includes(lower),
|
||||
);
|
||||
if (subs.length === 1) return { id: subs[0].id, name: subs[0].name, source: 'substring' as const };
|
||||
if (subs.length > 1) {
|
||||
const opts = subs.map((p) => `${p.name} (${p.id})`).join(', ');
|
||||
throw new Error(
|
||||
`multiple projects match "${arg}": ${opts}. Pass the UUID instead.`,
|
||||
);
|
||||
}
|
||||
throw new Error(`no project matches "${arg}"`);
|
||||
}
|
||||
|
||||
async function getJson(url) {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
const body = await safeText(resp);
|
||||
throw new Error(`daemon ${resp.status} on ${url}: ${body || resp.statusText}`);
|
||||
}
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
async function getFile(baseUrl, project, relPath, active, resolved?, offset = 0, limit = 2000) {
|
||||
const segments = String(relPath)
|
||||
.split('/')
|
||||
.filter((s) => s.length > 0)
|
||||
.map(encodeURIComponent);
|
||||
const url = `${baseUrl}/api/projects/${encodeURIComponent(project)}/raw/${segments.join('/')}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
const body = await safeText(resp);
|
||||
return errorResult(
|
||||
`daemon ${resp.status} on ${url}: ${body || resp.statusText}`,
|
||||
);
|
||||
}
|
||||
const mime = (resp.headers.get('content-type') || 'application/octet-stream')
|
||||
.split(';')[0]
|
||||
.trim();
|
||||
if (!isTextualMime(mime)) {
|
||||
return errorResult(
|
||||
`file at "${relPath}" has mime "${mime}"; binary content is not yet supported by od mcp. Use list_files to inspect its metadata.`,
|
||||
);
|
||||
}
|
||||
const text = await resp.text();
|
||||
const allLines = text.split('\n');
|
||||
const totalLines = allLines.length;
|
||||
const start = Math.min(offset, totalLines);
|
||||
const slice = allLines.slice(start, start + limit);
|
||||
const returnedLines = slice.length;
|
||||
const truncated = start + returnedLines < totalLines;
|
||||
|
||||
const extra: string[] = [];
|
||||
if (active) extra.push(formatActiveEchoLine(active, relPath));
|
||||
if (resolved && (resolved.source === 'slug' || resolved.source === 'substring')) {
|
||||
extra.push(`[od:resolved-project id="${resolved.id}" name="${resolved.name}" via="${resolved.source}"]`);
|
||||
}
|
||||
if (truncated || start > 0) {
|
||||
const nextOffset = start + returnedLines;
|
||||
const next = truncated ? `; call get_file again with offset=${nextOffset} to read more` : '';
|
||||
extra.push(
|
||||
`[od:file-window offset=${start} returnedLines=${returnedLines} totalLines=${totalLines}${next}]`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
...extra.map((t) => ({ type: 'text', text: t })),
|
||||
{ type: 'text', text: slice.join('\n') },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Stamp `usedActiveContext` onto JSON tool responses when the
|
||||
// project came from /api/active. Plain pass-through when the caller
|
||||
// supplied project explicitly - keeps token overhead at zero for the
|
||||
// explicit path.
|
||||
function withActiveEcho(payload, active, resolved?) {
|
||||
const result = active ? { ...payload, usedActiveContext: activeEchoPayload(active) } : payload;
|
||||
if (resolved && (resolved.source === 'slug' || resolved.source === 'substring')) {
|
||||
return { ...result, resolvedProject: { id: resolved.id, name: resolved.name } };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function activeEchoPayload(active) {
|
||||
return {
|
||||
projectId: active.projectId,
|
||||
projectName: active.projectName ?? null,
|
||||
fileName: active.fileName ?? null,
|
||||
ageMs: active.ageMs ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatActiveEchoLine(active, resolvedPath) {
|
||||
const proj = active.projectName || active.projectId;
|
||||
const note = `[od:active-context project="${proj}" file="${resolvedPath}"]`;
|
||||
return active.fileName === resolvedPath
|
||||
? note
|
||||
: `${note} (active file: ${active.fileName ?? 'none'})`;
|
||||
}
|
||||
|
||||
const VALID_INCLUDE_MODES = new Set(['auto', 'all', 'shallow']);
|
||||
const DEFAULT_MAX_BYTES = 1_500_000;
|
||||
const MAX_FILES = 200;
|
||||
|
||||
// Tracks total textual content bytes accumulated; binary stubs don't
|
||||
// count (their content is null). Once we cross the cap the caller
|
||||
// stops fetching and stamps `truncated: true` on the bundle.
|
||||
function totalTextBytes(files) {
|
||||
let n = 0;
|
||||
for (const f of files) {
|
||||
if (!f.binary && typeof f.content === 'string') n += f.content.length;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
async function getArtifact(baseUrl, projectArg, entryArg, includeMode, maxBytesArg) {
|
||||
const include = includeMode == null || includeMode === '' ? 'auto' : includeMode;
|
||||
if (!VALID_INCLUDE_MODES.has(include)) {
|
||||
return errorResult(
|
||||
`invalid include "${includeMode}"; expected one of: auto, all, shallow`,
|
||||
);
|
||||
}
|
||||
const maxBytes =
|
||||
Number.isFinite(maxBytesArg) && maxBytesArg > 0 ? Number(maxBytesArg) : DEFAULT_MAX_BYTES;
|
||||
|
||||
const { id, active, resolved } = await resolveProjectArg(baseUrl, projectArg);
|
||||
const data = await getJson(`${baseUrl}/api/projects/${encodeURIComponent(id)}`);
|
||||
const project = data?.project ?? data;
|
||||
// Active-file beats project default entry when project also came
|
||||
// from active context - if the user is on landing.html and asks
|
||||
// "bundle this", they mean landing.html, not whatever
|
||||
// metadata.entryFile happens to be.
|
||||
const explicitEntry = typeof entryArg === 'string' && entryArg.length > 0;
|
||||
const entry = explicitEntry
|
||||
? entryArg
|
||||
: (active && active.fileName) || project?.metadata?.entryFile;
|
||||
if (!entry) {
|
||||
return errorResult(
|
||||
`no entry file: pass entry="..." or set the project's metadata.entryFile`,
|
||||
);
|
||||
}
|
||||
|
||||
if (include === 'shallow') {
|
||||
let file;
|
||||
try {
|
||||
file = await fetchProjectFile(baseUrl, id, entry);
|
||||
} catch (err) {
|
||||
return errorResult(err && err.message ? err.message : String(err));
|
||||
}
|
||||
return okBundle({ project, entry, files: [file], truncated: false, active, resolved });
|
||||
}
|
||||
|
||||
if (include === 'all') {
|
||||
const meta = await getJson(`${baseUrl}/api/projects/${encodeURIComponent(id)}/files`);
|
||||
const allFiles = Array.isArray(meta?.files) ? meta.files : [];
|
||||
const fetched = [];
|
||||
let truncated = false;
|
||||
for (const f of allFiles) {
|
||||
if (fetched.length >= MAX_FILES || totalTextBytes(fetched) >= maxBytes) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const remaining = maxBytes - totalTextBytes(fetched);
|
||||
fetched.push(await fetchProjectFile(baseUrl, id, f.name, remaining));
|
||||
} catch (err) {
|
||||
if (err instanceof BudgetExceededError) truncated = true;
|
||||
// Skip files that fail to fetch; keep going.
|
||||
}
|
||||
}
|
||||
return okBundle({ project, entry, files: fetched, truncated, active, resolved });
|
||||
}
|
||||
|
||||
// Auto mode: BFS from entry. The entry's own fetch must succeed -
|
||||
// a 404 there almost always means the agent typo'd `entry:`, and
|
||||
// returning an empty bundle would hide that.
|
||||
let entryFile;
|
||||
try {
|
||||
entryFile = await fetchProjectFile(baseUrl, id, entry);
|
||||
} catch (err) {
|
||||
return errorResult(err && err.message ? err.message : String(err));
|
||||
}
|
||||
const MAX_DEPTH = 3;
|
||||
const visited = new Set([entry]);
|
||||
const fetched = [entryFile];
|
||||
let truncated = false;
|
||||
let frontier = [];
|
||||
if (isTextualMime(entryFile.mime)) {
|
||||
frontier = extractRelativeRefs(entryFile.content || '', entry, entryFile.mime).filter(
|
||||
(r) => !visited.has(r),
|
||||
);
|
||||
}
|
||||
outer: for (let depth = 1; depth < MAX_DEPTH && frontier.length > 0; depth++) {
|
||||
const next = [];
|
||||
for (const refPath of frontier) {
|
||||
if (visited.has(refPath)) continue;
|
||||
visited.add(refPath);
|
||||
if (fetched.length >= MAX_FILES || totalTextBytes(fetched) >= maxBytes) {
|
||||
truncated = true;
|
||||
break outer;
|
||||
}
|
||||
let file;
|
||||
try {
|
||||
const remaining = maxBytes - totalTextBytes(fetched);
|
||||
file = await fetchProjectFile(baseUrl, id, refPath, remaining);
|
||||
} catch (err) {
|
||||
if (err instanceof BudgetExceededError) truncated = true;
|
||||
continue;
|
||||
}
|
||||
fetched.push(file);
|
||||
if (!isTextualMime(file.mime)) continue;
|
||||
const refs = extractRelativeRefs(file.content || '', refPath, file.mime);
|
||||
for (const ref of refs) {
|
||||
if (!visited.has(ref)) next.push(ref);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
return okBundle({ project, entry, files: fetched, truncated, active, resolved });
|
||||
}
|
||||
|
||||
// Thrown by fetchProjectFile when the server-advertised content-length exceeds
|
||||
// the remaining byte budget. Distinguished from generic fetch errors (404,
|
||||
// network) so callers can set truncated: true without treating it as a hard
|
||||
// failure of the whole bundle.
|
||||
class BudgetExceededError extends Error {}
|
||||
|
||||
async function fetchProjectFile(baseUrl, projectId, relPath, remainingBytes = Infinity) {
|
||||
const segments = String(relPath)
|
||||
.split('/')
|
||||
.filter((s) => s.length > 0)
|
||||
.map(encodeURIComponent);
|
||||
const url = `${baseUrl}/api/projects/${encodeURIComponent(projectId)}/raw/${segments.join('/')}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
const body = await safeText(resp);
|
||||
throw new Error(`daemon ${resp.status} on ${url}: ${body || resp.statusText}`);
|
||||
}
|
||||
const mime = (resp.headers.get('content-type') || 'application/octet-stream')
|
||||
.split(';')[0]
|
||||
.trim();
|
||||
const headerSize = Number(resp.headers.get('content-length'));
|
||||
const size = Number.isFinite(headerSize) && headerSize >= 0 ? headerSize : null;
|
||||
if (!isTextualMime(mime)) {
|
||||
return { name: relPath, mime, size, content: null, binary: true };
|
||||
}
|
||||
// If the server advertises a size that already exceeds our remaining
|
||||
// budget, skip reading the body to avoid a large allocation.
|
||||
if (size !== null && size > remainingBytes) {
|
||||
throw new BudgetExceededError(`file ${relPath} (${size} bytes) exceeds remaining budget`);
|
||||
}
|
||||
const content = await resp.text();
|
||||
return { name: relPath, mime, size: size ?? content.length, content, binary: false };
|
||||
}
|
||||
|
||||
// Patterns common to HTML and CSS (also fine to run on plain markdown).
|
||||
const HTML_REF_PATTERNS = [
|
||||
/<script\b[^>]*\bsrc=["']([^"']+)["']/gi,
|
||||
/<link\b[^>]*\bhref=["']([^"']+)["']/gi,
|
||||
/<img\b[^>]*\bsrc=["']([^"']+)["']/gi,
|
||||
/<source\b[^>]*\bsrc=["']([^"']+)["']/gi,
|
||||
/<video\b[^>]*\bsrc=["']([^"']+)["']/gi,
|
||||
/<audio\b[^>]*\bsrc=["']([^"']+)["']/gi,
|
||||
/<iframe\b[^>]*\bsrc=["']([^"']+)["']/gi,
|
||||
];
|
||||
|
||||
const CSS_REF_PATTERNS = [
|
||||
/\burl\(\s*["']?([^"')]+)["']?\s*\)/gi,
|
||||
/@import\s+(?:url\()?\s*["']([^"')]+)["']/gi,
|
||||
];
|
||||
|
||||
// JS/TS only - running these on prose creates false positives on words
|
||||
// like "imported from 'X'".
|
||||
const JS_REF_PATTERNS = [
|
||||
/\bimport\s+[^'"]*?['"]([^'"]+)['"]/g,
|
||||
/\bfrom\s+['"]([^'"]+)['"]/g,
|
||||
/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g,
|
||||
/\brequire\(\s*['"]([^'"]+)['"]\s*\)/g,
|
||||
];
|
||||
|
||||
// `srcset` can list multiple comma-separated candidates.
|
||||
const SRCSET_PATTERN = /\bsrcset=["']([^"']+)["']/gi;
|
||||
|
||||
function isJsLike(mime, fromPath) {
|
||||
if (mime && /javascript|typescript/i.test(mime)) return true;
|
||||
return /\.(?:m?jsx?|tsx?|cjs)$/i.test(fromPath);
|
||||
}
|
||||
|
||||
function isCssLike(mime, fromPath) {
|
||||
if (mime && /^text\/css\b/i.test(mime)) return true;
|
||||
return /\.css$/i.test(fromPath);
|
||||
}
|
||||
|
||||
function isHtmlLike(mime, fromPath) {
|
||||
if (mime && /^text\/html\b/i.test(mime)) return true;
|
||||
return /\.html?$/i.test(fromPath);
|
||||
}
|
||||
|
||||
function extractRelativeRefs(text, fromPath, fromMime) {
|
||||
if (!text) return [];
|
||||
const refs = new Set();
|
||||
const runPatterns = [];
|
||||
if (isHtmlLike(fromMime, fromPath)) {
|
||||
runPatterns.push(...HTML_REF_PATTERNS, ...CSS_REF_PATTERNS);
|
||||
}
|
||||
if (isCssLike(fromMime, fromPath)) {
|
||||
runPatterns.push(...CSS_REF_PATTERNS);
|
||||
}
|
||||
if (isJsLike(fromMime, fromPath)) {
|
||||
runPatterns.push(...JS_REF_PATTERNS);
|
||||
}
|
||||
// Fallback for unknown textual files: only the safest pattern,
|
||||
// url() in case it's a CSS-in-something we don't recognize.
|
||||
if (runPatterns.length === 0) {
|
||||
runPatterns.push(...CSS_REF_PATTERNS);
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
for (const re of runPatterns) {
|
||||
for (const m of text.matchAll(re)) {
|
||||
const ref = (m[1] || '').trim();
|
||||
if (ref) candidates.push(ref);
|
||||
}
|
||||
}
|
||||
// Pull every candidate URL out of any srcset attributes in HTML.
|
||||
if (isHtmlLike(fromMime, fromPath)) {
|
||||
for (const m of text.matchAll(SRCSET_PATTERN)) {
|
||||
const list = m[1] || '';
|
||||
for (const part of list.split(',')) {
|
||||
const url = part.trim().split(/\s+/)[0];
|
||||
if (url) candidates.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const raw of candidates) {
|
||||
if (/^(?:https?:|\/\/|data:|mailto:|tel:|#)/i.test(raw)) continue;
|
||||
const dir = fromPath.includes('/')
|
||||
? fromPath.slice(0, fromPath.lastIndexOf('/') + 1)
|
||||
: '';
|
||||
const resolved = raw.startsWith('/') ? raw.slice(1) : dir + raw;
|
||||
const stripped = resolved.replace(/[?#].*$/, '');
|
||||
const segs = stripped.split('/').filter(Boolean);
|
||||
const out: string[] = [];
|
||||
let escaped = false;
|
||||
for (const s of segs) {
|
||||
if (s === '.') continue;
|
||||
if (s === '..') {
|
||||
if (out.length === 0) { escaped = true; break; }
|
||||
out.pop();
|
||||
continue;
|
||||
}
|
||||
out.push(s);
|
||||
}
|
||||
if (escaped || out.length === 0) continue;
|
||||
refs.add(out.join('/'));
|
||||
}
|
||||
return [...refs];
|
||||
}
|
||||
|
||||
function okBundle(bundle) {
|
||||
const payload = {
|
||||
entryFile: bundle.entry,
|
||||
projectId: bundle.project?.id,
|
||||
projectName: bundle.project?.name,
|
||||
truncated: bundle.truncated === true,
|
||||
files: bundle.files.map((f) => ({
|
||||
name: f.name,
|
||||
mime: f.mime,
|
||||
size: f.size,
|
||||
binary: f.binary === true,
|
||||
content: f.binary ? null : f.content,
|
||||
})),
|
||||
manifest: bundle.project?.metadata ?? null,
|
||||
};
|
||||
return ok(withActiveEcho(payload, bundle.active, bundle.resolved));
|
||||
}
|
||||
|
||||
function isTextualMime(mime) {
|
||||
if (!mime) return false;
|
||||
return TEXTUAL_MIME_PATTERNS.some((re) => re.test(mime));
|
||||
}
|
||||
|
||||
async function safeText(resp) {
|
||||
try {
|
||||
return await resp.text();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(err, daemonUrl) {
|
||||
const code = err && (err.cause?.code || err.code);
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
if (code === 'ECONNREFUSED' || code === 'ENOTFOUND') {
|
||||
return `cannot reach the Open Design daemon at ${daemonUrl}. Is it running? Start it with \`pnpm tools-dev\`.`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Exported for unit tests only.
|
||||
export { extractRelativeRefs, resolveProjectId, resolveProjectArg, withActiveEcho, fetchProjectFile, getArtifact, getFile };
|
||||
|
|
@ -29,12 +29,16 @@ export async function ensureProject(projectsRoot, projectId) {
|
|||
return dir;
|
||||
}
|
||||
|
||||
export async function listFiles(projectsRoot, projectId) {
|
||||
export async function listFiles(projectsRoot, projectId, opts = {}) {
|
||||
const dir = projectDir(projectsRoot, projectId);
|
||||
const out = [];
|
||||
await collectFiles(dir, '', out);
|
||||
// Newest first — matches the visual order users expect after generating.
|
||||
out.sort((a, b) => b.mtime - a.mtime);
|
||||
const since = Number(opts.since);
|
||||
if (Number.isFinite(since) && since > 0) {
|
||||
return out.filter((f) => Number(f.mtime) > since);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -368,6 +372,57 @@ export function mimeFor(name) {
|
|||
return EXT_MIME[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export async function searchProjectFiles(projectsRoot, projectId, query, opts = {}) {
|
||||
const max = Math.min(Number(opts.max) || 200, 1000);
|
||||
const pattern = opts.pattern || null;
|
||||
const items = await listFiles(projectsRoot, projectId);
|
||||
const dir = projectDir(projectsRoot, projectId);
|
||||
const escaped = String(query).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(escaped, 'i');
|
||||
const matches = [];
|
||||
for (const f of items) {
|
||||
if (!isTextualMime(f.mime)) continue;
|
||||
if (pattern && !globMatch(f.name, pattern)) continue;
|
||||
let content;
|
||||
try {
|
||||
content = await readFile(path.join(dir, f.name), 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (re.test(lines[i])) {
|
||||
const snippet = lines[i].length > 220 ? lines[i].slice(0, 220) + '…' : lines[i];
|
||||
matches.push({ file: f.name, line: i + 1, snippet });
|
||||
if (matches.length >= max) return matches;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function isTextualMime(mime) {
|
||||
if (!mime) return false;
|
||||
return (
|
||||
/^text\//i.test(mime) ||
|
||||
/^application\/(json|javascript|typescript|xml|x-(?:yaml|toml|httpd-php|sh))\b/i.test(mime) ||
|
||||
/\+(?:json|xml)\b/i.test(mime) ||
|
||||
/^image\/svg\+xml/i.test(mime)
|
||||
);
|
||||
}
|
||||
|
||||
function globMatch(name, glob) {
|
||||
const re = new RegExp(
|
||||
'^' +
|
||||
glob
|
||||
.split('*')
|
||||
.map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('.*') +
|
||||
'$',
|
||||
);
|
||||
return re.test(name);
|
||||
}
|
||||
|
||||
// Coarse kind buckets the frontend uses to pick a viewer.
|
||||
export function kindFor(name) {
|
||||
// Editable sketches use a compound extension so they slot into the
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import {
|
|||
readProjectFile,
|
||||
removeProjectDir,
|
||||
sanitizeName,
|
||||
searchProjectFiles,
|
||||
writeProjectFile,
|
||||
} from './projects.js';
|
||||
import { validateArtifactManifestInput } from './artifact-manifest.js';
|
||||
|
|
@ -717,6 +718,133 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
|||
|
||||
// ---- Projects (DB-backed) -------------------------------------------------
|
||||
|
||||
// Soft "what is the user looking at right now in Open Design?" channel. The
|
||||
// web UI POSTs the current project + file on every route change;
|
||||
// the MCP surface reads it so a coding agent in another repo can
|
||||
// resolve "the design I have open" without the user typing the
|
||||
// project id. In-memory only - daemon restart clears it.
|
||||
/** @type {{ projectId: string; fileName: string | null; ts: number } | null} */
|
||||
let activeContext = null;
|
||||
const ACTIVE_CONTEXT_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
// Active context is private to the local machine. The daemon binds
|
||||
// 0.0.0.0 by default, so without an origin check a peer on the LAN
|
||||
// could read what the user is currently looking at (GET) or spoof
|
||||
// it to redirect MCP fallbacks (POST). The web proxies same-origin
|
||||
// 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)) {
|
||||
return res.status(403).json({ error: 'cross-origin request rejected' });
|
||||
}
|
||||
try {
|
||||
const body = req.body || {};
|
||||
if (body.active === false) {
|
||||
activeContext = null;
|
||||
res.json({ active: false });
|
||||
return;
|
||||
}
|
||||
const projectId = typeof body.projectId === 'string' ? body.projectId : '';
|
||||
if (!projectId) {
|
||||
sendApiError(res, 400, 'BAD_REQUEST', 'projectId is required');
|
||||
return;
|
||||
}
|
||||
const fileName =
|
||||
typeof body.fileName === 'string' && body.fileName.length > 0
|
||||
? body.fileName
|
||||
: null;
|
||||
activeContext = { projectId, fileName, ts: Date.now() };
|
||||
res.json({ active: true, ...activeContext });
|
||||
} catch (err) {
|
||||
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/active', (req, res) => {
|
||||
if (!isLocalSameOrigin(req, resolvedPort)) {
|
||||
return res.status(403).json({ error: 'cross-origin request rejected' });
|
||||
}
|
||||
if (!activeContext || Date.now() - activeContext.ts > ACTIVE_CONTEXT_TTL_MS) {
|
||||
activeContext = null;
|
||||
res.json({ active: false });
|
||||
return;
|
||||
}
|
||||
const project = getProject(db, activeContext.projectId);
|
||||
res.json({
|
||||
active: true,
|
||||
projectId: activeContext.projectId,
|
||||
projectName: project?.name ?? null,
|
||||
fileName: activeContext.fileName,
|
||||
ts: activeContext.ts,
|
||||
ageMs: Date.now() - activeContext.ts,
|
||||
});
|
||||
});
|
||||
|
||||
// Surfaces the absolute paths to `node` + `apps/daemon/dist/cli.js`
|
||||
// so the Settings → MCP server panel can render snippets that work
|
||||
// even when `od` isn't on the user's PATH (the common case for
|
||||
// source clones - and macOS/Linux ship a /usr/bin/od octal-dump
|
||||
// tool that shadows ours anyway). Computed from import.meta.url so
|
||||
// both src/ (tsx dev) and dist/ (built) launches resolve to the
|
||||
// same dist/cli.js path. Cached for 5s because the panel pings on
|
||||
// every open and the path lookup + two existsSync calls are cheap
|
||||
// but not free, and these paths cannot change without a daemon
|
||||
// restart anyway.
|
||||
const INSTALL_INFO_TTL_MS = 5000;
|
||||
let installInfoCache: { t: number; payload: object } | null = null;
|
||||
|
||||
app.get('/api/mcp/install-info', (req, res) => {
|
||||
if (!isLocalSameOrigin(req, resolvedPort)) {
|
||||
return res.status(403).json({ error: 'cross-origin request rejected' });
|
||||
}
|
||||
const now = Date.now();
|
||||
if (installInfoCache && now - installInfoCache.t < INSTALL_INFO_TTL_MS) {
|
||||
return res.json(installInfoCache.payload);
|
||||
}
|
||||
let cliPath;
|
||||
try {
|
||||
cliPath = fileURLToPath(new URL('../dist/cli.js', import.meta.url));
|
||||
} catch (err) {
|
||||
return sendApiError(res, 500, 'CLI_RESOLVE_FAILED', String(err));
|
||||
}
|
||||
const cliExists = fs.existsSync(cliPath);
|
||||
// process.execPath is the absolute path to the node binary that
|
||||
// is running the daemon RIGHT NOW. We prefer it over bare `node`
|
||||
// because IDE-spawned MCP clients inherit a minimal PATH from the
|
||||
// OS launcher (Spotlight, Dock, etc.) that often does not see
|
||||
// user-level node installs (nvm, fnm, asdf). On rare occasions
|
||||
// (uninstall mid-session, exotic embeds) the path may not exist
|
||||
// by the time the user copies the snippet; catch that and warn.
|
||||
const nodeExists = fs.existsSync(process.execPath);
|
||||
const hints: string[] = [];
|
||||
if (!cliExists) {
|
||||
hints.push(
|
||||
'apps/daemon/dist/cli.js is missing. Run `pnpm --filter @open-design/daemon build` (or just `pnpm build`) and refresh.',
|
||||
);
|
||||
}
|
||||
if (!nodeExists) {
|
||||
hints.push(
|
||||
`Node binary at ${process.execPath} no longer exists. Reinstall Node and restart the daemon.`,
|
||||
);
|
||||
}
|
||||
const payload = {
|
||||
command: process.execPath,
|
||||
args: [cliPath, 'mcp', '--daemon-url', `http://127.0.0.1:${resolvedPort}`],
|
||||
daemonUrl: `http://127.0.0.1:${resolvedPort}`,
|
||||
// Surface platform so the install panel can localize path hints
|
||||
// (~/.cursor vs %USERPROFILE%\.cursor) and keyboard shortcuts
|
||||
// (Cmd vs Ctrl). One of 'darwin' | 'linux' | 'win32' in
|
||||
// practice; the panel falls back to POSIX wording for anything
|
||||
// else.
|
||||
platform: process.platform,
|
||||
cliExists,
|
||||
nodeExists,
|
||||
buildHint: hints.length ? hints.join(' ') : null,
|
||||
};
|
||||
installInfoCache = { t: now, payload };
|
||||
res.json(payload);
|
||||
});
|
||||
|
||||
app.get('/api/projects', (_req, res) => {
|
||||
try {
|
||||
const latestRunStatuses = listLatestProjectRunStatuses(db);
|
||||
|
|
@ -1738,7 +1866,10 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
|||
// project's own folder (see apps/daemon/src/projects.ts).
|
||||
app.get('/api/projects/:id/files', async (req, res) => {
|
||||
try {
|
||||
const files = await listFiles(PROJECTS_DIR, req.params.id);
|
||||
const since = Number(req.query?.since);
|
||||
const files = await listFiles(PROJECTS_DIR, req.params.id, {
|
||||
since: Number.isFinite(since) ? since : undefined,
|
||||
});
|
||||
/** @type {import('@open-design/contracts').ProjectFilesResponse} */
|
||||
const body = { files };
|
||||
res.json(body);
|
||||
|
|
@ -1747,6 +1878,25 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
|||
}
|
||||
});
|
||||
|
||||
app.get('/api/projects/:id/search', async (req, res) => {
|
||||
try {
|
||||
const query = String(req.query.q ?? '');
|
||||
if (!query) {
|
||||
sendApiError(res, 400, 'BAD_REQUEST', 'q query parameter is required');
|
||||
return;
|
||||
}
|
||||
const pattern = req.query.pattern ? String(req.query.pattern) : null;
|
||||
const max = Math.min(Number(req.query.max) || 200, 1000);
|
||||
const matches = await searchProjectFiles(PROJECTS_DIR, req.params.id, query, {
|
||||
pattern,
|
||||
max,
|
||||
});
|
||||
res.json({ query, matches });
|
||||
} catch (err) {
|
||||
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
||||
}
|
||||
});
|
||||
|
||||
// Streams a ZIP of the project's on-disk tree so the "Download as .zip"
|
||||
// share menu can hand the user the actual files they uploaded — e.g. the
|
||||
// imported `ui-design/` folder — instead of a one-file snapshot of the
|
||||
|
|
|
|||
|
|
@ -302,53 +302,13 @@ describe('app-config origin guard', () => {
|
|||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows GET when Origin is the trusted web port (proxy flow)', async () => {
|
||||
const webPort = port + 1;
|
||||
process.env.OD_WEB_PORT = String(webPort);
|
||||
try {
|
||||
const res = await httpRequest(`${baseUrl}/api/app-config`, {
|
||||
headers: {
|
||||
Host: `127.0.0.1:${port}`,
|
||||
Origin: `http://127.0.0.1:${webPort}`,
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
} finally {
|
||||
delete process.env.OD_WEB_PORT;
|
||||
}
|
||||
});
|
||||
|
||||
it('allows PUT when Origin is the trusted web port (proxy flow)', async () => {
|
||||
const webPort = port + 1;
|
||||
process.env.OD_WEB_PORT = String(webPort);
|
||||
try {
|
||||
const res = await httpRequest(`${baseUrl}/api/app-config`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Host: `127.0.0.1:${port}`,
|
||||
Origin: `http://localhost:${webPort}`,
|
||||
},
|
||||
body: JSON.stringify({ onboardingCompleted: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
} finally {
|
||||
delete process.env.OD_WEB_PORT;
|
||||
}
|
||||
});
|
||||
|
||||
it('still rejects cross-origin even when OD_WEB_PORT is set', async () => {
|
||||
process.env.OD_WEB_PORT = String(port + 1);
|
||||
try {
|
||||
const res = await httpRequest(`${baseUrl}/api/app-config`, {
|
||||
headers: {
|
||||
Host: `127.0.0.1:${port}`,
|
||||
Origin: 'https://evil.com',
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
} finally {
|
||||
delete process.env.OD_WEB_PORT;
|
||||
}
|
||||
it('still rejects non-loopback Origin', async () => {
|
||||
const res = await httpRequest(`${baseUrl}/api/app-config`, {
|
||||
headers: {
|
||||
Host: `127.0.0.1:${port}`,
|
||||
Origin: 'https://evil.com',
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
57
apps/daemon/tests/mcp-extract-refs.test.ts
Normal file
57
apps/daemon/tests/mcp-extract-refs.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// @ts-nocheck
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractRelativeRefs } from '../src/mcp.js';
|
||||
|
||||
describe('extractRelativeRefs', () => {
|
||||
it('flat project: index.html referencing tokens.css resolves to tokens.css', () => {
|
||||
const refs = extractRelativeRefs('<link href="tokens.css">', 'index.html', 'text/html');
|
||||
expect(refs).toContain('tokens.css');
|
||||
});
|
||||
|
||||
it('nested: pages/landing.html referencing ../tokens.css resolves to tokens.css', () => {
|
||||
const refs = extractRelativeRefs('<link href="../tokens.css">', 'pages/landing.html', 'text/html');
|
||||
expect(refs).toContain('tokens.css');
|
||||
});
|
||||
|
||||
it('deeply nested: a/b/c/file.css referencing ../../shared.css resolves to a/shared.css', () => {
|
||||
const refs = extractRelativeRefs('@import "../../shared.css";', 'a/b/c/file.css', 'text/css');
|
||||
expect(refs).toContain('a/shared.css');
|
||||
});
|
||||
|
||||
it('escape attempt from root: index.html referencing ../../etc/passwd is rejected', () => {
|
||||
const refs = extractRelativeRefs('<link href="../../etc/passwd">', 'index.html', 'text/html');
|
||||
expect(refs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('escape attempt at depth 1: pages/landing.html referencing ../../escape.txt is rejected', () => {
|
||||
const refs = extractRelativeRefs('<link href="../../escape.txt">', 'pages/landing.html', 'text/html');
|
||||
expect(refs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('external https URL is ignored', () => {
|
||||
const refs = extractRelativeRefs('<script src="https://cdn.example.com/app.js"></script>', 'index.html', 'text/html');
|
||||
expect(refs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('data URL is ignored', () => {
|
||||
const refs = extractRelativeRefs('<img src="data:image/png;base64,abc">', 'index.html', 'text/html');
|
||||
expect(refs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('anchor ref is ignored', () => {
|
||||
const refs = extractRelativeRefs('<a href="#section">', 'index.html', 'text/html');
|
||||
expect(refs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('mailto and tel refs are ignored', () => {
|
||||
const refs = extractRelativeRefs('<a href="mailto:x@y.com"><a href="tel:+1">', 'index.html', 'text/html');
|
||||
expect(refs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('srcset with parent-relative entries resolves correctly', () => {
|
||||
const html = '<img srcset="../img/small.png 1x, ../img/large.png 2x">';
|
||||
const refs = extractRelativeRefs(html, 'pages/index.html', 'text/html');
|
||||
expect(refs).toContain('img/small.png');
|
||||
expect(refs).toContain('img/large.png');
|
||||
});
|
||||
});
|
||||
147
apps/daemon/tests/mcp-get-artifact.test.ts
Normal file
147
apps/daemon/tests/mcp-get-artifact.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// @ts-nocheck
|
||||
import http from 'node:http';
|
||||
import express from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { getArtifact, fetchProjectFile } from '../src/mcp.js';
|
||||
|
||||
// A minimal mock of the daemon's project file endpoints. Tests control
|
||||
// the file list and per-file response via the opts object.
|
||||
function makeDaemonApp(opts = {}) {
|
||||
const { files = [], fileContent = 'body {}', contentType = 'text/css', contentLength = null } = opts;
|
||||
const app = express();
|
||||
|
||||
app.get('/api/projects/:id', (_req, res) =>
|
||||
res.json({
|
||||
project: { id: _req.params.id, name: 'Test', metadata: { entryFile: 'index.html' } },
|
||||
}),
|
||||
);
|
||||
|
||||
app.get('/api/projects/:id/files', (_req, res) => res.json({ files }));
|
||||
|
||||
app.get('/api/projects/:id/raw/*', (_req, res) => {
|
||||
const headers = { 'content-type': contentType };
|
||||
if (contentLength != null) headers['content-length'] = String(contentLength);
|
||||
res.set(headers).send(fileContent);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function startServer(app) {
|
||||
return new Promise((resolve) => {
|
||||
const tmp = http.createServer();
|
||||
tmp.listen(0, '127.0.0.1', () => {
|
||||
const { port } = tmp.address();
|
||||
tmp.close(() => {
|
||||
const server = app.listen(port, '127.0.0.1', () =>
|
||||
resolve({ server, baseUrl: `http://127.0.0.1:${port}` }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const PROJECT_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
|
||||
describe('getArtifact file-count cap (MAX_FILES = 200)', () => {
|
||||
let server;
|
||||
let baseUrl;
|
||||
|
||||
const fileList = Array.from({ length: 250 }, (_, i) => ({ name: `file${i}.css` }));
|
||||
|
||||
beforeAll(async () => {
|
||||
const r = await startServer(makeDaemonApp({ files: fileList, fileContent: 'a {}', contentType: 'text/css' }));
|
||||
server = r.server;
|
||||
baseUrl = r.baseUrl;
|
||||
});
|
||||
|
||||
afterAll(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
it('caps at 200 files and sets truncated: true when the project has 250 files', async () => {
|
||||
const result = await getArtifact(baseUrl, PROJECT_ID, 'index.html', 'all', 10_000_000);
|
||||
const body = JSON.parse(result.content[0].text);
|
||||
expect(body.truncated).toBe(true);
|
||||
expect(body.files.length).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getArtifact maxBytes cap', () => {
|
||||
let server;
|
||||
let baseUrl;
|
||||
|
||||
// 10 files, each 200 bytes. With maxBytes=400 the third loop iteration
|
||||
// finds totalTextBytes >= maxBytes and sets truncated: true.
|
||||
const fileList = Array.from({ length: 10 }, (_, i) => ({ name: `file${i}.css` }));
|
||||
const fileContent = 'a'.repeat(200);
|
||||
|
||||
beforeAll(async () => {
|
||||
const r = await startServer(makeDaemonApp({ files: fileList, fileContent, contentType: 'text/css' }));
|
||||
server = r.server;
|
||||
baseUrl = r.baseUrl;
|
||||
});
|
||||
|
||||
afterAll(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
it('stops fetching and sets truncated: true when byte cap is reached', async () => {
|
||||
const result = await getArtifact(baseUrl, PROJECT_ID, 'index.html', 'all', 400);
|
||||
const body = JSON.parse(result.content[0].text);
|
||||
expect(body.truncated).toBe(true);
|
||||
expect(body.files.length).toBeLessThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchProjectFile per-file size pre-check', () => {
|
||||
let server;
|
||||
let baseUrl;
|
||||
|
||||
beforeAll(async () => {
|
||||
const r = await startServer(
|
||||
makeDaemonApp({ fileContent: 'x'.repeat(10_000), contentType: 'text/css', contentLength: 10_000 }),
|
||||
);
|
||||
server = r.server;
|
||||
baseUrl = r.baseUrl;
|
||||
});
|
||||
|
||||
afterAll(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
it('throws when content-length exceeds remainingBytes without reading the body', async () => {
|
||||
await expect(fetchProjectFile(baseUrl, PROJECT_ID, 'styles.css', 5_000)).rejects.toThrow(
|
||||
/exceeds remaining budget/,
|
||||
);
|
||||
});
|
||||
|
||||
it('succeeds and returns content when remainingBytes is sufficient', async () => {
|
||||
const file = await fetchProjectFile(baseUrl, PROJECT_ID, 'styles.css', 20_000);
|
||||
expect(file.binary).toBe(false);
|
||||
expect(file.content.length).toBe(10_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getArtifact truncated: true when per-file content-length pre-check fires (include=all)', () => {
|
||||
let server;
|
||||
let baseUrl;
|
||||
|
||||
// 5 files, each 250 bytes with explicit content-length.
|
||||
// maxBytes=400: file0 (remaining=400, size=250) fetches fine.
|
||||
// file1+ (remaining=150, size=250 > 150) hit the BudgetExceededError path.
|
||||
// totalTextBytes never reaches maxBytes, so only the pre-check path sets truncated.
|
||||
const fileList = Array.from({ length: 5 }, (_, i) => ({ name: `file${i}.css` }));
|
||||
const fileContent = 'a'.repeat(250);
|
||||
|
||||
beforeAll(async () => {
|
||||
const r = await startServer(
|
||||
makeDaemonApp({ files: fileList, fileContent, contentType: 'text/css', contentLength: 250 }),
|
||||
);
|
||||
server = r.server;
|
||||
baseUrl = r.baseUrl;
|
||||
});
|
||||
|
||||
afterAll(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
it('sets truncated: true even when totalTextBytes never reaches maxBytes', async () => {
|
||||
const result = await getArtifact(baseUrl, PROJECT_ID, 'index.html', 'all', 400);
|
||||
const body = JSON.parse(result.content[0].text);
|
||||
expect(body.truncated).toBe(true);
|
||||
expect(body.files.length).toBe(1);
|
||||
});
|
||||
});
|
||||
112
apps/daemon/tests/mcp-get-file.test.ts
Normal file
112
apps/daemon/tests/mcp-get-file.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// @ts-nocheck
|
||||
import http from 'node:http';
|
||||
import express from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { getFile } from '../src/mcp.js';
|
||||
|
||||
const PROJECT_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
|
||||
function makeDaemonApp(text, contentType = 'text/plain') {
|
||||
const app = express();
|
||||
app.get('/api/projects/:id/raw/*', (_req, res) => {
|
||||
res.set({ 'content-type': contentType }).send(text);
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
function startServer(app) {
|
||||
return new Promise((resolve) => {
|
||||
const tmp = http.createServer();
|
||||
tmp.listen(0, '127.0.0.1', () => {
|
||||
const { port } = tmp.address();
|
||||
tmp.close(() => {
|
||||
const server = app.listen(port, '127.0.0.1', () =>
|
||||
resolve({ server, baseUrl: `http://127.0.0.1:${port}` }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const FIVE_HUNDRED_LINES = Array.from({ length: 500 }, (_, i) => `line ${i + 1}`).join('\n');
|
||||
|
||||
describe('getFile offset/limit slicing', () => {
|
||||
let server;
|
||||
let baseUrl;
|
||||
|
||||
beforeAll(async () => {
|
||||
const r = await startServer(makeDaemonApp(FIVE_HUNDRED_LINES, 'text/plain'));
|
||||
server = r.server;
|
||||
baseUrl = r.baseUrl;
|
||||
});
|
||||
|
||||
afterAll(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
it('default args return the full file when totalLines <= 2000 and add no window marker', async () => {
|
||||
const r = await getFile(baseUrl, PROJECT_ID, 'file.txt', null, null);
|
||||
const textParts = r.content.map((c) => c.text);
|
||||
expect(textParts.some((t) => t.startsWith('[od:file-window'))).toBe(false);
|
||||
const body = textParts[textParts.length - 1];
|
||||
expect(body.split('\n').length).toBe(500);
|
||||
expect(body.split('\n')[0]).toBe('line 1');
|
||||
expect(body.split('\n')[499]).toBe('line 500');
|
||||
});
|
||||
|
||||
it('limit caps the slice and stamps a truncation marker with totalLines', async () => {
|
||||
const r = await getFile(baseUrl, PROJECT_ID, 'file.txt', null, null, 0, 100);
|
||||
const textParts = r.content.map((c) => c.text);
|
||||
const marker = textParts.find((t) => t.startsWith('[od:file-window'));
|
||||
expect(marker).toBeDefined();
|
||||
expect(marker).toContain('offset=0');
|
||||
expect(marker).toContain('returnedLines=100');
|
||||
expect(marker).toContain('totalLines=500');
|
||||
expect(marker).toContain('offset=100');
|
||||
const body = textParts[textParts.length - 1];
|
||||
expect(body.split('\n').length).toBe(100);
|
||||
expect(body.split('\n')[0]).toBe('line 1');
|
||||
expect(body.split('\n')[99]).toBe('line 100');
|
||||
});
|
||||
|
||||
it('offset returns a mid-file slice and the marker reflects start', async () => {
|
||||
const r = await getFile(baseUrl, PROJECT_ID, 'file.txt', null, null, 200, 50);
|
||||
const textParts = r.content.map((c) => c.text);
|
||||
const marker = textParts.find((t) => t.startsWith('[od:file-window'));
|
||||
expect(marker).toContain('offset=200');
|
||||
expect(marker).toContain('returnedLines=50');
|
||||
const body = textParts[textParts.length - 1];
|
||||
expect(body.split('\n')[0]).toBe('line 201');
|
||||
expect(body.split('\n')[49]).toBe('line 250');
|
||||
});
|
||||
|
||||
it('offset past EOF returns empty slice but still stamps the marker (no truncation note)', async () => {
|
||||
const r = await getFile(baseUrl, PROJECT_ID, 'file.txt', null, null, 1000, 50);
|
||||
const textParts = r.content.map((c) => c.text);
|
||||
const marker = textParts.find((t) => t.startsWith('[od:file-window'));
|
||||
expect(marker).toContain('offset=500');
|
||||
expect(marker).toContain('returnedLines=0');
|
||||
expect(marker).toContain('totalLines=500');
|
||||
expect(marker).not.toContain('call get_file again');
|
||||
const body = textParts[textParts.length - 1];
|
||||
expect(body).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFile binary rejection unchanged', () => {
|
||||
let server;
|
||||
let baseUrl;
|
||||
|
||||
beforeAll(async () => {
|
||||
const r = await startServer(makeDaemonApp('binary-bytes', 'image/png'));
|
||||
server = r.server;
|
||||
baseUrl = r.baseUrl;
|
||||
});
|
||||
|
||||
afterAll(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
it('returns an error result for binary mimes regardless of offset/limit', async () => {
|
||||
const r = await getFile(baseUrl, PROJECT_ID, 'logo.png', null, null, 0, 100);
|
||||
expect(r.isError).toBe(true);
|
||||
const text = r.content.map((c) => c.text).join('\n');
|
||||
expect(text).toMatch(/binary content is not yet supported/);
|
||||
});
|
||||
});
|
||||
140
apps/daemon/tests/mcp-install-info.test.ts
Normal file
140
apps/daemon/tests/mcp-install-info.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// @ts-nocheck
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import express from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { isLocalSameOrigin } from '../src/server.js';
|
||||
|
||||
// The install-info endpoint is a self-contained handler that resolves
|
||||
// absolute paths to node + cli.js so the Settings → MCP server panel
|
||||
// can render snippets that work regardless of PATH. We re-build a
|
||||
// minimal Express app with the same handler shape rather than booting
|
||||
// the full daemon (which needs SQLite, sidecar, fs scaffolding).
|
||||
|
||||
interface InstallInfoOpts {
|
||||
cliPath: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
function makeInstallInfoApp({ cliPath, port }: InstallInfoOpts) {
|
||||
const app = express();
|
||||
|
||||
const TTL_MS = 5000;
|
||||
let cache: { t: number; payload: object } | null = null;
|
||||
let resolveCalls = 0;
|
||||
|
||||
app.get('/api/mcp/install-info', (req, res) => {
|
||||
if (!isLocalSameOrigin(req, port)) {
|
||||
return res.status(403).json({ error: 'cross-origin request rejected' });
|
||||
}
|
||||
const now = Date.now();
|
||||
if (cache && now - cache.t < TTL_MS) {
|
||||
return res.json(cache.payload);
|
||||
}
|
||||
resolveCalls += 1;
|
||||
const cliExists = fs.existsSync(cliPath);
|
||||
const nodeExists = fs.existsSync(process.execPath);
|
||||
const hints: string[] = [];
|
||||
if (!cliExists) hints.push('cli missing');
|
||||
if (!nodeExists) hints.push('node missing');
|
||||
const payload = {
|
||||
command: process.execPath,
|
||||
args: [cliPath, 'mcp', '--daemon-url', `http://127.0.0.1:${port}`],
|
||||
daemonUrl: `http://127.0.0.1:${port}`,
|
||||
platform: process.platform,
|
||||
cliExists,
|
||||
nodeExists,
|
||||
buildHint: hints.length ? hints.join(' ') : null,
|
||||
};
|
||||
cache = { t: now, payload };
|
||||
res.json(payload);
|
||||
});
|
||||
|
||||
// Test-only escape hatch so assertions can prove the cache cold-paths.
|
||||
(app as any)._resolveCalls = () => resolveCalls;
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('GET /api/mcp/install-info', () => {
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
let port: number;
|
||||
let tmpDir: string;
|
||||
let cliPath: string;
|
||||
let app: express.Express;
|
||||
|
||||
beforeAll(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-mcp-info-'));
|
||||
cliPath = path.join(tmpDir, 'cli.js');
|
||||
fs.writeFileSync(cliPath, '// stub\n', 'utf8');
|
||||
// listen on a random free port; capture so isLocalSameOrigin
|
||||
// can compare the Host header
|
||||
const tmp = http.createServer();
|
||||
tmp.listen(0, '127.0.0.1', () => {
|
||||
port = (tmp.address() as { port: number }).port;
|
||||
tmp.close(() => {
|
||||
app = makeInstallInfoApp({ cliPath, port });
|
||||
server = app.listen(port, '127.0.0.1', () => resolve());
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
afterAll(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
it('returns command, args, platform, daemonUrl', async () => {
|
||||
const res = await fetch(`${baseUrl ?? `http://127.0.0.1:${port}`}/api/mcp/install-info`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.command).toBe(process.execPath);
|
||||
expect(body.args).toEqual([cliPath, 'mcp', '--daemon-url', `http://127.0.0.1:${port}`]);
|
||||
expect(body.daemonUrl).toBe(`http://127.0.0.1:${port}`);
|
||||
expect(body.platform).toBe(process.platform);
|
||||
expect(body.cliExists).toBe(true);
|
||||
expect(body.nodeExists).toBe(true);
|
||||
expect(body.buildHint).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects cross-origin requests with 403', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`, {
|
||||
headers: { Origin: 'https://evil.com' },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('accepts requests with no Origin header (loopback fetch)', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('accepts requests with matching localhost Origin', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`, {
|
||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('caches the payload across rapid calls', async () => {
|
||||
const before = (app as any)._resolveCalls();
|
||||
await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
|
||||
await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
|
||||
await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
|
||||
const after = (app as any)._resolveCalls();
|
||||
// The first call may go through or may hit the cache from earlier
|
||||
// tests; what matters is that 3 rapid calls add at most 1 fresh
|
||||
// resolve, not 3.
|
||||
expect(after - before).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
88
apps/daemon/tests/mcp-resolve-project.test.ts
Normal file
88
apps/daemon/tests/mcp-resolve-project.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// @ts-nocheck
|
||||
import http from 'node:http';
|
||||
import express from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { resolveProjectId, withActiveEcho } from '../src/mcp.js';
|
||||
|
||||
// Two projects whose names share the substring 'app' for ambiguity testing.
|
||||
const PROJECTS = [
|
||||
{ id: '11111111-1111-1111-1111-111111111111', name: 'My App' },
|
||||
{ id: '22222222-2222-2222-2222-222222222222', name: 'Store App' },
|
||||
{ id: '33333333-3333-3333-3333-333333333333', name: 'recaptr' },
|
||||
];
|
||||
|
||||
describe('resolveProjectId', () => {
|
||||
let server;
|
||||
let baseUrl;
|
||||
|
||||
beforeAll(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
const app = express();
|
||||
app.get('/api/projects', (_req, res) => res.json({ projects: PROJECTS }));
|
||||
const tmp = http.createServer();
|
||||
tmp.listen(0, '127.0.0.1', () => {
|
||||
const { port } = tmp.address();
|
||||
baseUrl = `http://127.0.0.1:${port}`;
|
||||
tmp.close(() => {
|
||||
server = app.listen(port, '127.0.0.1', () => resolve());
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
afterAll(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
it('UUID input returns source: uuid without fetching the project list', async () => {
|
||||
const r = await resolveProjectId(baseUrl, '11111111-1111-1111-1111-111111111111');
|
||||
expect(r.source).toBe('uuid');
|
||||
expect(r.id).toBe('11111111-1111-1111-1111-111111111111');
|
||||
});
|
||||
|
||||
it('exact name match returns source: exact', async () => {
|
||||
const r = await resolveProjectId(baseUrl, 'My App');
|
||||
expect(r.source).toBe('exact');
|
||||
expect(r.id).toBe('11111111-1111-1111-1111-111111111111');
|
||||
expect(r.name).toBe('My App');
|
||||
});
|
||||
|
||||
it('slug match (my-app) returns source: slug', async () => {
|
||||
const r = await resolveProjectId(baseUrl, 'my-app');
|
||||
expect(r.source).toBe('slug');
|
||||
expect(r.id).toBe('11111111-1111-1111-1111-111111111111');
|
||||
});
|
||||
|
||||
it('single substring match returns source: substring', async () => {
|
||||
const r = await resolveProjectId(baseUrl, 'recapt');
|
||||
expect(r.source).toBe('substring');
|
||||
expect(r.id).toBe('33333333-3333-3333-3333-333333333333');
|
||||
expect(r.name).toBe('recaptr');
|
||||
});
|
||||
|
||||
it('multiple substring matches throw an ambiguity error', async () => {
|
||||
// 'My App' and 'Store App' both contain 'app'
|
||||
await expect(resolveProjectId(baseUrl, 'app')).rejects.toThrow(/multiple projects match/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withActiveEcho resolvedProject stamping', () => {
|
||||
it('uuid source: resolvedProject is not added', () => {
|
||||
const result = withActiveEcho({ x: 1 }, null, { id: 'abc', name: 'Test', source: 'uuid' });
|
||||
expect(result).not.toHaveProperty('resolvedProject');
|
||||
});
|
||||
|
||||
it('exact source: resolvedProject is not added', () => {
|
||||
const result = withActiveEcho({ x: 1 }, null, { id: 'abc', name: 'Test', source: 'exact' });
|
||||
expect(result).not.toHaveProperty('resolvedProject');
|
||||
});
|
||||
|
||||
it('slug source: resolvedProject is added with id and name', () => {
|
||||
const result = withActiveEcho({ x: 1 }, null, { id: 'abc', name: 'Test', source: 'slug' });
|
||||
expect(result.resolvedProject).toEqual({ id: 'abc', name: 'Test' });
|
||||
});
|
||||
|
||||
it('substring source: resolvedProject is added with id and name', () => {
|
||||
const result = withActiveEcho({ x: 1 }, null, { id: 'abc', name: 'Test', source: 'substring' });
|
||||
expect(result.resolvedProject).toEqual({ id: 'abc', name: 'Test' });
|
||||
});
|
||||
});
|
||||
|
|
@ -83,6 +83,26 @@ export function App() {
|
|||
}
|
||||
}, [config.theme]);
|
||||
|
||||
// Tell the daemon what the user is currently looking at, so the MCP
|
||||
// server can surface it as `get_active_context` to a coding agent in
|
||||
// another repo. Best-effort fire-and-forget; the daemon holds it in
|
||||
// memory with a short TTL and the MCP layer falls back to
|
||||
// {active:false} if this hasn't run.
|
||||
const activeProjectId = route.kind === 'project' ? route.projectId : null;
|
||||
const activeFileName = route.kind === 'project' ? route.fileName : null;
|
||||
useEffect(() => {
|
||||
const body = activeProjectId
|
||||
? { projectId: activeProjectId, fileName: activeFileName }
|
||||
: { active: false };
|
||||
fetch('/api/active', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => {
|
||||
// Daemon down or transient network — not worth surfacing.
|
||||
});
|
||||
}, [activeProjectId, activeFileName]);
|
||||
|
||||
// Bootstrap — detect daemon, load pickers, seed sensible defaults.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
export type SettingsSection =
|
||||
| 'execution'
|
||||
| 'media'
|
||||
| 'integrations'
|
||||
| 'language'
|
||||
| 'appearance'
|
||||
| 'notifications'
|
||||
|
|
@ -447,6 +448,17 @@ export function SettingsDialog({
|
|||
<small>Image / video / audio</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`settings-nav-item${activeSection === 'integrations' ? ' active' : ''}`}
|
||||
onClick={() => setActiveSection('integrations')}
|
||||
>
|
||||
<Icon name="link" size={18} />
|
||||
<span>
|
||||
<strong>MCP server</strong>
|
||||
<small>Connect your coding agent</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`settings-nav-item${activeSection === 'language' ? ' active' : ''}`}
|
||||
|
|
@ -900,6 +912,7 @@ export function SettingsDialog({
|
|||
) : null}
|
||||
|
||||
{activeSection === 'media' ? <MediaProvidersSection cfg={cfg} setCfg={setCfg} /> : null}
|
||||
{activeSection === 'integrations' ? <IntegrationsSection /> : null}
|
||||
|
||||
{activeSection === 'language' ? (
|
||||
<section className="settings-section">
|
||||
|
|
@ -1154,6 +1167,583 @@ function MediaProvidersSection({
|
|||
);
|
||||
}
|
||||
|
||||
// Per-client install paths. Each entry's `snippet` is what the user
|
||||
// copies; some clients also support a richer `deeplink` flow that
|
||||
// triggers a one-click install with an in-client approval dialog.
|
||||
//
|
||||
// Schemas drift between clients in deliberate ways. VS Code keys
|
||||
// servers under "servers" with a required "type" field; Zed uses
|
||||
// "context_servers"; Cursor, Windsurf, and Antigravity share
|
||||
// "mcpServers"; Claude Code is best served by its CLI which writes
|
||||
// to the local config for you. Verified against each tool's official
|
||||
// docs in May 2026.
|
||||
//
|
||||
// Important: every snippet uses absolute paths to `node` and the
|
||||
// daemon's built cli.js, fetched from the daemon at runtime. macOS
|
||||
// and Linux ship a system /usr/bin/od (octal-dump) that shadows any
|
||||
// `od` we might add to PATH, and most Open Design users run from
|
||||
// source where `od` is not installed globally. The installer panel
|
||||
// must NOT reference bare `od`.
|
||||
type McpClientId =
|
||||
| 'claude'
|
||||
| 'codex'
|
||||
| 'cursor'
|
||||
| 'vscode'
|
||||
| 'zed'
|
||||
| 'windsurf'
|
||||
| 'antigravity';
|
||||
|
||||
interface McpInstallInfo {
|
||||
command: string;
|
||||
args: string[];
|
||||
daemonUrl: string;
|
||||
platform: 'darwin' | 'linux' | 'win32' | string;
|
||||
cliExists: boolean;
|
||||
nodeExists: boolean;
|
||||
buildHint: string | null;
|
||||
}
|
||||
|
||||
interface McpClient {
|
||||
id: McpClientId;
|
||||
label: string;
|
||||
// Function so the dropdown can show different methods per OS
|
||||
// (Claude Code uses CLI on POSIX but JSON edit on Windows because
|
||||
// the bash/PowerShell/cmd.exe quoting is too fragile to reliably
|
||||
// emit a single command that works in every shell).
|
||||
buildMethod: (info: McpInstallInfo) => string;
|
||||
// Function so per-OS path hints (~/.cursor on POSIX vs
|
||||
// %USERPROFILE%\.cursor on Windows) and shortcut differences
|
||||
// (⌘⇧P vs Ctrl+Shift+P) can be rendered correctly.
|
||||
buildInstruction: (info: McpInstallInfo) => string;
|
||||
buildSnippet: (info: McpInstallInfo) => string;
|
||||
buildSnippetLang: (info: McpInstallInfo) => 'bash' | 'json' | 'toml';
|
||||
// Optional one-click install action. Currently only Cursor
|
||||
// supports deeplinks of this shape.
|
||||
buildDeeplink?: (info: McpInstallInfo) => string;
|
||||
deeplinkLabel?: string;
|
||||
}
|
||||
|
||||
// Path hint per OS. Localizes the "where to paste" copy so a
|
||||
// Windows user does not see ~/.cursor/mcp.json (which their shell
|
||||
// will not expand) or a Linux user does not see %APPDATA% paths.
|
||||
function homeConfigPath(
|
||||
platform: McpInstallInfo['platform'],
|
||||
posix: string,
|
||||
windows: string,
|
||||
): string {
|
||||
return platform === 'win32' ? windows : posix;
|
||||
}
|
||||
|
||||
function commandPaletteShortcut(platform: McpInstallInfo['platform']): string {
|
||||
return platform === 'darwin' ? '⌘⇧P' : 'Ctrl+Shift+P';
|
||||
}
|
||||
|
||||
function settingsShortcut(platform: McpInstallInfo['platform']): string {
|
||||
return platform === 'darwin' ? '⌘,' : 'Ctrl+,';
|
||||
}
|
||||
|
||||
// btoa() requires every input character be representable in Latin-1
|
||||
// (codepoints 0-255). A Mac/Linux home directory like
|
||||
// "/Users/Émile/.fnm/.../node" trips that and throws
|
||||
// InvalidCharacterError. UTF-8-encode the string into bytes first,
|
||||
// then map each byte back to a Latin-1 char before base64'ing.
|
||||
function utf8Btoa(s: string): string {
|
||||
const bytes = new TextEncoder().encode(s);
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
function buildSharedMcpJson(info: McpInstallInfo): string {
|
||||
const inner = { command: info.command, args: info.args };
|
||||
const innerJson = JSON.stringify(inner, null, 2)
|
||||
.split('\n')
|
||||
.map((line, i) => (i === 0 ? line : ` ${line}`))
|
||||
.join('\n');
|
||||
return `{
|
||||
"mcpServers": {
|
||||
"open-design": ${innerJson}
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
const MCP_CLIENTS: McpClient[] = [
|
||||
{
|
||||
id: 'claude',
|
||||
label: 'Claude Code',
|
||||
// `claude mcp add-json <name> '<json>'` takes ONLY the inner
|
||||
// server-config object, not the full mcpServers wrapper. We
|
||||
// inline the JSON into the command itself so the snippet is a
|
||||
// real one-liner the user can copy and run, no template
|
||||
// substitution. Single quotes around the JSON work in bash, zsh,
|
||||
// PowerShell, and Git Bash; the only outlier is Windows cmd.exe,
|
||||
// where users would need to swap to PowerShell.
|
||||
buildMethod: () => 'CLI command',
|
||||
buildInstruction: () => 'Run this in your terminal.',
|
||||
buildSnippet: (info) => {
|
||||
const inner = JSON.stringify({ command: info.command, args: info.args });
|
||||
return `claude mcp add-json --scope user open-design '${inner}'`;
|
||||
},
|
||||
buildSnippetLang: () => 'bash',
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
label: 'Codex',
|
||||
// Codex CLI shares config between the terminal CLI and the IDE
|
||||
// extension at ~/.codex/config.toml (TOML, not JSON, and a
|
||||
// different table key from every other client - mcp_servers
|
||||
// rather than mcpServers / servers / context_servers). Schema
|
||||
// ref: https://developers.openai.com/codex/mcp.
|
||||
//
|
||||
// For our payload (just command + args, both strings/arrays of
|
||||
// strings) JSON.stringify happens to produce valid TOML literal
|
||||
// values, since TOML basic strings use the same double-quote
|
||||
// escape rules and TOML inline arrays match JSON array syntax.
|
||||
buildMethod: () => 'TOML config',
|
||||
buildInstruction: (info) => {
|
||||
const path = homeConfigPath(
|
||||
info.platform,
|
||||
'~/.codex/config.toml',
|
||||
'%USERPROFILE%\\.codex\\config.toml',
|
||||
);
|
||||
return `Append this table to ${path}. The same config is shared between the Codex CLI and the Codex IDE extension.`;
|
||||
},
|
||||
buildSnippet: (info) => `[mcp_servers.open-design]
|
||||
command = ${JSON.stringify(info.command)}
|
||||
args = ${JSON.stringify(info.args)}`,
|
||||
buildSnippetLang: () => 'toml',
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
label: 'Cursor',
|
||||
buildMethod: () => 'One-click install',
|
||||
buildInstruction: (info) =>
|
||||
`Click "Install in Cursor" to install with an approval dialog, or merge this JSON into ${homeConfigPath(info.platform, '~/.cursor/mcp.json', '%USERPROFILE%\\.cursor\\mcp.json')}.`,
|
||||
buildSnippet: buildSharedMcpJson,
|
||||
buildSnippetLang: () => 'json',
|
||||
buildDeeplink: (info) => {
|
||||
const inner = { command: info.command, args: info.args };
|
||||
// Cursor expects the inner server-config object base64-encoded
|
||||
// as ?config=...; the handler decodes it and pops an approval
|
||||
// dialog before writing to mcp.json. We UTF-8-encode first so
|
||||
// non-Latin1 chars in paths (e.g. an accented username) do not
|
||||
// throw from btoa().
|
||||
const encoded = utf8Btoa(JSON.stringify(inner));
|
||||
return `cursor://anysphere.cursor-deeplink/mcp/install?name=open-design&config=${encoded}`;
|
||||
},
|
||||
deeplinkLabel: 'Install in Cursor',
|
||||
},
|
||||
{
|
||||
id: 'vscode',
|
||||
label: 'VS Code',
|
||||
buildMethod: () => 'JSON config',
|
||||
buildInstruction: (info) =>
|
||||
`Open the Command Palette (${commandPaletteShortcut(info.platform)}), run "MCP: Open User Configuration", and merge this JSON. Copilot Chat must be in Agent mode for tools to show up.`,
|
||||
buildSnippet: (info) => `{
|
||||
"servers": {
|
||||
"open-design": {
|
||||
"type": "stdio",
|
||||
"command": ${JSON.stringify(info.command)},
|
||||
"args": ${JSON.stringify(info.args)}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
buildSnippetLang: () => 'json',
|
||||
},
|
||||
{
|
||||
id: 'antigravity',
|
||||
label: 'Antigravity',
|
||||
buildMethod: () => 'JSON config',
|
||||
buildInstruction: () =>
|
||||
'In Antigravity: Agent panel "..." menu → MCP Servers → Manage MCP Servers → View raw config. Merge this JSON.',
|
||||
buildSnippet: buildSharedMcpJson,
|
||||
buildSnippetLang: () => 'json',
|
||||
},
|
||||
{
|
||||
id: 'zed',
|
||||
label: 'Zed',
|
||||
buildMethod: () => 'JSON config',
|
||||
buildInstruction: (info) =>
|
||||
`Open Zed Settings (${settingsShortcut(info.platform)}) and merge this into the top-level object. Zed uses "context_servers", not "mcpServers".`,
|
||||
buildSnippet: (info) => `{
|
||||
"context_servers": {
|
||||
"open-design": {
|
||||
"source": "custom",
|
||||
"command": ${JSON.stringify(info.command)},
|
||||
"args": ${JSON.stringify(info.args)}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
buildSnippetLang: () => 'json',
|
||||
},
|
||||
{
|
||||
id: 'windsurf',
|
||||
label: 'Windsurf',
|
||||
buildMethod: () => 'JSON config',
|
||||
buildInstruction: (info) =>
|
||||
`Open ${homeConfigPath(info.platform, '~/.codeium/windsurf/mcp_config.json', '%USERPROFILE%\\.codeium\\windsurf\\mcp_config.json')} (or use the MCPs icon in Cascade → Configure) and merge:`,
|
||||
buildSnippet: buildSharedMcpJson,
|
||||
buildSnippetLang: () => 'json',
|
||||
},
|
||||
];
|
||||
|
||||
function IntegrationsSection() {
|
||||
const [clientId, setClientId] = useState<McpClientId>('claude');
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [info, setInfo] = useState<McpInstallInfo | null>(null);
|
||||
const [infoError, setInfoError] = useState<string | null>(null);
|
||||
const pickerRef = useRef<HTMLDivElement | null>(null);
|
||||
// The reset is wired through a ref-driven timer rather than effect
|
||||
// cleanup so re-clicks during the 2s window restart the countdown.
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Close the dropdown on outside click or Escape.
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (!pickerRef.current) return;
|
||||
if (!pickerRef.current.contains(e.target as Node)) setPickerOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setPickerOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDoc);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [pickerOpen]);
|
||||
|
||||
// Pull the absolute paths to node + cli.js from the running daemon
|
||||
// so snippets work even when `od` isn't on PATH (the realistic
|
||||
// case for source clones, plus macOS/Linux ship a /usr/bin/od that
|
||||
// shadows any global install). Fetched on mount; if the daemon is
|
||||
// unreachable we surface a clear error instead of a half-built
|
||||
// snippet that would silently fail when pasted.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch('/api/mcp/install-info')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`daemon ${res.status}`);
|
||||
return (await res.json()) as McpInstallInfo;
|
||||
})
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setInfo(data);
|
||||
setInfoError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setInfoError(String(err && err.message ? err.message : err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const client = MCP_CLIENTS.find((c) => c.id === clientId) ?? MCP_CLIENTS[0]!;
|
||||
const snippet = info ? client.buildSnippet(info) : '';
|
||||
const snippetLang: 'bash' | 'json' | 'toml' = info
|
||||
? client.buildSnippetLang(info)
|
||||
: 'json';
|
||||
|
||||
// Reset the "Copied" badge when the user flips to a different
|
||||
// client; otherwise the green check sits there next to a snippet
|
||||
// they haven't actually copied.
|
||||
useEffect(() => {
|
||||
setCopied(false);
|
||||
if (copyTimerRef.current) {
|
||||
clearTimeout(copyTimerRef.current);
|
||||
copyTimerRef.current = null;
|
||||
}
|
||||
}, [clientId]);
|
||||
|
||||
const onCopy = async () => {
|
||||
if (!snippet) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(snippet);
|
||||
setCopied(true);
|
||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
||||
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard API can fail under non-secure contexts; the snippet
|
||||
// is selectable so the user can still copy manually.
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h3>MCP server</h3>
|
||||
<p className="hint">
|
||||
Lets a coding agent in another repo (Claude Code, Cursor,
|
||||
VS Code, Antigravity, Zed, Windsurf) read your Open Design
|
||||
projects. Use it to pull a design into your app without
|
||||
exporting a zip first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-about-list" style={{ display: 'block' }}>
|
||||
{infoError ? (
|
||||
<div
|
||||
className="empty-card"
|
||||
style={{ marginBottom: 14, color: 'var(--danger-fg, #f88)' }}
|
||||
>
|
||||
Couldn’t reach the local daemon to resolve install paths
|
||||
({infoError}). Make sure Open Design is running, then reopen this
|
||||
panel.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{info && (!info.cliExists || !info.nodeExists) ? (
|
||||
<div
|
||||
className="empty-card"
|
||||
style={{
|
||||
marginBottom: 14,
|
||||
borderLeft: '3px solid var(--warning-fg, #fbbf24)',
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
{!info.cliExists
|
||||
? 'Build the daemon first.'
|
||||
: 'Node binary is missing.'}
|
||||
</strong>{' '}
|
||||
{info.buildHint ??
|
||||
'apps/daemon/dist/cli.js is missing. Run `pnpm build` and refresh.'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="ds-picker"
|
||||
ref={pickerRef}
|
||||
style={{ marginBottom: 14 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`ds-picker-trigger${pickerOpen ? ' open' : ''}`}
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={pickerOpen}
|
||||
>
|
||||
<span className="ds-picker-meta">
|
||||
<span className="ds-picker-title">{client.label}</span>
|
||||
<span className="ds-picker-sub">
|
||||
{info ? client.buildMethod(info) : ''}
|
||||
</span>
|
||||
</span>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size={14}
|
||||
className="ds-picker-chevron"
|
||||
style={{ transform: pickerOpen ? 'rotate(180deg)' : undefined }}
|
||||
/>
|
||||
</button>
|
||||
{pickerOpen ? (
|
||||
<div className="ds-picker-popover" role="listbox">
|
||||
<div className="ds-picker-list">
|
||||
{MCP_CLIENTS.map((c) => {
|
||||
const active = c.id === clientId;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`ds-picker-item${active ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
setClientId(c.id);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="ds-picker-item-text">
|
||||
<span className="ds-picker-item-title">{c.label}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
{info ? c.buildMethod(info) : ''}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{info ? (
|
||||
<p style={{ margin: '0 0 10px' }}>{client.buildInstruction(info)}</p>
|
||||
) : null}
|
||||
|
||||
{client.buildDeeplink && info ? (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={() => {
|
||||
// Use a hidden anchor so the cursor:// scheme is
|
||||
// handled the same way as a normal link click; some
|
||||
// browsers block window.location assignments to
|
||||
// unknown schemes from button handlers.
|
||||
const url = client.buildDeeplink!(info);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.click();
|
||||
}}
|
||||
disabled={!info.cliExists || !info.nodeExists}
|
||||
style={{ padding: '6px 14px', fontSize: 13 }}
|
||||
>
|
||||
<Icon name="link" size={14} />
|
||||
<span style={{ marginLeft: 6 }}>{client.deeplinkLabel}</span>
|
||||
</button>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 10,
|
||||
fontSize: 12,
|
||||
color: 'var(--fg-2, #9aa0a6)',
|
||||
}}
|
||||
>
|
||||
Cursor pops an approval dialog before writing the config.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<pre
|
||||
style={{
|
||||
background: 'var(--surface-2, #11141a)',
|
||||
color: 'var(--fg-1, #e6e6e6)',
|
||||
padding: '12px 14px',
|
||||
borderRadius: 8,
|
||||
overflowX: 'auto',
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.55,
|
||||
margin: 0,
|
||||
userSelect: 'text',
|
||||
whiteSpace: snippetLang === 'bash' ? 'pre-wrap' : 'pre',
|
||||
wordBreak: snippetLang === 'bash' ? 'break-all' : 'normal',
|
||||
minHeight: 60,
|
||||
}}
|
||||
data-lang={snippetLang}
|
||||
>
|
||||
<code>
|
||||
{snippet ||
|
||||
(infoError
|
||||
? '# resolving paths failed, see the error above'
|
||||
: '# loading install paths from the local daemon…')}
|
||||
</code>
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={onCopy}
|
||||
disabled={!snippet}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
aria-label="Copy MCP configuration snippet"
|
||||
>
|
||||
<Icon name={copied ? 'check' : 'copy'} size={14} />
|
||||
<span style={{ marginLeft: 6 }}>{copied ? 'Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
padding: '10px 12px',
|
||||
background: 'var(--bg-subtle)',
|
||||
border: '1px solid var(--border)',
|
||||
borderLeft: '3px solid var(--accent)',
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<strong>Restart your client to pick up the new server.</strong>{' '}
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
Most editors only load MCP servers at startup. In Cursor / VS
|
||||
Code / Antigravity / Windsurf you can run{' '}
|
||||
<code>Developer: Reload Window</code> from the command palette
|
||||
instead of a full restart. Zed and Claude Code need a quit and
|
||||
reopen.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 20, lineHeight: 1.55 }}>
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 8px',
|
||||
fontSize: 11,
|
||||
color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
What your agent can do
|
||||
</p>
|
||||
<ul
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingLeft: 18,
|
||||
fontSize: 13,
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
<li>
|
||||
Read or search any file in a project (HTML, JSX, CSS, JSON,
|
||||
SVG, Markdown).
|
||||
</li>
|
||||
<li>
|
||||
Pull a design bundle in one call: the entry file plus every
|
||||
CSS variable, component, and font it references.
|
||||
</li>
|
||||
<li>
|
||||
Default to the project and file you have open in Open Design,
|
||||
so you can say “build this in my app” without
|
||||
re-stating which design.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
marginTop: 14,
|
||||
fontSize: 12,
|
||||
color: 'var(--text-muted)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Open Design must be running for MCP tool calls to succeed. If
|
||||
you started your coding agent before opening Open Design,
|
||||
restart the agent so it can reach the live daemon.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const THEMES: Array<{ value: AppTheme; labelKey: 'settings.themeSystem' | 'settings.themeLight' | 'settings.themeDark' }> = [
|
||||
{ value: 'system', labelKey: 'settings.themeSystem' },
|
||||
{ value: 'light', labelKey: 'settings.themeLight' },
|
||||
|
|
|
|||
338
pnpm-lock.yaml
338
pnpm-lock.yaml
|
|
@ -17,6 +17,9 @@ importers:
|
|||
|
||||
apps/daemon:
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.0.0
|
||||
version: 1.29.0(zod@4.4.2)
|
||||
'@open-design/contracts':
|
||||
specifier: workspace:0.3.0
|
||||
version: link:../../packages/contracts
|
||||
|
|
@ -1011,6 +1014,12 @@ packages:
|
|||
'@noble/hashes':
|
||||
optional: true
|
||||
|
||||
'@hono/node-server@1.19.14':
|
||||
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -1179,6 +1188,16 @@ packages:
|
|||
resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.29.0':
|
||||
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@cfworker/json-schema': ^4.1.1
|
||||
zod: ^3.25 || ^4.0
|
||||
peerDependenciesMeta:
|
||||
'@cfworker/json-schema':
|
||||
optional: true
|
||||
|
||||
'@next/env@16.2.4':
|
||||
resolution: {integrity: sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==}
|
||||
|
||||
|
|
@ -1631,6 +1650,10 @@ packages:
|
|||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
acorn@8.16.0:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
|
@ -1652,6 +1675,14 @@ packages:
|
|||
ajv:
|
||||
optional: true
|
||||
|
||||
ajv-formats@3.0.1:
|
||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||
peerDependencies:
|
||||
ajv: ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
ajv:
|
||||
optional: true
|
||||
|
||||
ajv-keywords@3.5.2:
|
||||
resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -1795,6 +1826,10 @@ packages:
|
|||
resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
|
||||
body-parser@2.2.2:
|
||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
|
|
@ -1987,6 +2022,10 @@ packages:
|
|||
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
content-disposition@1.1.0:
|
||||
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
content-type@1.0.5:
|
||||
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
|
@ -1997,6 +2036,10 @@ packages:
|
|||
cookie-signature@1.0.7:
|
||||
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
|
||||
|
||||
cookie-signature@1.2.2:
|
||||
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
||||
engines: {node: '>=6.6.0'}
|
||||
|
||||
cookie@0.7.2:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
|
@ -2011,6 +2054,10 @@ packages:
|
|||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
cors@2.8.6:
|
||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
crc@3.8.0:
|
||||
resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==}
|
||||
|
||||
|
|
@ -2324,6 +2371,14 @@ packages:
|
|||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
eventsource-parser@3.0.8:
|
||||
resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
eventsource@3.0.7:
|
||||
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
expand-template@2.0.3:
|
||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -2335,10 +2390,20 @@ packages:
|
|||
exponential-backoff@3.1.3:
|
||||
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
|
||||
|
||||
express-rate-limit@8.4.1:
|
||||
resolution: {integrity: sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
express: '>= 4.11'
|
||||
|
||||
express@4.22.1:
|
||||
resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==}
|
||||
engines: {node: '>= 0.10.0'}
|
||||
|
||||
express@5.2.1:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
extend@3.0.2:
|
||||
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
||||
|
||||
|
|
@ -2382,6 +2447,10 @@ packages:
|
|||
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
finalhandler@2.1.1:
|
||||
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
||||
engines: {node: '>= 18.0.0'}
|
||||
|
||||
flattie@1.1.1:
|
||||
resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -2412,6 +2481,10 @@ packages:
|
|||
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
fresh@2.0.0:
|
||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
fs-constants@1.0.0:
|
||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||
|
||||
|
|
@ -2555,6 +2628,10 @@ packages:
|
|||
hastscript@9.0.1:
|
||||
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
|
||||
|
||||
hono@4.12.16:
|
||||
resolution: {integrity: sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
hosted-git-info@4.1.0:
|
||||
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -2604,6 +2681,10 @@ packages:
|
|||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
|
|
@ -2623,6 +2704,10 @@ packages:
|
|||
ini@1.3.8:
|
||||
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||
|
||||
ip-address@10.1.0:
|
||||
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
|
@ -2651,6 +2736,9 @@ packages:
|
|||
is-potential-custom-element-name@1.0.1:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
is-promise@4.0.0:
|
||||
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||
|
||||
is-wsl@3.1.1:
|
||||
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
|
||||
engines: {node: '>=16'}
|
||||
|
|
@ -2686,6 +2774,9 @@ packages:
|
|||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.2.3:
|
||||
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
|
|
@ -2711,6 +2802,9 @@ packages:
|
|||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
json-schema-typed@8.0.2:
|
||||
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
|
||||
|
||||
json-stringify-safe@5.0.1:
|
||||
resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
|
||||
|
||||
|
|
@ -2846,9 +2940,17 @@ packages:
|
|||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
media-typer@1.1.0:
|
||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
merge-descriptors@1.0.3:
|
||||
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
|
||||
|
||||
merge-descriptors@2.0.0:
|
||||
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
methods@1.1.2:
|
||||
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
|
@ -2941,10 +3043,18 @@ packages:
|
|||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-db@1.54.0:
|
||||
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@3.0.2:
|
||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
mime@1.6.0:
|
||||
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -3026,6 +3136,10 @@ packages:
|
|||
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
negotiator@1.0.0:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
neotraverse@0.6.18:
|
||||
resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==}
|
||||
engines: {node: '>= 10'}
|
||||
|
|
@ -3205,6 +3319,9 @@ packages:
|
|||
path-to-regexp@0.1.13:
|
||||
resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==}
|
||||
|
||||
path-to-regexp@8.4.2:
|
||||
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
|
||||
|
||||
pathe@1.1.2:
|
||||
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
||||
|
||||
|
|
@ -3233,6 +3350,10 @@ packages:
|
|||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pkce-challenge@5.0.1:
|
||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
|
||||
playwright-core@1.59.1:
|
||||
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -3342,6 +3463,10 @@ packages:
|
|||
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
raw-body@3.0.2:
|
||||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
rc@1.2.8:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
|
@ -3471,6 +3596,10 @@ packages:
|
|||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
router@2.2.0:
|
||||
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
|
|
@ -3514,6 +3643,10 @@ packages:
|
|||
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
send@1.2.1:
|
||||
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
serialize-error@7.0.1:
|
||||
resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -3522,6 +3655,10 @@ packages:
|
|||
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
serve-static@2.2.1:
|
||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
|
|
@ -3812,6 +3949,10 @@ packages:
|
|||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
type-is@2.0.1:
|
||||
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
typedarray@0.0.6:
|
||||
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||
|
||||
|
|
@ -4894,6 +5035,10 @@ snapshots:
|
|||
|
||||
'@exodus/bytes@1.15.0': {}
|
||||
|
||||
'@hono/node-server@1.19.14(hono@4.12.16)':
|
||||
dependencies:
|
||||
hono: 4.12.16
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
optional: true
|
||||
|
||||
|
|
@ -5010,6 +5155,28 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@modelcontextprotocol/sdk@1.29.0(zod@4.4.2)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.14(hono@4.12.16)
|
||||
ajv: 8.20.0
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
content-type: 1.0.5
|
||||
cors: 2.8.6
|
||||
cross-spawn: 7.0.6
|
||||
eventsource: 3.0.7
|
||||
eventsource-parser: 3.0.8
|
||||
express: 5.2.1
|
||||
express-rate-limit: 8.4.1(express@5.2.1)
|
||||
hono: 4.12.16
|
||||
jose: 6.2.3
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
raw-body: 3.0.2
|
||||
zod: 4.4.2
|
||||
zod-to-json-schema: 3.25.2(zod@4.4.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@next/env@16.2.4': {}
|
||||
|
||||
'@next/swc-darwin-arm64@16.2.4':
|
||||
|
|
@ -5450,6 +5617,11 @@ snapshots:
|
|||
mime-types: 2.1.35
|
||||
negotiator: 0.6.3
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
negotiator: 1.0.0
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
|
@ -5462,6 +5634,10 @@ snapshots:
|
|||
optionalDependencies:
|
||||
ajv: 8.20.0
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.20.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.20.0
|
||||
|
||||
ajv-keywords@3.5.2(ajv@6.15.0):
|
||||
dependencies:
|
||||
ajv: 6.15.0
|
||||
|
|
@ -5730,6 +5906,20 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
body-parser@2.2.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 1.0.5
|
||||
debug: 4.4.3
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.2
|
||||
on-finished: 2.4.1
|
||||
qs: 6.15.1
|
||||
raw-body: 3.0.2
|
||||
type-is: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
boolean@3.2.0:
|
||||
|
|
@ -5931,12 +6121,16 @@ snapshots:
|
|||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
content-disposition@1.1.0: {}
|
||||
|
||||
content-type@1.0.5: {}
|
||||
|
||||
cookie-es@1.2.3: {}
|
||||
|
||||
cookie-signature@1.0.7: {}
|
||||
|
||||
cookie-signature@1.2.2: {}
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
|
@ -5946,6 +6140,11 @@ snapshots:
|
|||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cors@2.8.6:
|
||||
dependencies:
|
||||
object-assign: 4.1.1
|
||||
vary: 1.1.2
|
||||
|
||||
crc@3.8.0:
|
||||
dependencies:
|
||||
buffer: 5.7.1
|
||||
|
|
@ -6346,12 +6545,23 @@ snapshots:
|
|||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
eventsource-parser@3.0.8: {}
|
||||
|
||||
eventsource@3.0.7:
|
||||
dependencies:
|
||||
eventsource-parser: 3.0.8
|
||||
|
||||
expand-template@2.0.3: {}
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
exponential-backoff@3.1.3: {}
|
||||
|
||||
express-rate-limit@8.4.1(express@5.2.1):
|
||||
dependencies:
|
||||
express: 5.2.1
|
||||
ip-address: 10.1.0
|
||||
|
||||
express@4.22.1:
|
||||
dependencies:
|
||||
accepts: 1.3.8
|
||||
|
|
@ -6388,6 +6598,39 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
express@5.2.1:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
body-parser: 2.2.2
|
||||
content-disposition: 1.1.0
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.2
|
||||
cookie-signature: 1.2.2
|
||||
debug: 4.4.3
|
||||
depd: 2.0.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
finalhandler: 2.1.1
|
||||
fresh: 2.0.0
|
||||
http-errors: 2.0.1
|
||||
merge-descriptors: 2.0.0
|
||||
mime-types: 3.0.2
|
||||
on-finished: 2.4.1
|
||||
once: 1.4.0
|
||||
parseurl: 1.3.3
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.15.1
|
||||
range-parser: 1.2.1
|
||||
router: 2.2.0
|
||||
send: 1.2.1
|
||||
serve-static: 2.2.1
|
||||
statuses: 2.0.2
|
||||
type-is: 2.0.1
|
||||
vary: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
extend@3.0.2: {}
|
||||
|
||||
extract-zip@2.0.1:
|
||||
|
|
@ -6435,6 +6678,17 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
finalhandler@2.1.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
on-finished: 2.4.1
|
||||
parseurl: 1.3.3
|
||||
statuses: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
flattie@1.1.1: {}
|
||||
|
||||
fontace@0.4.1:
|
||||
|
|
@ -6464,6 +6718,8 @@ snapshots:
|
|||
|
||||
fresh@0.5.2: {}
|
||||
|
||||
fresh@2.0.0: {}
|
||||
|
||||
fs-constants@1.0.0: {}
|
||||
|
||||
fs-extra@10.1.0:
|
||||
|
|
@ -6700,6 +6956,8 @@ snapshots:
|
|||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
|
||||
hono@4.12.16: {}
|
||||
|
||||
hosted-git-info@4.1.0:
|
||||
dependencies:
|
||||
lru-cache: 6.0.0
|
||||
|
|
@ -6761,6 +7019,10 @@ snapshots:
|
|||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
|
@ -6776,6 +7038,8 @@ snapshots:
|
|||
|
||||
ini@1.3.8: {}
|
||||
|
||||
ip-address@10.1.0: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
iron-webcrypto@1.2.1: {}
|
||||
|
|
@ -6792,6 +7056,8 @@ snapshots:
|
|||
|
||||
is-potential-custom-element-name@1.0.1: {}
|
||||
|
||||
is-promise@4.0.0: {}
|
||||
|
||||
is-wsl@3.1.1:
|
||||
dependencies:
|
||||
is-inside-container: 1.0.0
|
||||
|
|
@ -6816,6 +7082,8 @@ snapshots:
|
|||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
jose@6.2.3: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
|
|
@ -6854,6 +7122,8 @@ snapshots:
|
|||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
json-schema-typed@8.0.2: {}
|
||||
|
||||
json-stringify-safe@5.0.1:
|
||||
optional: true
|
||||
|
||||
|
|
@ -7059,8 +7329,12 @@ snapshots:
|
|||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
||||
merge-descriptors@1.0.3: {}
|
||||
|
||||
merge-descriptors@2.0.0: {}
|
||||
|
||||
methods@1.1.2: {}
|
||||
|
||||
micromark-core-commonmark@2.0.3:
|
||||
|
|
@ -7256,10 +7530,16 @@ snapshots:
|
|||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-db@1.54.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mime-types@3.0.2:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
mime@1.6.0: {}
|
||||
|
||||
mime@2.6.0: {}
|
||||
|
|
@ -7322,6 +7602,8 @@ snapshots:
|
|||
|
||||
negotiator@0.6.3: {}
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
neotraverse@0.6.18: {}
|
||||
|
||||
next@16.2.4(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
|
|
@ -7486,6 +7768,8 @@ snapshots:
|
|||
|
||||
path-to-regexp@0.1.13: {}
|
||||
|
||||
path-to-regexp@8.4.2: {}
|
||||
|
||||
pathe@1.1.2: {}
|
||||
|
||||
pathval@2.0.1: {}
|
||||
|
|
@ -7502,6 +7786,8 @@ snapshots:
|
|||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pkce-challenge@5.0.1: {}
|
||||
|
||||
playwright-core@1.59.1: {}
|
||||
|
||||
playwright@1.59.1:
|
||||
|
|
@ -7622,6 +7908,13 @@ snapshots:
|
|||
iconv-lite: 0.4.24
|
||||
unpipe: 1.0.0
|
||||
|
||||
raw-body@3.0.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.2
|
||||
unpipe: 1.0.0
|
||||
|
||||
rc@1.2.8:
|
||||
dependencies:
|
||||
deep-extend: 0.6.0
|
||||
|
|
@ -7835,6 +8128,16 @@ snapshots:
|
|||
'@rollup/rollup-win32-x64-msvc': 4.60.2
|
||||
fsevents: 2.3.3
|
||||
|
||||
router@2.2.0:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
depd: 2.0.0
|
||||
is-promise: 4.0.0
|
||||
parseurl: 1.3.3
|
||||
path-to-regexp: 8.4.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
|
@ -7882,6 +8185,22 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
fresh: 2.0.0
|
||||
http-errors: 2.0.1
|
||||
mime-types: 3.0.2
|
||||
ms: 2.1.3
|
||||
on-finished: 2.4.1
|
||||
range-parser: 1.2.1
|
||||
statuses: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
serialize-error@7.0.1:
|
||||
dependencies:
|
||||
type-fest: 0.13.1
|
||||
|
|
@ -7896,6 +8215,15 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
serve-static@2.2.1:
|
||||
dependencies:
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
parseurl: 1.3.3
|
||||
send: 1.2.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
|
@ -8217,6 +8545,12 @@ snapshots:
|
|||
media-typer: 0.3.0
|
||||
mime-types: 2.1.35
|
||||
|
||||
type-is@2.0.1:
|
||||
dependencies:
|
||||
content-type: 1.0.5
|
||||
media-typer: 1.1.0
|
||||
mime-types: 3.0.2
|
||||
|
||||
typedarray@0.0.6: {}
|
||||
|
||||
typesafe-path@0.2.2: {}
|
||||
|
|
@ -8725,6 +9059,10 @@ snapshots:
|
|||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-to-json-schema@3.25.2(zod@4.4.2):
|
||||
dependencies:
|
||||
zod: 4.4.2
|
||||
|
||||
zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
|
|
|||
Loading…
Reference in a new issue