Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions claude-notes/plans/2026-07-28-hub-mcp-get-errors-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# hub-mcp `get_errors` v2: validate locally with the QuartoHub WASM pipeline

## Overview

Agents using the Quarto Hub MCP server can read and write project files but
cannot see render errors. **v1** (branch `feature/hub-mcp-get-errors`,
plan `claude-notes/plans/2026-07-16-hub-mcp-get-errors.md`) had the browser
preview publish its diagnostics into an automerge index-doc sidecar that the
MCP read back with a content-hash staleness flag. It was implemented and
verified end-to-end, but review guidance from Carlos killed the architecture:

> Don't try to chase synchronization with the CRDT. Just grab the content of
> the file/project you care about and have an API entry point to check for
> the validity. You're never going to be able to know if the document you
> just changed ends up looking exactly how you expected it to, because it's
> a distributed system.

**v2** (this branch): `get_errors` renders the project files the MCP already
holds — using the *same WASM module the browser preview runs*
(`wasm-quarto-hub-client`) hosted in the MCP's Node process — and reports the
diagnostics of exactly what it rendered. Deterministic, no cross-peer
choreography, no schema change, hub-client untouched. The only cross-peer
data still read is the existing `captures` sidecar (execution errors happen
elsewhere and cannot be recomputed locally).

Feasibility proven 2026-07-28 in a Node spike: esbuild-bundle the
wasm-bindgen JS with three aliases (`/src/wasm-js-bridge/{cache,fetch,sass}.js`
→ `ts-packages/wasm-js-bridge/src/*`), `sass` external (never needed for
diagnostics), init from bytes (`init(await readFile(wasmPath))`), then
`vfs_add_file` + `render_page_in_project('index.qmd')` returned the identical
structured diagnostic the browser shows (`[Q-2-13] Unclosed Strong Star
Emphasis`, line 5 col 24) for a broken fixture.

## Design

New module `ts-packages/quarto-hub-mcp/src/local-render.ts`:

- `initRenderer(wasmBytes | wasmPath)` — one-time init (lazy, on first
`get_errors` call; keeps server startup fast).
- `renderDiagnostics(files: Map<string, FilePayload>, path: string)` —
`vfs_clear()`, `vfs_add_file('/project/' + p, text)` for every text file
(`vfs_add_binary_file` for binaries), then `render_page_in_project(path)`;
returns `{ diagnostics, warnings, pass1Failures, error }` mapped from the
WASM `RenderResponse`. Serialize renders with a promise chain (the VFS is
a module-global in the WASM instance).

`get_errors` tool (kept name, args `{ project, path? }`, read-only mode):
- `path` given → render that file; omitted → render every `.qmd` in the
project (pass-1 failures attribute sibling errors to their own paths, so a
single `index.qmd` render already surfaces most project-wide breakage —
render each remaining `.qmd` for completeness, capped and noted).
- Output per file: `{ path, checkedContentSha256, errors, warnings }` plus
`execution: { state, lastError }` from the `captures` sidecar. No `stale`
flag — the response describes exactly the bytes that were rendered.
- Tool description teaches the loop: read → fix via patch_file → call
get_errors again (it validates the new content immediately; no waiting).

Bundling (two consumers):
- `tsc` dev build (`dist/`, used by tests): a Node loader in
`local-render.ts` resolves the wasm-bindgen JS + `.wasm` from
`hub-client/wasm-quarto-hub-client/` via an env override
(`QUARTO_HUB_MCP_WASM_DIR`) falling back to a path probe.
- esbuild bundle (`dist-bundle/`, embedded in `q2 mcp`): extend
`crates/xtask`'s build-hub-mcp-bundle with the three bridge aliases +
`sass` external, and copy `wasm_quarto_hub_client_bg.wasm` (~38 MB) into
`dist-bundle/`. Note: the q2 binary already embeds a second copy of this
WASM for the preview SPA — dedupe is a follow-up, not v1.

## Work items (TDD)

### Phase 1 — local renderer
- [ ] Tests first (`src/local-render.test.ts`, real WASM, no mocks): broken
YAML → error diagnostic with line/col; clean doc → empty; sibling
pass-1 failure attributed to sibling path; binary files tolerated;
sequential renders don't interleave
- [ ] Implement `local-render.ts` (loader, VFS fill, render, mapping)

### Phase 2 — get_errors tool rework
- [ ] Rework `src/get-errors-handler.test.ts` (ported from v1): shapes,
captures surfacing (error surfaced / idle suppressed / running
surfaced), path filter, checkedContentSha256 present, renderer mocked
at the module seam
- [ ] Rework `src/get-errors-live.test.ts`: real server binary + test hub +
real WASM — create broken project via MCP, `get_errors` returns the
diagnostic; `patch_file` fix; `get_errors` immediately returns clean
- [ ] `tools.ts`: reimplement handler on local render + captures;
keep `onCapturesChange` wiring in connection-manager (v1's
`sidecars.captures`), drop everything diagnostics-sidecar
- [ ] Tool lists in both modes (`hub-mcp.test.ts`) include `get_errors`

### Phase 3 — bundling
- [ ] `cargo xtask build-hub-mcp-bundle`: bridge aliases, `sass` external,
wasm copy into dist-bundle; `q2 mcp --launcher-info` freshness check
- [ ] `bundle.test.ts` covers the wasm asset presence

### Phase 4 — verification
- [x] Package suites green; e2e recorded below. v2 contains ZERO Rust
changes and does not touch hub-client or the schema/sync packages
(all verified green at their upstream state), so the Rust verify
legs are unaffected; CI covers them on the PR.

## Follow-up: writes render-check their own content (2026-08-03)

User request after the first production fix loop: error checking should be
part of completing a set of updates, not a separate call the agent must
remember. Since validity = f(content) and the renderer is in-process, the
write tools now do it themselves:

- `write_file` / `patch_file` / `create_file` on a `.qmd` stage the new
text over the current file map, render it, and append the result to the
tool response: `Render check: clean.` (with warning count when nonzero),
or the structured error list when the new content is broken.
- Non-`.qmd` writes are unchanged; a check that cannot run degrades to
`Render check unavailable (…); call get_errors to verify` and never
fails the write (`renderCheckSuffix` in `src/tools.ts`).
- The `fix_errors` prompt now points the loop at the in-response check,
with one final `get_errors` to confirm.
- Tests: `src/write-render-check.test.ts` (7, handler-level, renderer
mocked at the module seam, fail-first verified); `get-errors-live.test.ts`
extended to pin `Render check: clean` in the real-binary patch response.

## End-to-end verification record (2026-07-28)

Throwaway Rust hub (`target/debug/hub --data-dir <tmp> --port 3105
--allow-insecure-auth`); real MCP server (`dist/index.js`, which loads
the real WASM host) driven over stdio in a single session:

1. `create_project` with `index.qmd` containing
`Hello **unclosed strong` → indexDocId `2APRALdSKxe8RbrcF3JckcFnbDQL`.
2. `get_errors { project }` → inspected output:
`errors: [ { kind: "error", title: "Unclosed Strong Star Emphasis",
code: "Q-2-13", problem: "I reached the end of the block before
finding a closing '**' …", start_line: 5, start_column: 24, details:
[ { kind: "info", content: "This is the opening '**' mark.", … } ] } ]`
plus `checkedContentSha256: sha256:4305d4…` naming the exact text
rendered. (The ANSI `rendered` snippet observed in this first run is
stripped from tool output as of the follow-up commit — structured
fields only.)
3. `patch_file` closing the emphasis → `get_errors { project, path }`
immediately returned `errors: [], warnings: []` with the new
`checkedContentSha256: sha256:7aef66…`. No polling, no other peer.

Also verified via the committed integration test
`src/get-errors-live.test.ts` (real server binary + in-process test
hub + real WASM: same loop), and `bundle.test.ts` pins that
dist-bundle ships `wasm-host.mjs` + the `.wasm`; the embedded `q2 mcp`
bundle rebuilt and `--launcher-info` confirmed 15 bundle files at the
branch commit.

## Carried over from v1 (independent of architecture)
- [x] `scripts/local-prod-server.mjs`: WS proxy no longer crashes on client
ECONNRESET (unhandled socket 'error') — verified by hard-killing a
live WS client
- Strand backlog (braid still awaiting the q2 skein doc id on this machine):
1. samod wedge: connection close with pending sync state busy-loops the
hub and stops all doc exchange until restart (see v1 plan's BLOCKER
section for log signatures + repro; p0/p1)
2. deleteFile/renameFile leave stale `captures` sidecar entries
3. preview red-bars `date-modified: last-modified` (keyword unresolvable
in browser VFS) as if it were a document error
4. dedupe the two embedded copies of wasm_quarto_hub_client_bg.wasm in q2

## v1 disposition
`feature/hub-mcp-get-errors` (sidecar publish/read, fully implemented and
tested) is preserved as a branch. If a human-facing "see collaborators'
preview state" feature is ever wanted, that work is a starting point — but
it is intentionally NOT part of this PR.
16 changes: 16 additions & 0 deletions scripts/local-prod-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,25 @@ function handleUpgrade(req, socket, head) {
headers: req.headers,
};

// A client that drops without a closing handshake emits 'error'
// (ECONNRESET) on this socket. Unhandled, that event crashes the
// whole proxy process — tear down just this connection instead.
socket.on('error', (err) => {
console.error(`WebSocket client socket error: ${err.message}`);
socket.destroy();
});

const proxyReq = http.request(options);

proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
// Same hazard on the hub side of the pipe.
proxySocket.on('error', (err) => {
console.error(`WebSocket hub socket error: ${err.message}`);
socket.destroy();
});
socket.on('close', () => proxySocket.destroy());
proxySocket.on('close', () => socket.destroy());

socket.write('HTTP/1.1 101 Switching Protocols\r\n');
Object.keys(proxyRes.headers).forEach(key => {
socket.write(`${key}: ${proxyRes.headers[key]}\r\n`);
Expand Down
2 changes: 1 addition & 1 deletion ts-packages/quarto-hub-mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"dist"
],
"scripts": {
"build": "tsc && node -e \"import('node:fs').then(fs => fs.chmodSync('dist/index.js', 0o755))\"",
"build": "tsc && node scripts/build-wasm-host.mjs && node -e \"import('node:fs').then(fs => fs.chmodSync('dist/index.js', 0o755))\"",
"bundle": "node scripts/bundle.mjs",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist dist-bundle",
Expand Down
58 changes: 58 additions & 0 deletions ts-packages/quarto-hub-mcp/scripts/build-wasm-host.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Prebundle the WASM host for plain-Node consumers (dist/ and the
// esbuild dist-bundle). Produces:
// dist/wasm-host.mjs — bundled wasm-bindgen JS + bridges
// dist/wasm_quarto_hub_client_bg.wasm — the WASM binary, loaded by the host
//
// The wasm-bindgen JS imports its bridge modules by the Vite-root
// paths hub-client serves them from; the alias plugin maps those to
// the ts-packages/wasm-js-bridge sources.
import * as esbuild from 'esbuild';
import { copyFile, mkdir } from 'node:fs/promises';
import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const pkgDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const repoRoot = path.resolve(pkgDir, '../..');
const wasmPkg = path.join(repoRoot, 'hub-client/wasm-quarto-hub-client');
const bridgeDir = path.join(repoRoot, 'ts-packages/wasm-js-bridge/src');

const bridgeAlias = {
name: 'wasm-bridge-alias',
setup(build) {
build.onResolve({ filter: /^\/src\/wasm-js-bridge\// }, (args) => ({
path: path.join(bridgeDir, path.basename(args.path)),
}));
build.onResolve({ filter: /^wasm-quarto-hub-client$/ }, () => ({
path: path.join(wasmPkg, 'wasm_quarto_hub_client.js'),
}));
},
};

/**
* Build the host bundle + WASM binary into `outDir`. Called with
* dist/ by the package build and with dist-bundle/ by bundle.mjs.
*/
export async function buildWasmHost(outDir) {
await mkdir(outDir, { recursive: true });
await esbuild.build({
entryPoints: [path.join(pkgDir, 'scripts/wasm-host-entry.mjs')],
bundle: true,
platform: 'node',
format: 'esm',
outfile: path.join(outDir, 'wasm-host.mjs'),
plugins: [bridgeAlias],
// dart-sass is pure JS and the html render's theme compilation
// needs it, so it rides inside the host bundle (the embedded
// dist-bundle has no node_modules to resolve it from at runtime).
logLevel: 'warning',
});
await copyFile(
path.join(wasmPkg, 'wasm_quarto_hub_client_bg.wasm'),
path.join(outDir, 'wasm_quarto_hub_client_bg.wasm'),
);
console.log(`wasm-host bundled into ${path.relative(pkgDir, outDir)}/`);
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
await buildWasmHost(path.join(pkgDir, 'dist'));
}
6 changes: 6 additions & 0 deletions ts-packages/quarto-hub-mcp/scripts/bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { parsePlatformList, stageKeyring } from './stage-keyring.mjs';
import { buildWasmHost } from './build-wasm-host.mjs';

const here = dirname(fileURLToPath(import.meta.url));
const pkgRoot = join(here, '..');
Expand Down Expand Up @@ -125,6 +126,11 @@ await esbuild.build({
outfile: join(outDir, 'auth-stream.mjs'),
});

// The WASM render host (`get_errors` local validation): index.mjs
// dynamic-imports ./wasm-host.mjs next to itself at first use, which
// loads ./wasm_quarto_hub_client_bg.wasm next to *itself*.
await buildWasmHost(outDir);

// --- ship the keyring addon as a mini node_modules ---------------------
// The staged platform packages must match the **release target's**
// users, not the build host: release jobs request explicit platforms
Expand Down
25 changes: 25 additions & 0 deletions ts-packages/quarto-hub-mcp/scripts/wasm-host-entry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Entry point for the prebundled WASM host (dist/wasm-host.mjs).
//
// `build-wasm-host.mjs` bundles this with esbuild, aliasing the
// wasm-bindgen JS's Vite-root-absolute bridge imports
// (/src/wasm-js-bridge/*) to the ts-packages/wasm-js-bridge sources,
// so the same wasm-quarto-hub-client module the browser preview runs
// loads in plain Node. `sass` stays external — diagnostics never
// compile stylesheets, and the bridge only imports it lazily.
import { readFile } from 'node:fs/promises';
import init from 'wasm-quarto-hub-client';

export * from 'wasm-quarto-hub-client';

let ready;

/**
* Initialize the WASM module from the binary shipped next to this
* file. Idempotent; callers await it before any render/vfs call.
*/
export function ensureInit() {
ready ??= readFile(new URL('./wasm_quarto_hub_client_bg.wasm', import.meta.url)).then(
(bytes) => init({ module_or_path: bytes }),
);
return ready;
}
6 changes: 6 additions & 0 deletions ts-packages/quarto-hub-mcp/src/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ describe('bundle smoke', () => {
it('ships the expected artifacts', () => {
const bundleDir = path.join(tmpDir, 'bundle');
expect(fs.existsSync(path.join(bundleDir, 'index.mjs'))).toBe(true);
// get_errors local validation: the WASM host + binary must ride in
// the bundle (index.mjs dynamic-imports ./wasm-host.mjs at first use).
expect(fs.existsSync(path.join(bundleDir, 'wasm-host.mjs'))).toBe(true);
const wasm = path.join(bundleDir, 'wasm_quarto_hub_client_bg.wasm');
expect(fs.existsSync(wasm)).toBe(true);
expect(fs.statSync(wasm).size).toBeGreaterThan(10_000_000);
const info = JSON.parse(
fs.readFileSync(path.join(bundleDir, 'build-info.json'), 'utf8'),
) as { gitCommit: string; nodeTarget: string; keyringPackages: string[] };
Expand Down
Loading
Loading