Skip to content

A headless CLI for mcp - #11

Merged
chenyan2002 merged 7 commits into
mainfrom
mcp
Oct 25, 2026
Merged

chenyan2002 merged 7 commits into
mainfrom
mcp

Conversation

@chenyan2002

@chenyan2002 chenyan2002 commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

This branch makes component-ui usable by agents. A command-line frontend runs a component
without its host, answers its imports from a file, and writes each run back as a file that
reproduces it. A skill teaches agents the workflow. To get there, the core is separated from
the UI and shared with the CLI, and values are read and written with proxy-component's WAVE
codec. The UI gains those features too: trap stacks, saving sessions, and answering imports
by default.

Command line (component-ui)

  • inspect app.wasm lists:
    • the exports to call, and the host resources they use, whose functions can make arguments
    • the imports that ask for answers, and those answered on their own (constructors, and
      functions without a result)
    • the named types they use
  • call app.wasm '<call>'… [--answers f] [--save f] runs calls on one instance. Calls are
    written as the console shows them: iface.func(1, "a"), new iface/res(1),
    iface/res.method(#borrow-res-1, …), drop #res-1, with WAVE arguments.
  • The answers file is the run. It is checked strictly on replay.
    • > call lines, then each import the guest made, in order:
      • an answer, import(args) = value, or !trap to trap the guest
      • a hole, = ?, where the run stopped
      • a //! line for an import that didn't ask, e.g. //! imports/y.set-a(#borrow-y-1, 20)
        or //! drop #y-2
    • New mocks are written #mock-<id>, so later uses of the id point back to where it was
      created, and replay checks the mock gets that id.
    • A line that no longer matches the guest's calls stops the run, naming the line to
      restart from.
  • The happy path past the file's end, --defaults none|careful|eager (default eager):
    • ok for results, and records and tuples of those
    • new mocks for handles, noting the instances passed over
    • --save marks these // default, for the agent to review
  • Stops show:
    • the expected type, and the definitions of the named types it uses
    • where the call's instances came from
    • the instances the answer could use
  • Exit codes: 0 when every call returned, 2 when an answer is needed, 3 when a call
    failed (e.g. trapped), 1 on errors.
  • Traps list the guest's stack, demangled, without its runtime's frames:
    ! cannot block a synchronous task before returning
    at kv_store_component.wasm: <fastly::http::response::handle::ResponseHandle as core::ops::drop::Drop>::drop
    at kv_store_component.wasm: kv_store_component::…::kv_store::Entry::dtor
    at kv_test-….wasm: kv_test::write_entry
  • JavaScript imports: --imports <pkg>=<file.js> implements imports in JavaScript, and
    template app.wasm <pkg> prints a module to start from. A function skipped because an
    argument is a mock says so, on its line and in a final warning.
  • Running it: npm run build:cli builds dist-cli/component-ui.mjs, a bin that
    npm link puts on the PATH. It needs Node 23 or later, and restarts itself with
    --experimental-wasm-jspi where needed.

Agent skill (skills/component-ui/SKILL.md)

  • The workflow: inspect, run with --save, fill in the ?, rerun.
  • Reviewing defaults: when the guest probably expects an existing instance rather than a
    mock.
  • Exploring: editing answers, e.g. to err(…) or !trap.
  • Reading runs and traps: including that a panic right after an answer may mean the answer
    broke the host's contract, and that agents should ask for the original commented WIT, which
    components don't embed.
  • Reference: WAVE and call syntax, the answers file format, options, and limits.
  • Tested by two fresh agents with only the skill and the CLI. One made resource.wasm's
    test-imports() return ok. The other traced kv-store.wasm's handling of a GET request,
    and found that it sends its KV lookup twice. Their friction is fixed here: trap reporting,
    stack frames, inspect's sections, a full trace in the file, instance provenance in hints,
    and skill clarifications.

UI

  • Trap stacks in the console, demangled.
  • Console settings (gear button):
  • how imports are answered: "Ask for every result", "Fill in obvious results", or "Fill in,
    preferring new mocks", the CLI's three levels; defaulted answers are marked // default
  • Save logs, which writes the session as an answers file the CLI replays and continues
  • Clear logs
  • The instance picker shows each instance's provenance as a tooltip.
  • The console shows the guest's drops of host instances.
  • Visible changes from the shared WAVE writer:
  • none record fields are omitted
  • both quote kinds are escaped
  • floats have no exponents
  • a borrow is #borrow-x-1 (was #borrowed-x-1)
  • a method is labelled iface/res.method(#borrow-x-1, …) (was #borrowed-x-1.method(…))
  • a list<u8> of text shows as b"…"

Core changes

  • Core and UI separated (src/platform*.ts, src/log.ts). In Node, the core uses the
    browser build of preview2-shim: its Node build exits the process on wasi:cli/exit, leaks
    the host's environment, and keeps global state.
  • Structured call records (src/call-record.ts): interface, resource, kind, function,
    WAVE arguments with a method's self first, and a callee.
  • One call log for UI and CLI (src/call-log.ts): calls, imports, output and instance
    creations, in order. The console renders it; the CLI prints it and writes answers files
    from it.
  • WAVE from proxy-component's fork of wasm-wave, in crates/bindgen (format/parse).
    The UI and CLI write values exactly as proxy-component does. The parser checks handle labels
    as strictly as proxy-component, and adds #mock, #mock-<id> and b"…".
  • demangle in crates/bindgen, with rustc-demangle and cpp_demangle. It adds 62 KB
    to the gzipped bindgen.
  • Shared answering (src/answering.ts): the happy path, promptContext, defaultAnswer
    and answerPrompt, used by both the driver and the console.

Docs

  • docs/proxy-component-gap.md: where component-ui and proxy-component differ (trace format,
    names, handles, coverage), what now matches (the value encoding), and how answers files
    relate to proxy-component's traces.
  • README: features, the command line, how it works, and known limitations:
  • imports can't ask from resource destructors, during instantiation, or from exported
    constructors and statics, all because of where jco can suspend
  • the embedded WIT lacks doc comments
  • WASI isn't recorded or deterministic
  • wasi:cli/exit isn't reflected in the exit code

Testing

  • npm run test:node, 30 tests, covering:
  • the core in Node
  • the WAVE codec: proxy-component's output, round trips, handles, b"…", #mock-<id>
  • demangling and frame filtering
  • the CLI end to end: holes, --save replays, divergences, defaults levels, traps, mocks
    in call arguments, drops, JavaScript imports
  • Playwright, 33 tests, updated for the new labels and formats, plus new tests for Save logs,
    the defaults levels, and trap frames.
  • CI runs the Node tests in their own job, on Node 24, the version for both workflows now:
    Node 22's V8 lacks WebAssembly.Suspending.

Not in this PR

  • An MCP server, which would keep a guest suspended between tool calls. It can be built on
    the shared driver.
  • Deterministic WASI (seeded random numbers, fixed clocks), which would make replays exact
    where guests use them.
  • Interop with proxy-component traces. The gap and the remaining steps are documented.
  • Answering imports from resource destructors. It needs jco to return synchronous answers
    from its JSPI trampolines.

- src/log.ts: the LogEntry, ImportPrompt and ImportLog types moved out of console.ts. The core no longer imports anything from the UI.
- src/platform.ts: a Platform interface with one method, importModule.
  - platform-browser.ts imports through blob: URLs and now holds readWasm.
  - platform-node.ts imports from temporary files, which keeps stack traces short.
  - loadComponent and instantiate take the platform as an argument.
- WASI in Node uses the browser build of preview2-shim. The Node build calls process.exit on wasi:cli/exit, passes the host's environment to the guest, ignores our output callbacks, and keeps global state. Node bundles must therefore resolve the shim with the browser condition.
- Bindgen is now transpiled without --no-nodejs-compat, so it loads its core wasm with fs under Node. Only that compat code changed in src/obj/bindgen.js.
- tsconfig.json leaves out platform-node.ts. It is typechecked through tests/tsconfig.json instead.
- Node tests: tests/node/core.test.ts is bundled by scripts/build-node-tests.mjs and run with npm run test:node. It has 3 tests:
  - JSPI is available
  - an import without an implementation waits for its answer (calculator)
  - stdout and stderr reach the output callbacks (printer)
Every logged call now carries a structured CallRecord, and the console shows exactly the same text as before. Typecheck, the build, the Node tests (4/4, one of them new) and Playwright (30/30) all pass. Nothing is committed.

The record (src/call-record.ts):
{ kind: 'export' | 'host' | 'import', callKind: 'func' | 'constructor' | 'method' | 'static' | 'drop',
  iface, resource?, func, self?, args: string[] /* one WAVE string each */, label }
- kind says who calls whom:
  - export: the UI calls the guest.
  - host: the UI calls the host directly, e.g. a method of a host resource.
  - import: the guest calls the host.
- label is the text the console already showed, built the same way as before.

What changed:
- One builder for every label: describeCall and describeDrop replace eight hand-built label strings in calls.ts, dialog.ts and host-resources.ts.
- Naming helpers moved: qualified, qualifiedResource and unversioned moved from host-resources.ts into call-record.ts, which avoids an import cycle.
- Arguments formatted one by one: wave.ts has formatArgList, which formats each argument separately. It replaces formatArgs, which joined them.
- Loggers take records: ExportsContext.log, the ImportDialogs logger, and the console's logCall and logImport now take a CallRecord instead of a string. App messages that aren't calls, like a failed file load, go through a new logAction(text).
- New lookup: ResourceRegistry.instance(id) finds an instance by id, which drops need for their record.
component-ui now reads and writes WAVE with proxy-component's own wasm-wave fork, compiled into crates/bindgen. Values and handle labels are written byte for byte as proxy-component writes them. The parser accepts the same handle labels proxy-component does, plus two component-ui extensions (#mock and host- ids).

Rust: crates/bindgen

- Dependency: wasm-wave from chenyan2002/wasm-tools, pinned to the commit proxy-component uses (efecaa18). The default wit feature is off, so it doesn't pull in an older wit-parser.
- Interface: a new wave interface in wit/bindgen.wit exports format(type, value) and parse(type, text).
  - Types and values cross the boundary as flat node lists, since WIT types can't be recursive. Children are referenced by index, and the root is the last node.
  - jco marshals the numbers exactly: bigints, f32, NaN, -0.
- Implementation: src/wave.rs, built on the fork's to_string and from_str.
- Generated code: src/obj is regenerated. The core wasm grew from 308 KB to 383 KB gzipped.

TypeScript: src/wave.ts

- Writing: the hand-written writer is gone. formatValue, formatArgList, formatResult and formatThrown keep their signatures and only convert jco values to nodes, so no caller changed.
  - Values that don't match their type (e.g. 3.5 for a u32) are caught in TS and still show as <invalid u32>, instead of being silently converted by jco.
- Parsing: the new parseWave(type, text) returns a form Draft. The existing parseDraft then does the instance-ownership checks and mock creation, exactly as for input typed into a form.
- Handle labels match proxy-component:
  - It writes #x-1 for an owned handle and #borrow-x-1 for a borrowed one. Borrowed handles used to be written #borrowed-x-1.
  - It reads only those forms. The label must name the handle's resource and carry borrow- exactly for a borrow; anything else is a parse error.
  - Extensions: #mock asks for a new mock, and host- ids (e.g. #host-y-1) are accepted. proxy-component would reject both.
- Float fields in forms now accept nan, inf and -inf, the WAVE spellings (draft.ts).

Known issue

wasm-wave ignores record fields the type doesn't have. {x: 1, y: 2} reads as {x: 1}, and a misspelled optional field silently becomes none. This comes from upstream wasm-wave, so proxy-component behaves the same way. It's documented in the gap doc and asserted under a FIXME in the tests, so a fix upstream will show up as a failing test.
- src/driver.ts, the shared driver, which doesn't depend on Node or the browser:
  - loads and instantiates a component
  - resolves calls written exactly as the console prints them: iface.func(…), new iface/res(…), iface/res.static(…), #borrow-x-1.method(…)
  - parses arguments with parseWave
  - emits events: call, ask, result, error, detail, output
  - routes prompting imports to a pluggable AnswerSource, and stops a run when that source has no answer or rejects one
- src/answers.ts: the answers file and the sequence answer source.
  - Format: one answer per line, callee[(args)] = <wave>. = !trap traps the guest, #mock is a new mock, and lines starting with // are comments.
  - Matching: answers are used in call order, and each is checked against the import it names. A method's answer can name the instance (#borrow-y-1.get-a) or any instance of its resource (imports/y.get-a). Pinned arguments are compared as values (via canonicalWave), not as text.
  - Errors point to the answer's source, e.g. answers.txt:4: the answer is for …, but the guest called …. Answers no import asked for produce a warning.
- src/cli.ts: two commands.
  - inspect lists the exports to call, the imports that ask for answers, and the definitions of named types.
  - call takes --answers, --answer, --imports <pkg>=<file.js> and --json.
  - Exit codes: 0 when every call ran, 2 when an import needs an answer, 1 on errors.
  - On Node versions that need the --experimental-wasm-jspi flag, it restarts itself with it.
- Packaging: scripts/build-node.mjs replaces build-node-tests.mjs and builds both the CLI (into dist-cli/, gitignored) and the tests. package.json gets a bin entry and a build:cli script, and test:node builds the CLI first.
- Small core changes:
  - ImportPrompt.answer(draft?) now takes an optional draft and returns whether the value was accepted.
  - ImportPrompt also carries the import's func.
  - New helpers: draftErrors in draft.ts, calleesOf in call-record.ts, canonicalWave in wave.ts.
The answers file is now the whole run: `> call` lines for the export
calls, and after each, the answers its imports got, in call order.
`--save` writes it from a run, with a `?` where the run stopped; the
agent replaces the `?` and runs again, taking the calls from the file.

Replay is strict: a line that doesn't match the guest's call (another
import, other arguments, an answer left over, or a missing call line)
stops the run with an error naming the line to restart from.

Happy path past the file's last answer, set by --defaults:
- eager (default): `ok` for results, records and tuples of those, and
  `#mock` for handles, noting the instances it passed over, e.g.
  `// default; instances of y: #y-1 (from new imports/y(10))`; or the
  only instance of a resource that can't be mocked
- careful: stops where there are instances to choose
- none: stops at every import that asks
careful and none require each answer to give its arguments.

Self-contained files:
- new mocks are written as `#mock-<id>`, e.g. `#mock-y-2`, and replay
  checks the mock gets that id
- `//!` lines, rewritten on each save: a header (component, --defaults,
  what isn't recorded: JavaScript imports, WASI) and the origins of
  instances no answer returned, e.g. `//! new imports/y(10) = #y-1`
- other comments are kept, including the notes on defaults

Calls and values:
- methods are written `iface/res.method(#borrow-x-1, …)` everywhere,
  in the UI console too; CallRecord gains `callee`, and a method's
  `self` is its first argument
- a list<u8> of text shows as b"…", in the UI and CLI, and reads back
- stop hints give the expected type, the named types it uses, and the
  instances the UI's instance picker would offer

Output and exit codes:
- the run is printed as the console shows it, from the last answer on
  (--from, --all), each answer marked with where it came from
- --json is removed
- exit 3 when a call fails, e.g. traps; 2 when an answer is needed
…essions

Skill and CLI usability, from two test agents debugging resource.wasm
and kv-store.wasm with only the skill:
- skills/component-ui/SKILL.md: the run, fill the ?, rerun loop;
  reviewing defaults; reading runs and traps; WAVE and call syntax;
  the answers file; options; limits. Tells agents to ask for the
  original commented WIT, which components don't embed.
- `component-ui template <component.wasm> <pkg>` prints the JavaScript
  module the UI's import editor starts with, for --imports.
- inspect lists the exports, the host resources they use, the imports
  that ask, and those answered on their own; "Types: none" when empty.
- A call that traps before using the answers after it is reported as
  the trap, with exit 3, rather than as a divergence.
- Stop hints say where the call's instances came from; instance lists
  say how an instance became available, e.g. moved into the host.
- An --imports function skipped because an argument is a mock says so
  on its line, in the UI and the CLI, and the CLI warns at the end.
- The CLI build makes dist-cli/component-ui.mjs executable.

One call log for the UI and the CLI (src/call-log.ts):
- Calls, the imports made during them, stream output and created
  instances, in order. The console renders it; the CLI prints it and
  writes answers files from it.
- ImportLog gains `answered()`, so any answer's new mocks, the UI's
  too, are written as #mock-<id>.
- The answers file shows every import the guest made: answers, holes,
  and `//!` lines for those that didn't ask, e.g.
  constructor; and the guest's drops of host instances, `//! drop #y-2`,
  which the console now shows too.

Saving sessions in the UI:
- Save on the console writes the current instance's calls and answers
  as an answers file, which the CLI replays and co
  to calls are written as #mock-<id> (#borrow-mock-<id> for a borrow),
  and the CLI's call syntax gains `drop #x-1`.

Trap stacks:
- The guest's wasm frames show under a trap, in the console and the
  CLI, without its runtime's (e.g. Rust panic hand
  10-frame limit.
- crates/bindgen exports `demangle`, using rustc-d
  cpp_demangle (+62 KB gzipped).

Shared instance descriptions (describeInstance, holdersText) for CLI
hints, the resource table and the UI's instance pi
gain a provenance tooltip.

Docs: README (features, CLI, how it works, known limitations: imports
can't ask from destructors, during instantiation,
constructors and statics; embedded WIT lacks doc comments) and the gap
doc (answers files from the UI, #mock-<id>, b"…").
The console can answer imports that ask as the CLI's --defaults does:
- "Ask for every result" (none, the default): every import shows a form.
- "Fill in obvious results" (careful): ok, and a new mock where nothing
  else could be returned; the others ask.
- "Fill in, preferring new mocks" (eager): also a new mock where existing
  instances could be returned.
A defaulted import is marked `// default` at the end of its line, and
Save writes it as the CLI's --save does, notes on passed-over instances
included, so the session replays in the CLI.

The console's header is down to a gear button. Its settings dialog has
the levels, with what each does, and Save logs and Clear logs, which
both close it. The choice is kept in localStorage.

Shared answering code, in src/answering.ts:
- happyValue, listInstances and the levels, moved out of the driver and
  the answers file;
- promptContext(prompt): the instance picker's choices, and whether a
  mock can be made;
- defaultAnswer(prompt, level) and answerPrompt(prompt, answer), which
  the driver and the console both use.

Who answered an import is in the shared call log: ImportPrompt.answer
and trap take an optional `by` ({ source, note }), passed to the log's
`answered()`. The CLI's run output and the answers-file writer read it
from there; the CLI's own map of answers keeps only what's file-specific
(comments, and whether an answer gave its arguments).
@chenyan2002
chenyan2002 merged commit bd5c385 into main Oct 25, 2026
2 checks passed
@chenyan2002
chenyan2002 deleted the mcp branch October 25, 2026 19:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant