feat(ruview): MCP Apps console widget, HTTP transport, terminal UI (ADR-375)

- ui://ruview/console-v1.html: one self-contained widget for MCP Apps hosts
  and ChatGPT (ui.resourceUri + openai/outputTemplate). Renders node
  streams, device scans and doctor results; Refresh re-runs the tool via
  window.openai.callTool or an MCP Apps tools/call. Empty CSP allowlist, no
  network, textContent only, parent-frame messages only.
- MCP: protocol negotiation (2025-06-18/2025-03-26/2024-11-05),
  structuredContent + compact JSON text (17-33% smaller, MEASURED),
  resources/list|read, tool titles, canonical tool name in result _meta.
  Transport-neutral handleRpc + shared FIFO dispatcher.
- `mcp start --http`: Streamable HTTP for ChatGPT/remote clients. Bearer or
  secret-path token (constant-time), loopback default, Origin allowlist,
  256 KiB cap, no batches, write grants never honoured over HTTP.
- CLI: formatted TTY output (tables, rate bars, alerts first, NO_COLOR),
  JSON for pipes/--json, capture countdown, `esp32 --watch` with per-node
  sparklines, lazy module loading (--help ~20% faster, MEASURED).
- monitor detects Ameba CSI report-buffer starvation ("lack of csi buf"),
  the root cause of the Realtek heartbeat-only stall seen live.
- Size budget 320 -> 384 KiB (package ~339 kB).

Verified: 160 tests + 83 security tests; headless-Chrome e2e of the MCP
Apps flow against live boards (refresh round trip 108 -> 105 packets);
HTTP security probes; clean tarball smoke (stdio + HTTP).

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv committed 2026-10-01 15:52:26 -04:00
1 parent 3cd360f9e2
commit 45796e4e3e
19 files changed
+1497 -422

No files matched your search

+3 -2
View File
@@ -43,8 +43,9 @@ jobs:
# ADR-283/325: brain + local hosts + replay assets + guarded Spaces OAuth adapter;
# still runtime-dependency-free. ADR-369..374 add the SDK, firmware
# flashing, training runner/gate, doctor, device access, and SSH
# hosts: 320 KiB reviewed ceiling.
unpacked_budget: 327680
# hosts. ADR-375 adds the MCP Apps console widget (~12 KB), the
# Streamable HTTP transport and the terminal UI: 384 KiB reviewed ceiling.
unpacked_budget: 393216
- dir: harness/homecore
build: false
publishable: true
+3 -2
View File
@@ -105,8 +105,9 @@ jobs:
set -euo pipefail
case "${{ inputs.package }}" in
# ADR-283/325/369-374: brain + hosts + replay + Spaces OAuth + SDK,
# flashing, training, doctor, device access, SSH hosts; no runtime deps.
harness/ruview) export UNPACKED_BUDGET=327680 ;;
# flashing, training, doctor, device access, SSH hosts; ADR-375 console
# widget, HTTP transport, terminal UI; no runtime deps.
harness/ruview) export UNPACKED_BUDGET=393216 ;;
# ADR-285: CLI + MCP + reviewed brain + WASM-kernel adapter.
harness/homecore) export UNPACKED_BUDGET=180000 ;;
# ADR-264 O2: map-free tarball (was 188 kB with maps).
@@ -0,0 +1,202 @@
# ADR-375: MCP Apps console, HTTP transport and terminal UI for `@ruvnet/ruview`
- **Status**: proposed
- **Date**: 2026-10-01
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: npm, mcp, mcp-apps, chatgpt, ui, cli, http, least-authority, performance
- **Related**: ADR-263 (harness hardening), ADR-265 (distribution and size
budget), ADR-369 (operator surface and SDK), ADR-373 (device access),
ADR-374 (remote hosts)
## Context
The harness exposes one tool registry through the CLI, an MCP stdio server and
an SDK (ADR-369). Hardware testing of the device tools (ADR-373 Amendment 2)
showed three gaps in how results reach people and models:
- **MCP results were text only.** Every tool returned pretty-printed JSON in a
text block, with no `structuredContent`. The server declared protocol
`2024-11-05`, and `resources/list` was always empty, so MCP Apps hosts and
ChatGPT had nothing to render.
- **ChatGPT could not connect.** ChatGPT connectors reach MCP servers over
HTTPS. The harness spoke stdio only.
- **The CLI printed raw JSON to humans.** A live capture of two nodes was a
1.2 kB JSON object in the terminal, with alerts such as heartbeat-only
senders easy to miss.
Reference implementations for the target hosts (the `web-based-chatgpt-mcp-starter`
and `signal-to-swarm` sites) declare a `ui://` HTML resource and point UI tools
at it through both the MCP Apps key (`_meta.ui.resourceUri`) and the ChatGPT
keys (`openai/outputTemplate`, `openai/widgetAccessible`).
The package must stay free of runtime dependencies (ADR-263) and inside a
reviewed size budget (ADR-265).
## Decision
### 1. One console widget, two host families
`src/ui/console-widget.js` holds one self-contained HTML resource,
`ui://ruview/console-v1.html`, with mimeType `text/html;profile=mcp-app`.
- **Tools.** `ruview_esp32_capture`, `ruview_devices_scan` and `ruview_doctor`
carry `_meta.ui.resourceUri`, `ui/resourceUri`, `openai/outputTemplate` and
`openai/widgetAccessible`.
- **Data intake.** The widget reads results from either host family:
- MCP Apps: a `ui/initialize` handshake over `postMessage`, then
`ui/notifications/tool-input` and `ui/notifications/tool-result`;
- ChatGPT: `window.openai.toolOutput` and the `openai:set_globals` event.
- **Refresh.** A Refresh button re-runs the same tool with the same
arguments, through `window.openai.callTool` or an MCP Apps `tools/call`
request.
- **Safety.**
- The resource declares an empty CSP allowlist and makes no network
requests.
- Every value reaches the DOM through `textContent`; there is no
`innerHTML`.
- Messages are accepted only from the parent frame.
- A test pins all of this.
- **Design.** The visual language follows the reference sites: dark ground,
`#d0ff72` accent, monospace labels, and plain wording about what each value
is. Device vitals are labelled "device-reported (not validated)". Kernel
outputs carry the "no reference measurement" note, and SYNTHETIC frames
raise a warning.
### 2. Structured results and protocol negotiation
- **Protocol negotiation.** `initialize` negotiates `2025-06-18`,
`2025-03-26` or `2024-11-05`, and advertises `resources`.
- **Result shape.** `tools/call` returns `structuredContent` (always an
object), a text block with the same JSON in compact form, and
`_meta['ruview/tool']` set to the canonical tool name (so an aliased call
still refreshes correctly).
- **Compact text.** Compact rather than pretty-printed JSON cuts each result
by 17–33% (MEASURED on this host: guidance 17.5%, device scan 20.7%, live
capture 33.4%). Clients that read only `content` still receive the
complete result.
- **Titles.** Tools gain `title` and `annotations.title`.
`handleRpc` is transport-neutral, and `createDispatcher` holds the FIFO tool
chain both transports share (ADR-263 O2).
### 3. Streamable HTTP transport
`ruview mcp start --http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]`
starts `src/mcp-http.js`.
- **Authentication on every request.** A request must carry either
`Authorization: Bearer <token>`, or the secret-path form `/mcp/<token>`.
The second exists because ChatGPT connectors cannot send a static header.
- Tokens are compared in constant time and must be at least 16 characters.
- The token comes from `RUVIEW_MCP_TOKEN`, never argv. If unset, one is
generated and printed once to stderr.
- **Least authority.**
- It binds to loopback by default.
- Browser `Origin`s are refused unless allowlisted (DNS-rebinding defence).
Server-to-server clients send no Origin.
- `workspace-write` and `hardware-write` are never honoured over HTTP, even
when granted in `RUVIEW_MCP_GRANTS`. Flashing and calibration stay
stdio/CLI-only.
- **Protocol surface.**
- POST only; single JSON responses with no SSE stream and no sessions.
- Batches and unsupported `MCP-Protocol-Version` values are refused.
- Bodies are capped at 256 KiB.
- Notifications return 202.
- `/healthz` reports only the name and version.
Exposing the port to ChatGPT (a tunnel or reverse proxy with TLS) is the
operator's choice. The startup banner says so and warns on a non-loopback
bind.
### 4. Terminal UI
`src/cli-ui.js` renders results for a TTY:
- aligned tables, with unicode rate bars;
- colour that respects `NO_COLOR` and `TERM=dumb`;
- alerts and fixes ahead of the data;
- a stderr countdown for timed captures.
Pipes and `--json` keep receiving exact JSON. `ruview call` always prints
JSON, because remote hosts parse it.
`ruview esp32 --watch [--seconds 3]` redraws repeated capture windows with a
per-node rate sparkline.
`ruview monitor` now flags Ameba CSI report-buffer starvation (`lack of csi
buf` / `csi buf not enough`). This is the root cause of the Realtek
heartbeat-only stall, observed live: 399 starvation lines in 8 s, 0 CSI.
### 5. Startup
- **Launcher split.** `bin/cli.js` is a thin launcher, and `src/cli.js` loads
the tool registry, doctor, brain, host adapters and transports on demand.
- **Measured effect.**
- `--help`/`--version`/`skills` skip the registry: about 20% faster, median
of 9 interleaved runs against the previous build on this host (MEASURED).
- The MCP path needs the whole registry to list tools, so its start time is
unchanged within noise.
- **Compile cache rejected.** Node's on-disk compile cache
(`module.enableCompileCache`) was evaluated: module load was 27.7 ms with
it and 28.5 ms without (25 interleaved runs, MEASURED). That is within
noise, so it was not adopted.
### 6. Size budget
The additions cost about 38 kB:
- widget, 12.6 kB;
- HTTP transport;
- terminal UI;
- CLI split.
The `@ruvnet/ruview` unpacked budget rises from 320 KiB to 384 KiB in
`npm-packages.yml` and `ruview-npm-release.yml`. The package is about 338 kB.
## Evidence
All on Windows 11 x64, Node 24, with an ESP32-C6 (CP210x, COM16) and an
RTL8721Dx (PL2303GC, COM10) attached. All results MEASURED unless noted.
**Browser e2e.** A real MCP Apps host flow in headless Chrome:
- The host lists tools and reads the `ui://` resource over HTTP MCP, then
loads it in a sandboxed iframe (`allow-scripts` only).
- It answers `ui/initialize` and delivers a live `ruview_esp32_capture`
result.
- The widget's Refresh button triggered a second real capture through the
host (108 → 105 packets).
- The devices and doctor views rendered, in dark and light themes. The only
console error was the test host's own missing favicon.
**Security probes** against the live HTTP server:
- no token: 401;
- wrong token: 401;
- foreign Origin: 403;
- GET: 405;
- `ruview_node_flash` with `hardware-write` granted in the environment:
`authority_denied`.
**Tests.**
- `npm test`: 160 pass, 1 skip.
- `test:security`: 83 pass, 1 skip.
- New suites: `mcp-apps.test.mjs` (protocol, metadata, widget safety, HTTP
auth/origin/size/write denial) and `cli-ui.test.mjs` (renderers, colour
rules, the starvation detector run against a stand-in serial module).
**Tarball smoke** from a clean install:
- CLI and SDK work;
- stdio MCP negotiates `2025-06-18` and serves the widget and structured
results;
- HTTP MCP lists 22 tools, 3 with `ui://` metadata.
## Consequences
- MCP Apps hosts and ChatGPT render node streams, device scans and
diagnostics instead of raw JSON, and can refresh them in place.
- Remote MCP clients can use the read tools over authenticated HTTP. Write
tools remain local by construction.
- Each tool result costs fewer model tokens.
- The widget is a second place where result shapes are interpreted. Changes
to the capture, devices or doctor result shapes must update it, and its
version suffix (`console-v1`) changes when the contract does.
- Host behaviour for MCP Apps and ChatGPT keys is followed as documented by
the reference implementations. A live ChatGPT connector session (public
HTTPS URL) has not been run yet: CLAIMED, pending.
+1
View File
@@ -2,6 +2,7 @@
Latest proposed decisions:
- [ADR-375: MCP Apps console, HTTP transport and terminal UI for `@ruvnet/ruview`](ADR-375-ruview-mcp-apps-console-http-transport-and-terminal-ui.md)
- [ADR-374: Remote host access over SSH (read-only)](ADR-374-remote-host-access-over-ssh.md)
- [ADR-373: Host device access layer — ESP32, mmWave, LiDAR](ADR-373-host-device-access-layer.md)
- [ADR-372: Structured debugging doctor](ADR-372-structured-debugging-doctor.md)
+12 -8
View File
@@ -23,8 +23,8 @@
".mcp/servers.json": "fec6075400f8350d8075beac8306690355c4b015425bfd0e5f52966234e9d66f",
"CLAUDE.md": "25f9306b6caae22078cb9375108b6f6bb813d83e617739f6d9372f116b0ede5d",
"LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06",
"README.md": "1b09064f3e1a7082b40078351df8758cc1bcdb3ea587eb0305692271791c7d7e",
"bin/cli.js": "95cbb40d5780f53f73772b1106c3dbb23d6e194035d342900b193a7bf35ba462",
"README.md": "cdce362447f71e79667d8293569ba63ec251e15010aa3d01958f66fbe3bb54ec",
"bin/cli.js": "8ebf6ca4b6d9529a5e338374c19329c1a2fc2f9e52f13b4341928b7168ee461b",
"brain/corpus/core.jsonl": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
"flywheel/evaluations.json": "ac4ff1f897a2444870cd2b8ae8aee8b1578e61467aeca4db57893f41be98a572",
"flywheel/fixture.mjs": "de71be88753d0da4695d91011b54380c994a018986fafba36cb13739307a9bce",
@@ -32,7 +32,7 @@
"flywheel/genome.json": "75db44a3cab70d9459fc8c07863f640ac1214bfaa243483939e1506d63f51214",
"flywheel/replay.mjs": "0670ca0b03701f4afe0b4bca8a3d58d481676b61a94a5b98c6a425aefb1159ab",
"flywheel/run.mjs": "6d4f97db16900c45367b6538848cbe1915af999e663720dfc51f2bb1698f1cd0",
"package.json": "5093f21e692d9f8e4ca4ba83099ee0d12bc2ae875177671bb38c1a9d88c2e6e7",
"package.json": "121c380a8e61ffe35dd3fad3d10b74c388de605eb64c1f1195979a84a2975a9d",
"scripts/sync-skills.mjs": "43715dab61e204dc91bbd61755810e8fdb2f66e2b0c0bd791b4bf48a2e293565",
"scripts/update-manifest.mjs": "8f56764b8f70aed55da0c7e2417ae875b0d58d781d839b6db7f115f08af61e6b",
"scripts/verify-manifest.mjs": "6491a221762efcfeb3e749ecab243b204f17fd5bc871f3d4025597f31b8f0f10",
@@ -44,7 +44,9 @@
"skills/train-pose.md": "8cb132e468392497377828f7974c744e4c877134ed9a87f960f77fa1221540ba",
"skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
"src/brain.js": "0f16a75aea943acdacc430ff11d5df7ecdec9cca2ab497795ff6f33eaebdfab6",
"src/devices/esp32.js": "59c445b9d36188405d075953941a0abf261ea3ee8c0523ffe71ac1d00fcccbff",
"src/cli-ui.js": "42d9c616d5363cb2dffc8ac00dd11fef2b3e9db548dc60851dbc827ee0b7f22d",
"src/cli.js": "ad331b361200ebf56bdf9e9aa17e1ddb4ccb01e99eecd30e409364cf22d9f5f5",
"src/devices/esp32.js": "ae389193430fc85601b46aef9281ad91631a1cb9bf4d2f247dfb02e2a4491c4d",
"src/devices/lidar.js": "d753d88375358ced5174376753c276b5ca621f38ae48eac58dbb307b9004b629",
"src/devices/mmwave.js": "09d828e3b3e140926194ab83bf27e7c63cbda1cf438385c82c0e89b9d8e4b526",
"src/devices/registry.js": "26725e43adaa908b41462f492c5f6bf63e9193f3ee076e3d64ebaa012b66f6be",
@@ -58,7 +60,8 @@
"src/hosts/codex.js": "d41ecd132ce2db7b47aad9cebbc020d70e6810d48c3554858d099ff2e8f6608b",
"src/hosts/index.js": "ab276c41ab722bcdf72c2d1649cecbb760ae05c41c1372aae4c2447aa7c11539",
"src/kernel.js": "f2d7cf59cc26a499764ed09b7af391e5cd37566ada5c2be45c4586ecba8b128b",
"src/mcp-server.js": "8b2ee4b939b25c1b1f507b295a43a2ebad852b8bac9d31af9bf7fb39b181c12e",
"src/mcp-http.js": "b3dc3ed53a48e69a39e2aa48382251b8ca2a0635c7c25858c2f0b7e7a5314fb4",
"src/mcp-server.js": "0e8817a919a3cde13bc57afa190498dfb655f29217b186b4ba500790c41a220c",
"src/policy.js": "baad5eb8c3eb2e416e0a71b342ad850d7c8413d7d976571ebf596937f69da8e2",
"src/process-runner.js": "49533b038044dfb8bc76ed01c030d06a9856ead0836157fb693e2a7d40f786d6",
"src/redact.js": "ebf1afff46341078706b0401838c53db043603586e280d51ece5cf1feba35189",
@@ -67,10 +70,11 @@
"src/sdk.d.ts": "82e73721de433551a2b4339821ebf7b2c9d44133c766498ae91d15483ed8a073",
"src/sdk.js": "a07b5e5777ee897a50c12f6002065c3ca781e087d625541d54e5f5308ede2116",
"src/spaces.js": "45ef786537cb2a446db5e926e5a1c10b73639d2767dec84611f914f78d4325eb",
"src/tools.js": "cd72ac87e85e0bfbef7af11c98d03c4cb63a5bdd5da760460e9df8de9468bc34",
"src/training.js": "b8451c48db69c7d077d75f07500996213beaa52705116f64afafe084e68e3956"
"src/tools.js": "9207389f5ddcbc2889cd4ac1518239a59a94fd3d2c3598b3adece48afcd260e6",
"src/training.js": "b8451c48db69c7d077d75f07500996213beaa52705116f64afafe084e68e3956",
"src/ui/console-widget.js": "68cb7a93f22a5a6a0a7640984b86b69ee0b9c722e39f5c8533de79ba6a0b15ba"
},
"filesDigest": "b8c8a261e647535ccfafc54783fc25d7de961eab584b3365c9457f9cf9cfe46f",
"filesDigest": "f6f1cec7894b73529ce589696e24033f0e69e0a90b11bbcc0847e23af2d014b6",
"brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
"gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd",
"developmentPins": {
+1 -1
View File
@@ -1 +1 @@
36990fd5c552e61b410ac8771d6a55769e8fcc9f695a1710b107a57e409a27de manifest.json
6e4a541fc2ca74b2c19de18ed6b7a5b1206d012257e264c3521a80b4379388c4 manifest.json
+22
View File
@@ -242,6 +242,28 @@ The bundled `.claude/settings.json` registers the `ruview` MCP server
(`npx -y @ruvnet/ruview mcp start`). Drop this package's `.claude/` into a repo, or run
`npx @ruvnet/ruview install --host claude-code`.
## ChatGPT and MCP Apps (ADR-375)
`ruview_esp32_capture`, `ruview_devices_scan` and `ruview_doctor` render in a
self-contained console widget (`ui://ruview/console-v1.html`) in ChatGPT and
other MCP Apps hosts, with a Refresh button that re-runs the tool. Results also
carry `structuredContent`.
```bash
# MCP over HTTP: loopback, token-authenticated, read tools only
export RUVIEW_MCP_TOKEN="$(node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))")"
RUVIEW_MCP_GRANTS=device-access npx @ruvnet/ruview mcp start --http --port 8790
```
Clients that can send headers use `Authorization: Bearer <token>` on
`http://127.0.0.1:8790/mcp`. ChatGPT connectors cannot, so expose the port over
HTTPS (a tunnel or reverse proxy) and add `https://<host>/mcp/<token>` as the
connector URL. Write grants (`hardware-write`, `workspace-write`) are never
honoured over HTTP: flashing and calibration stay on the CLI and stdio.
In a terminal, commands print a formatted view; pipes and `--json` print JSON.
`ruview esp32 --watch` redraws live capture windows with per-node trends.
## Hosts
Claude Code and Codex are implemented directly and tested with the local,
+5 -313
View File
@@ -1,322 +1,14 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// `npx ruview` — the RuView WiFi-sensing operator harness (minted via metaharness,
// hardened per ADR-182). Plain ESM, no build step: ships and runs as-is.
//
// The `ruview.*` tools (onboard/verify/claim-check/…) and local host adapters are
// pure Node and run with zero runtime dependencies.
// `npx ruview` launcher. The logic lives in src/cli.js, which loads modules on
// demand (ADR-375); `run` is re-exported for tests and embedders.
import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { realpathSync, existsSync, readdirSync, readFileSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { argv } from 'node:process';
import { runTool, listTools, findRepoRoot, OPERATOR_DEPS, isRemoteReadOnlyTool, resolveToolName } from '../src/tools.js';
import { saveHost } from '../src/remote.js';
import { formatDoctor, runDoctor, DOCTOR_GROUPS } from '../src/doctor.js';
import { getHost } from '../src/hosts/index.js';
import { makeProposal, searchBrain, verifyBrain } from '../src/brain.js';
import { run } from '../src/cli.js';
const NAME = 'ruview';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const SKILLS_DIR = join(ROOT, 'skills');
// Map friendly CLI verbs → registry tool names (underscore-canonical, ADR-263).
const VERB_TO_TOOL = {
onboard: 'ruview_onboard',
verify: 'ruview_verify',
'claim-check': 'ruview_claim_check',
calibrate: 'ruview_calibrate',
monitor: 'ruview_node_monitor',
flash: 'ruview_node_flash',
guidance: 'ruview_guidance',
spaces: 'ruview_spaces_list',
kernel: 'ruview_kernel_selftest',
'flash-plan': 'ruview_firmware_plan',
ports: 'ruview_firmware_ports',
train: 'ruview_train',
'train-plan': 'ruview_train_plan',
'train-gate': 'ruview_train_gate',
devices: 'ruview_devices_scan',
esp32: 'ruview_esp32_capture',
mmwave: 'ruview_mmwave_read',
lidar: 'ruview_lidar_read',
};
// Verbs whose kebab-case flags map 1:1 onto snake_case schema fields (ADR-369).
const SNAKE_VERBS = new Set(['flash', 'flash-plan', 'train', 'train-plan', 'train-gate', 'devices', 'esp32', 'mmwave', 'lidar']);
const NUMERIC_FLAGS = new Set(['baud', 'boot_log_seconds', 'samples', 'model_score', 'baseline_score', 'n_test', 'seconds', 'udp_port', 'max_packets', 'max_frames', 'node_id', 'analyze_max_frames']);
const BOOLEAN_FLAGS = new Set(['confirm', 'cuda', 'allow_unverified', 'analyze']);
function toSchemaArgs(flags) {
const out = {};
for (const [key, value] of Object.entries(flags)) {
const k = key.replaceAll('-', '_');
if (NUMERIC_FLAGS.has(k)) out[k] = Number(value);
else if (BOOLEAN_FLAGS.has(k)) out[k] = value === true || value === 'true';
else if (k === 'train_subjects' || k === 'test_subjects') out[k] = String(value).split(',').filter(Boolean);
else out[k] = value;
}
return out;
}
function pjson(o) { console.log(JSON.stringify(o, null, 2)); }
function listSkills() {
if (!existsSync(SKILLS_DIR)) return [];
return readdirSync(SKILLS_DIR).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, ''));
}
async function doctor(flags) {
const groups = typeof flags.group === 'string' ? flags.group.split(',').map((g) => g.trim()).filter(Boolean) : undefined;
const unknown = (groups || []).filter((g) => !DOCTOR_GROUPS.includes(g));
if (unknown.length) { console.error(`doctor: unknown group(s) ${unknown.join(', ')}; valid: ${DOCTOR_GROUPS.join(', ')}`); return 2; }
const report = await runDoctor({
groups,
port: typeof flags.port === 'string' ? flags.port : undefined,
probe_port: flags.probe === true,
sensing_url: typeof flags.url === 'string' ? flags.url : undefined,
}, OPERATOR_DEPS);
if (flags.json === true) pjson(report);
else console.log(formatDoctor(report));
return report.ok ? 0 : 1;
}
function help() {
console.log(`Usage: ${NAME} <command> [options]
Operator tools:
onboard [--path docker-demo|repo-build|live-esp32] pick a setup path
verify [--repo <dir>] run the deterministic proof (VERDICT: PASS)
claim-check --text "..." | --file <path> lint accuracy claims (the honesty guardrail)
calibrate --step baseline|enroll|train-room|room-watch
monitor --port COM8 [--seconds 12] [--baud 1500000] assert CSI is flowing on a node (no reset on open)
flash --port COM8 --variant s3-8mb [--confirm] build+flash firmware (Windows/ESP-IDF)
guidance [--topic homecore] [--query "Wasmtime"] source-cited code/capability map
spaces [--resource sites|...|alerts] [--limit 50] page OAuth-bound Cognitum spatial resources
kernel [--backend wasm|napi|auto] [--seconds 60] SYNTHETIC self-test of @ruvnet/ruview-kernel (optional)
Firmware (ADR-370, cross-platform esptool):
ports list serial ports (pyserial)
flash-plan --port <p> --bundle <dir> [--variant s3-8mb|s3-4mb|c6] verify + print the plan
flash --port <p> --bundle <dir> [--variant ...] [--baud 460800] --confirm
[--boot-log-seconds 15] [--allow-unverified] write flash, then capture boot evidence
Devices (ADR-373) — run on the machine the hardware is attached to:
devices classify USB serial devices (ESP32, mmWave, RPLIDAR)
esp32 [--udp-port 5005] [--seconds 10] [--bind 0.0.0.0] receive + summarize ESP32 / Realtek RAC1 node UDP streams
[--analyze [--node-id N] [--backend wasm|napi|auto]] run live CSI through @ruvnet/ruview-kernel
mmwave --port <p> [--model auto|mr60bha2|ld2410] [--seconds 10] 60/24 GHz radar readout
lidar --source rplidar --port <p> [--baud 115200] RPLIDAR scan summary
lidar --source iphone --url ws://HOST:8787/ws/lidar iPhone LiDAR relay (token: RUVIEW_LIDAR_TOKEN)
Remote hosts (ADR-374) — SSH, read-only tools only:
hosts add --name pi --ssh user@pi.local [--port 22] [--version X.Y.Z]
hosts list
hosts run --host pi --tool ruview_devices_scan [--args-json '{"seconds":10}']
call <tool> [--read-only] [--args-json '{...}'] generic schema-validated tool call
Training (ADR-371):
train-plan [--mode pose-smoke|pose|room] [...] resolve the command, run nothing
train --mode pose-smoke|pose|room [--config f] [--data-dir d] [--checkpoint-dir d]
[--enrollment f] [--output f] [--samples 64] [--cuda] --confirm
train-gate --file report.json | --model-score .59 --baseline-score .50 --split chronological ...
Harness:
doctor [--json] [--group a,b] [--port P [--probe]] [--url http://host:port]
structured diagnostics with fixes (ADR-372)
skills list bundled skills
skill <name> print a skill playbook
mcp start run the ruview.* MCP server (stdio)
install --host <h> project the harness config into the current repo
agent run --host claude-code|codex --prompt "..." [--repo <dir>]
brain search --query "..." | verify | propose
--version | --help
Hosts implemented and tested locally: claude-code (-p), codex (exec)`);
return 0;
}
/** tiny flag parser: --k v / --k=v / --flag (boolean) */
function parseFlags(rest) {
const f = {};
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a.startsWith('--')) {
const eq = a.indexOf('=');
if (eq !== -1) { f[a.slice(2, eq)] = a.slice(eq + 1); }
else if (i + 1 < rest.length && !rest[i + 1].startsWith('--')) { f[a.slice(2)] = rest[++i]; }
else { f[a.slice(2)] = true; }
}
}
return f;
}
export async function run(args) {
const cmd = args[0] ?? 'onboard';
const rest = args.slice(1);
const flags = parseFlags(rest);
// Direct tool verbs.
if (VERB_TO_TOOL[cmd]) {
const toolArgs = { ...flags };
if (cmd === 'claim-check') {
if (flags.file) {
toolArgs.text = readFileSync(flags.file, 'utf8');
delete toolArgs.file;
}
// Fail closed (ADR-263 O1): an honesty gate must never PASS on no input.
if (typeof toolArgs.text !== 'string' || toolArgs.text.trim().length === 0) {
console.error('claim-check: no input — pass --text "..." or --file <path> (empty input is an error, not a PASS).');
return 2;
}
const res = await runTool('ruview_claim_check', toolArgs);
pjson(res);
return res.ok ? 0 : 1;
}
if (cmd === 'monitor' && flags.seconds) toolArgs.seconds = Number(flags.seconds);
if (cmd === 'monitor' && flags.baud) toolArgs.baud = Number(flags.baud);
if (cmd === 'guidance' && flags.limit) toolArgs.limit = Number(flags.limit);
if (cmd === 'calibrate' && typeof flags.args === 'string') toolArgs.args = flags.args.split(',');
if (cmd === 'kernel' && flags.seconds !== undefined) toolArgs.seconds = Number(flags.seconds);
if (SNAKE_VERBS.has(cmd)) {
let snake = toSchemaArgs(flags);
if (cmd === 'train-gate' && typeof flags.file === 'string') {
snake = JSON.parse(readFileSync(flags.file, 'utf8'));
}
const res = await runTool(VERB_TO_TOOL[cmd], snake, { source: 'cli' });
pjson(res);
return res.ok ? 0 : 1;
}
if (cmd === 'spaces') {
if (flags['credentials-path'] !== undefined) toolArgs.credentials_path = flags['credentials-path'];
delete toolArgs['credentials-path'];
if (flags.limit !== undefined) toolArgs.limit = Number(flags.limit);
}
const res = await runTool(VERB_TO_TOOL[cmd], toolArgs, { source: 'cli' });
pjson(res);
return res.ok ? 0 : 1;
}
switch (cmd) {
case 'doctor': return doctor(flags);
case 'skills': console.log(listSkills().join('\n') || '(none)'); return 0;
case 'skill': {
const n = rest[0];
const p = n && join(SKILLS_DIR, `${n}.md`);
if (!p || !existsSync(p)) { console.error(`No skill "${n}". Try: ${listSkills().join(', ')}`); return 2; }
console.log(readFileSync(p, 'utf8'));
return 0;
}
case 'mcp': {
if (rest[0] === 'start' || rest[0] === undefined) {
const { startMcpServer } = await import('../src/mcp-server.js');
startMcpServer();
return new Promise(() => {}); // run until stdin closes
}
console.error('Usage: ruview mcp start'); return 2;
}
case 'agent': {
if (rest[0] !== 'run') { console.error('Usage: ruview agent run --host claude-code|codex --prompt "..." [--repo <dir>]'); return 2; }
const hostName = String(flags.host || 'codex');
const prompt = String(flags.prompt || '');
const repo = flags.repo ? resolve(flags.repo) : findRepoRoot();
if (!repo) { console.error('agent run: trusted RuView repo not found; pass --repo <root>.'); return 2; }
if (!prompt.trim()) { console.error('agent run: --prompt is required.'); return 2; }
const allowWrite = flags['allow-write'] === true;
if (allowWrite && flags.confirm !== true) { console.error('agent run: --allow-write also requires --confirm.'); return 2; }
try {
const result = await getHost(hostName).run({
prompt, repoRoot: repo, trustedRoot: repo, allowWrite, confirm: flags.confirm === true,
});
pjson({ ok: true, host: hostName, mode: allowWrite ? 'workspace-write' : 'read-only', stdout: result.stdout, stderr: result.stderr });
return 0;
} catch (error) {
pjson({ ok: false, host: hostName, error: error.message });
return 1;
}
}
case 'brain': {
const action = rest[0] || 'search';
if (action === 'search') {
const query = String(flags.query || '');
if (!query.trim()) { console.error('brain search: --query is required.'); return 2; }
pjson({ ok: true, results: searchBrain(query, { limit: flags.limit }) }); return 0;
}
if (action === 'verify') {
const repo = flags.repo ? resolve(flags.repo) : findRepoRoot();
if (!repo) { console.error('brain verify: RuView repo not found.'); return 2; }
const result = verifyBrain({ repo }); pjson(result); return result.ok ? 0 : 1;
}
if (action === 'propose') {
const result = makeProposal(flags); pjson(result); return result.ok ? 0 : 1;
}
console.error('Usage: ruview brain search|verify|propose'); return 2;
}
case 'install': {
const host = flags.host || 'claude-code';
if (!['claude-code', 'codex'].includes(host)) {
console.error(`Host "${host}" is not implemented. Supported: claude-code, codex.`);
return 2;
}
try {
const adapter = getHost(host);
console.log(`Projecting RuView harness for host "${host}" via ${adapter.name}.`);
console.log('Add to your host config — MCP server command: npx -y ruview mcp start');
console.log('Skills:', listSkills().join(', '));
return 0;
} catch {
console.error(`Host adapter "${host}" is unavailable.`);
return 1;
}
}
case 'tools': pjson(listTools()); return 0;
case 'call': {
// Generic, schema-validated tool call (ADR-374). `--read-only` is what
// remote hosts receive: it refuses anything but read-only tools.
const name = resolveToolName(rest[0] || '');
if (!name) { console.error('Usage: ruview call <tool> [--read-only] [--args-json \'{...}\']'); return 2; }
if (flags['read-only'] === true && !isRemoteReadOnlyTool(name)) {
pjson({ ok: false, reason: 'remote_tool_not_allowed', name }); return 1;
}
let toolArgs = {};
if (typeof flags['args-json'] === 'string') {
try { toolArgs = JSON.parse(flags['args-json']); } catch { pjson({ ok: false, reason: 'invalid_arguments', errors: ['--args-json is not valid JSON'] }); return 2; }
}
const res = await runTool(name, toolArgs, { source: 'cli' });
pjson(res);
return res.ok ? 0 : 1;
}
case 'hosts': {
const action = rest[0] || 'list';
if (action === 'list') { const res = await runTool('ruview_host_list', {}, { source: 'cli' }); pjson(res); return res.ok ? 0 : 1; }
if (action === 'add') {
try {
const out = saveHost({ name: flags.name, ssh: flags.ssh, port: flags.port ? Number(flags.port) : undefined, version: flags.version, description: flags.description });
pjson({ ok: true, ...out, next: `Verify the host key once: ssh ${out.host.ssh} true` }); return 0;
} catch (error) { pjson({ ok: false, reason: error.reason || 'invalid_host', detail: error.message }); return 2; }
}
if (action === 'run') {
let toolArgs = {};
if (typeof flags['args-json'] === 'string') {
try { toolArgs = JSON.parse(flags['args-json']); } catch { console.error('--args-json is not valid JSON'); return 2; }
}
const tool = resolveToolName(String(flags.tool || '')) || String(flags.tool || '');
const res = await runTool('ruview_host_run', { host: String(flags.host || ''), tool, args: toolArgs }, { source: 'cli' });
pjson(res); return res.ok ? 0 : 1;
}
console.error('Usage: ruview hosts list | add --name pi --ssh user@pi.local [--port 22] [--version X.Y.Z] | run --host pi --tool ruview_devices_scan [--args-json {...}]');
return 2;
}
case '--version': case '-v': {
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
console.log(pkg.version); return 0;
}
case '--help': case '-h': return help();
default:
console.error(`Unknown command: ${cmd}. Try \`${NAME} --help\`.`);
return 2;
}
}
export { run };
// CLI guard: run only when invoked directly (realpath both sides — npm/npx shims
// pass a non-normalized, possibly case-skewed argv[1] on Windows).
+1 -1
View File
@@ -38,7 +38,7 @@
],
"scripts": {
"test": "node --test test/*.test.mjs",
"test:security": "node --test test/hosts.test.mjs test/brain.test.mjs test/policy.test.mjs test/spaces.test.mjs test/kernel.test.mjs test/firmware.test.mjs test/training.test.mjs test/devices.test.mjs test/csi-nodes.test.mjs test/remote.test.mjs",
"test:security": "node --test test/hosts.test.mjs test/brain.test.mjs test/policy.test.mjs test/spaces.test.mjs test/kernel.test.mjs test/firmware.test.mjs test/training.test.mjs test/devices.test.mjs test/csi-nodes.test.mjs test/remote.test.mjs test/mcp-apps.test.mjs test/cli-ui.test.mjs",
"doctor": "node ./bin/cli.js doctor",
"mcp": "node ./bin/cli.js mcp start",
"brain:verify": "node ./bin/cli.js brain verify",
+172
View File
@@ -0,0 +1,172 @@
// SPDX-License-Identifier: MIT
// Terminal rendering for the CLI (ADR-375). Used only when stdout is a TTY and
// `--json` is absent; pipes and scripts keep receiving exact JSON. Respects
// NO_COLOR and TERM=dumb. Pure functions of (tool, result) so tests can pin
// the output byte-for-byte.
const ESC = '\x1b[';
const ANSI_RE = /\x1b\[[0-9;]*m/g;
export function colorEnabled(stream = process.stdout, env = process.env) {
return Boolean(stream && stream.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb';
}
export function createStyle(on) {
const wrap = (code) => (s) => (on ? `${ESC}${code}m${s}${ESC}0m` : String(s));
return { acid: wrap('38;5;155'), muted: wrap('38;5;245'), warn: wrap('38;5;221'), bad: wrap('38;5;203'), bold: wrap('1') };
}
const visible = (s) => String(s).replace(ANSI_RE, '').length;
const pad = (s, n) => String(s) + ' '.repeat(Math.max(0, n - visible(s)));
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—');
const pct = (v) => (typeof v === 'number' ? `${(v * 100).toFixed(v > 0 && v < 0.01 ? 2 : 1)}%` : '—');
const BARS = '▁▂▃▄▅▆▇█';
/** Unicode bar of `width` cells for a 0..1 fraction. */
export function bar(frac, width = 10) {
const f = Math.max(0, Math.min(1, Number(frac) || 0));
const full = Math.floor(f * width);
return '█'.repeat(full) + (full < width ? (f * width - full >= 0.5 ? '▌' : ' ') : '') + ' '.repeat(Math.max(0, width - full - 1));
}
/** Sparkline of a numeric series (min..max scaled). */
export function sparkline(values) {
const v = values.filter((x) => typeof x === 'number' && Number.isFinite(x));
if (!v.length) return '';
const lo = Math.min(...v); const hi = Math.max(...v);
return v.map((x) => BARS[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * 7)]).join('');
}
/** Align rows (arrays of cells) into columns. */
export function table(rows, style, gap = 2) {
const widths = [];
for (const row of rows) row.forEach((c, i) => { widths[i] = Math.max(widths[i] || 0, visible(c)); });
return rows.map((row, r) => {
const line = row.map((c, i) => (i === row.length - 1 ? String(c) : pad(c, widths[i] + gap))).join('').trimEnd();
return r === 0 ? style.muted(line) : line;
}).join('\n');
}
function head(view, r, s) {
const measured = r.evidence && /^MEASURED/.test(r.evidence);
const status = r.ok === false
? s.bad(`✖ ${String(r.reason || 'failed').replace(/_/g, ' ')}`)
: measured ? s.acid('● MEASURED') : s.acid('✔ ok');
return `${s.muted(`RUVIEW / ${view}`)} ${status}`;
}
function failureLines(r, s) {
if (r.ok !== false) return [];
const out = [];
for (const k of ['detail', 'remedy', 'hint']) if (r[k]) out.push(`${s.muted(k === 'remedy' ? 'fix' : k)} ${r[k]}`);
return out;
}
function renderCapture(r, s, history) {
const lines = [head('NODE STREAM', r, s), ...failureLines(r, s)];
lines.push(s.muted(`packets ${r.packets ?? '—'} · decoded ${r.decodedPackets ?? '—'} · csi nodes ${r.csiNodes ?? '—'} · ${r.seconds ?? '—'} s · ${r.listen ?? ''}`));
for (const hb of r.heartbeatOnlySenders || []) lines.push(s.warn(`! heartbeat-only: ${hb.address} (${hb.heartbeats} heartbeats, no CSI)`));
const nodes = r.nodes || [];
if (nodes.length) {
const maxHz = Math.max(...nodes.map((n) => n.csiRateHz || 0), 1);
const rows = [['SOURCE', 'NODE', 'CSI RATE', '', 'LOSS', 'RSSI', 'SHAPE', 'SEQ', history ? 'TREND' : '']];
for (const n of nodes) {
const key = `${n.source}:${n.nodeId}`;
const csi = n.csi || {};
const seq = [n.seqReordered && `${n.seqReordered}r`, n.seqStrays && `${n.seqStrays}s`, n.seqResyncs && `${n.seqResyncs}R`].filter(Boolean).join(' ') || '—';
const loss = n.csiLossFraction;
rows.push([
n.source === 'realtek' ? 'realtek' : 'esp32', String(n.nodeId),
`${num(n.csiRateHz, 1)} Hz`, s.acid(bar((n.csiRateHz || 0) / maxHz, 8)),
loss == null ? '—' : loss > 0.05 ? s.warn(pct(loss)) : pct(loss),
`${num(n.rssiMean, 1)} dBm`,
`${csi.shape || '—'}${n.csiShapes ? ` +${n.csiShapes.length - 1}` : ''}${csi.synthetic ? s.warn(' SYNTHETIC') : ''}`,
seq, history ? s.acid(sparkline(history.get(key) || [])) : '',
]);
}
lines.push('', table(rows, s));
}
const a = r.analysis;
if (a) {
lines.push('');
if (a.ok === false) lines.push(s.warn(`kernel analysis: ${a.reason}${a.detail ? ` — ${a.detail}` : ''}`));
else {
const last = a.summary?.last || {}; const hr = last.heart || {}; const rr = last.respiratory || {};
lines.push(`${s.bold('kernel analysis')} ${s.muted(`${a.backend} · ${a.integrity} · ${a.input?.frames} frames @ ${num(a.input?.sampleRateHz, 1)} Hz`)}`);
lines.push(` heart ${num(hr.bpm, 1)} bpm (${hr.status || '—'}) breathing ${rr.status === 'unavailable' ? 'unavailable' : `${num(rr.bpm, 1)} bpm (${rr.status || '—'})`}`);
lines.push(s.muted(` ${a.note || 'estimates without a reference measurement'}`));
}
}
if (r.unknownPackets) lines.push(s.muted(`unknown packets ${r.unknownPackets}: ${Object.keys(r.unknownMagics || {}).join(', ')}`));
return lines.join('\n');
}
function renderDevices(r, s) {
const lines = [head('DEVICES', r, s), ...failureLines(r, s)];
const rows = [['PORT', 'BRIDGE', 'LIKELY', 'CONFIRM WITH']];
for (const d of (r.devices || []).filter((x) => !x.builtin)) {
rows.push([d.port, d.bridge || d.usb || 'unknown', (d.likelyRoles || []).join('/') || '—', (d.confirmWith || [])[0] || '—']);
}
if (rows.length > 1) lines.push('', table(rows, s));
if (r.note) lines.push('', s.muted(r.note));
return lines.join('\n');
}
function renderMonitor(r, s) {
const lines = [head('SERIAL MONITOR', r, s), ...failureLines(r, s)];
if (r.ok !== false || r.lines !== undefined) lines.push(`csi log lines ${r.csi_callbacks ?? 0} · console lines ${r.lines ?? '—'} · ${r.baud ?? '—'} baud · reset on open: ${r.reset_on_open === false ? 'no' : 'unknown'}`);
return lines.join('\n');
}
function renderFlash(r, s) {
const lines = [head(r.dryRun ? 'FLASH PLAN' : 'FLASH', r, s), ...failureLines(r, s)];
const p = r.plan;
if (p) {
lines.push(s.muted(`${p.chip} · ${p.flashSize} · ${p.port} · checksums ${p.checksums || 'none'}`));
lines.push('', table([['OFFSET', 'ROLE', 'BYTES', 'INTEGRITY'], ...(p.images || []).map((i) => [i.offset, i.role, String(i.bytes), i.integrity === 'verified' ? s.acid(i.integrity) : s.warn(i.integrity)])], s));
}
if (r.bootLog) lines.push('', `boot log: ${r.bootLog.lines ?? 0} lines · CSI callbacks ${r.bootLog.csiCallbacks ?? 0} · panic ${r.bootLog.panic ? s.bad('yes') : 'no'}`);
if (r.evidence) lines.push(s.acid(r.evidence));
return lines.join('\n');
}
function renderGeneric(tool, r, s) {
const view = String(tool || 'result').replace(/^ruview_/, '').replace(/_/g, ' ').toUpperCase();
const lines = [head(view, r, s), ...failureLines(r, s)];
const scalars = Object.entries(r).filter(([k, v]) => !['ok', 'reason', 'detail', 'remedy', 'hint'].includes(k) && (v === null || ['string', 'number', 'boolean'].includes(typeof v)));
for (const [k, v] of scalars.slice(0, 16)) lines.push(`${s.muted(pad(k, 18))}${String(v).length > 140 ? `${String(v).slice(0, 137)}…` : v}`);
const nested = Object.keys(r).filter((k) => r[k] && typeof r[k] === 'object');
if (nested.length) lines.push(s.muted(`… ${nested.join(', ')} (use --json for the full result)`));
return lines.join('\n');
}
/**
* Render a tool result for a terminal. `history` (Map of node key → rates)
* adds trend sparklines in watch mode.
*/
export function renderResult(tool, r, { color = false, history } = {}) {
const s = createStyle(color);
if (!r || typeof r !== 'object') return String(r);
if (tool === 'ruview_esp32_capture' || r.nodes) return renderCapture(r, s, history);
if (tool === 'ruview_devices_scan') return renderDevices(r, s);
if (tool === 'ruview_node_monitor') return renderMonitor(r, s);
if (tool === 'ruview_node_flash' || tool === 'ruview_firmware_plan') return renderFlash(r, s);
return renderGeneric(tool, r, s);
}
/**
* Show a countdown on stderr while `fn` runs (TTY only). Returns fn's result.
*/
export async function withProgress(label, seconds, fn, stream = process.stderr) {
if (!stream.isTTY || !seconds) return fn();
const frames = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
const start = Date.now();
let i = 0;
const draw = () => {
const left = Math.max(0, seconds - (Date.now() - start) / 1000);
stream.write(`\r${frames[i++ % frames.length]} ${label} · ${left.toFixed(0)}s left `);
};
draw();
const timer = setInterval(draw, 120);
try { return await fn(); } finally { clearInterval(timer); stream.write('\r\x1b[2K'); }
}
+401
View File
@@ -0,0 +1,401 @@
// SPDX-License-Identifier: MIT
// `npx ruview` — the RuView WiFi-sensing operator harness (minted via metaharness,
// hardened per ADR-182). Plain ESM, no build step: ships and runs as-is.
//
// The `ruview.*` tools (onboard/verify/claim-check/…) and local host adapters are
// pure Node and run with zero runtime dependencies. Modules load on demand so
// `--help`/`--version`/`skills` never pay for the tool registry (ADR-375).
import { fileURLToPath } from 'node:url';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { colorEnabled, renderResult, withProgress } from './cli-ui.js';
const NAME = 'ruview';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const SKILLS_DIR = join(ROOT, 'skills');
const tools = () => import('./tools.js');
// Map friendly CLI verbs → registry tool names (underscore-canonical, ADR-263).
const VERB_TO_TOOL = {
onboard: 'ruview_onboard',
verify: 'ruview_verify',
'claim-check': 'ruview_claim_check',
calibrate: 'ruview_calibrate',
monitor: 'ruview_node_monitor',
flash: 'ruview_node_flash',
guidance: 'ruview_guidance',
spaces: 'ruview_spaces_list',
kernel: 'ruview_kernel_selftest',
'flash-plan': 'ruview_firmware_plan',
ports: 'ruview_firmware_ports',
train: 'ruview_train',
'train-plan': 'ruview_train_plan',
'train-gate': 'ruview_train_gate',
devices: 'ruview_devices_scan',
esp32: 'ruview_esp32_capture',
mmwave: 'ruview_mmwave_read',
lidar: 'ruview_lidar_read',
};
// Verbs whose kebab-case flags map 1:1 onto snake_case schema fields (ADR-369).
const SNAKE_VERBS = new Set(['flash', 'flash-plan', 'train', 'train-plan', 'train-gate', 'devices', 'esp32', 'mmwave', 'lidar']);
const NUMERIC_FLAGS = new Set(['baud', 'boot_log_seconds', 'samples', 'model_score', 'baseline_score', 'n_test', 'seconds', 'udp_port', 'max_packets', 'max_frames', 'node_id', 'analyze_max_frames']);
const BOOLEAN_FLAGS = new Set(['confirm', 'cuda', 'allow_unverified', 'analyze']);
// Presentation-only flags never reach a tool schema.
const UI_FLAGS = new Set(['json', 'watch', 'interval']);
function toSchemaArgs(flags) {
const out = {};
for (const [key, value] of Object.entries(flags)) {
const k = key.replaceAll('-', '_');
if (UI_FLAGS.has(k)) continue;
if (NUMERIC_FLAGS.has(k)) out[k] = Number(value);
else if (BOOLEAN_FLAGS.has(k)) out[k] = value === true || value === 'true';
else if (k === 'train_subjects' || k === 'test_subjects') out[k] = String(value).split(',').filter(Boolean);
else out[k] = value;
}
return out;
}
function pjson(o) { console.log(JSON.stringify(o, null, 2)); }
/** JSON for pipes and --json; the terminal UI for a human at a TTY. */
function emit(tool, res, flags) {
if (flags.json === true || !process.stdout.isTTY) pjson(res);
else console.log(renderResult(tool, res, { color: colorEnabled() }));
}
/** Tools that hold the terminal for a known window get a countdown (TTY only). */
function progressLabel(tool, args) {
if (tool === 'ruview_esp32_capture') return [`Listening on UDP ${args.bind || '0.0.0.0'}:${args.udp_port || 5005}`, args.seconds ?? 10];
if (tool === 'ruview_node_monitor') return [`Reading ${args.port} at ${args.baud || 115200} baud`, args.seconds ?? 12];
if (tool === 'ruview_mmwave_read' || tool === 'ruview_lidar_read') return [`Reading ${args.port || args.url || 'device'}`, args.seconds ?? 10];
return [null, 0];
}
async function callTool(tool, args) {
const { runTool } = await tools();
const [label, seconds] = progressLabel(tool, args);
return label ? withProgress(label, seconds, () => runTool(tool, args, { source: 'cli' })) : runTool(tool, args, { source: 'cli' });
}
/** `esp32 --watch`: repeated short captures, redrawn in place, with per-node trend lines. */
async function watchCapture(args, flags) {
const { runTool } = await tools();
const window = { ...args, seconds: args.seconds ?? 3 };
const history = new Map();
let stop = false;
process.once('SIGINT', () => { stop = true; });
const tty = process.stdout.isTTY && flags.json !== true;
while (!stop) {
const res = await runTool('ruview_esp32_capture', window, { source: 'cli' });
for (const n of res.nodes || []) {
const key = `${n.source}:${n.nodeId}`;
history.set(key, [...(history.get(key) || []), n.csiRateHz].slice(-30));
}
if (!tty) { console.log(JSON.stringify(res)); continue; }
process.stdout.write('\x1b[2J\x1b[H');
console.log(renderResult('ruview_esp32_capture', res, { color: colorEnabled(), history }));
console.log(`\n${new Date().toLocaleTimeString()} · ${window.seconds}s windows · Ctrl+C to stop`);
if (res.reason === 'port_in_use' || res.reason === 'invalid_port' || res.reason === 'invalid_bind') return 1;
}
return 0;
}
function listSkills() {
if (!existsSync(SKILLS_DIR)) return [];
return readdirSync(SKILLS_DIR).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, ''));
}
async function doctor(flags) {
const [{ formatDoctor, runDoctor, DOCTOR_GROUPS }, { OPERATOR_DEPS }] = await Promise.all([import('./doctor.js'), tools()]);
const groups = typeof flags.group === 'string' ? flags.group.split(',').map((g) => g.trim()).filter(Boolean) : undefined;
const unknown = (groups || []).filter((g) => !DOCTOR_GROUPS.includes(g));
if (unknown.length) { console.error(`doctor: unknown group(s) ${unknown.join(', ')}; valid: ${DOCTOR_GROUPS.join(', ')}`); return 2; }
const report = await runDoctor({
groups,
port: typeof flags.port === 'string' ? flags.port : undefined,
probe_port: flags.probe === true,
sensing_url: typeof flags.url === 'string' ? flags.url : undefined,
}, OPERATOR_DEPS);
if (flags.json === true) pjson(report);
else console.log(formatDoctor(report));
return report.ok ? 0 : 1;
}
async function mcp(rest, flags) {
if (rest[0] !== undefined && rest[0] !== 'start') { console.error('Usage: ruview mcp start [--http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]]'); return 2; }
if (flags.http !== true) {
const { startMcpServer } = await import('./mcp-server.js');
startMcpServer();
return new Promise(() => {}); // run until stdin closes
}
const { startMcpHttp, DEFAULT_HTTP_PORT } = await import('./mcp-http.js');
const host = typeof flags.host === 'string' ? flags.host : '127.0.0.1';
const port = flags.port === undefined ? DEFAULT_HTTP_PORT : Number(flags.port);
if (!Number.isInteger(port) || port < 0 || port > 65535) { console.error('mcp start: --port must be 0..65535'); return 2; }
const allowOrigins = typeof flags['allow-origin'] === 'string' ? flags['allow-origin'].split(',').map((o) => o.trim()).filter(Boolean) : [];
// The token is read from the environment, never from argv (process lists are visible to other users).
const srv = await startMcpHttp({ host, port, token: process.env.RUVIEW_MCP_TOKEN, allowOrigins });
const generated = !process.env.RUVIEW_MCP_TOKEN;
process.stderr.write([
`ruview MCP over HTTP on ${srv.url}${srv.loopback ? '' : ' (non-loopback bind: anyone who can reach this port and holds the token can call read tools)'}`,
` grants: ${srv.grants.join(', ') || 'none (set RUVIEW_MCP_GRANTS=device-access for hardware reads)'}`,
` token: ${generated ? `${srv.token} (generated; set RUVIEW_MCP_TOKEN to keep it stable)` : 'from RUVIEW_MCP_TOKEN'}`,
' clients with headers: Authorization: Bearer <token>',
' ChatGPT (no custom headers): expose the port over HTTPS (e.g. a tunnel) and add the connector URL',
` https://<public-host>/mcp/${generated ? srv.token : '<token>'}`,
'',
].join('\n'));
const shutdown = () => srv.close().then(() => process.exit(0));
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
return new Promise(() => {});
}
function help() {
console.log(`Usage: ${NAME} <command> [options]
Operator tools:
onboard [--path docker-demo|repo-build|live-esp32] pick a setup path
verify [--repo <dir>] run the deterministic proof (VERDICT: PASS)
claim-check --text "..." | --file <path> lint accuracy claims (the honesty guardrail)
calibrate --step baseline|enroll|train-room|room-watch
monitor --port COM8 [--seconds 12] [--baud 1500000] assert CSI is flowing on a node (no reset on open)
flash --port COM8 --variant s3-8mb [--confirm] build+flash firmware (Windows/ESP-IDF)
guidance [--topic homecore] [--query "Wasmtime"] source-cited code/capability map
spaces [--resource sites|...|alerts] [--limit 50] page OAuth-bound Cognitum spatial resources
kernel [--backend wasm|napi|auto] [--seconds 60] SYNTHETIC self-test of @ruvnet/ruview-kernel (optional)
Firmware (ADR-370, cross-platform esptool):
ports list serial ports (pyserial)
flash-plan --port <p> --bundle <dir> [--variant s3-8mb|s3-4mb|c6] verify + print the plan
flash --port <p> --bundle <dir> [--variant ...] [--baud 460800] --confirm
[--boot-log-seconds 15] [--allow-unverified] write flash, then capture boot evidence
Devices (ADR-373) — run on the machine the hardware is attached to:
devices classify USB serial devices (ESP32, Realtek, mmWave, RPLIDAR)
esp32 [--udp-port 5005] [--seconds 10] [--bind 0.0.0.0] receive + summarize ESP32 / Realtek RAC1 node UDP streams
[--analyze [--node-id N] [--backend wasm|napi|auto]] run live CSI through @ruvnet/ruview-kernel
[--watch [--seconds 3]] live view: repeated windows with per-node trends
mmwave --port <p> [--model auto|mr60bha2|ld2410] [--seconds 10] 60/24 GHz radar readout
lidar --source rplidar --port <p> [--baud 115200] RPLIDAR scan summary
lidar --source iphone --url ws://HOST:8787/ws/lidar iPhone LiDAR relay (token: RUVIEW_LIDAR_TOKEN)
Remote hosts (ADR-374) — SSH, read-only tools only:
hosts add --name pi --ssh user@pi.local [--port 22] [--version X.Y.Z]
hosts list
hosts run --host pi --tool ruview_devices_scan [--args-json '{"seconds":10}']
call <tool> [--read-only] [--args-json '{...}'] generic schema-validated tool call
Training (ADR-371):
train-plan [--mode pose-smoke|pose|room] [...] resolve the command, run nothing
train --mode pose-smoke|pose|room [--config f] [--data-dir d] [--checkpoint-dir d]
[--enrollment f] [--output f] [--samples 64] [--cuda] --confirm
train-gate --file report.json | --model-score .59 --baseline-score .50 --split chronological ...
Harness:
doctor [--json] [--group a,b] [--port P [--probe]] [--url http://host:port]
structured diagnostics with fixes (ADR-372)
skills list bundled skills
skill <name> print a skill playbook
mcp start run the ruview.* MCP server (stdio)
mcp start --http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]
MCP over HTTP for ChatGPT and remote clients (ADR-375);
token: RUVIEW_MCP_TOKEN (generated if unset), grants: RUVIEW_MCP_GRANTS
install --host <h> project the harness config into the current repo
agent run --host claude-code|codex --prompt "..." [--repo <dir>]
brain search --query "..." | verify | propose
--version | --help
Output: a terminal gets a formatted view; pipes and --json get JSON.
Hosts implemented and tested locally: claude-code (-p), codex (exec)`);
return 0;
}
/** tiny flag parser: --k v / --k=v / --flag (boolean) */
function parseFlags(rest) {
const f = {};
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a.startsWith('--')) {
const eq = a.indexOf('=');
if (eq !== -1) { f[a.slice(2, eq)] = a.slice(eq + 1); }
else if (i + 1 < rest.length && !rest[i + 1].startsWith('--')) { f[a.slice(2)] = rest[++i]; }
else { f[a.slice(2)] = true; }
}
}
return f;
}
export async function run(args) {
const cmd = args[0] ?? 'onboard';
const rest = args.slice(1);
const flags = parseFlags(rest);
// Direct tool verbs.
if (VERB_TO_TOOL[cmd]) {
const tool = VERB_TO_TOOL[cmd];
const toolArgs = { ...flags };
for (const k of UI_FLAGS) delete toolArgs[k];
if (cmd === 'claim-check') {
if (flags.file) {
toolArgs.text = readFileSync(flags.file, 'utf8');
delete toolArgs.file;
}
// Fail closed (ADR-263 O1): an honesty gate must never PASS on no input.
if (typeof toolArgs.text !== 'string' || toolArgs.text.trim().length === 0) {
console.error('claim-check: no input — pass --text "..." or --file <path> (empty input is an error, not a PASS).');
return 2;
}
const res = await callTool(tool, toolArgs);
emit(tool, res, flags);
return res.ok ? 0 : 1;
}
if (cmd === 'monitor' && flags.seconds) toolArgs.seconds = Number(flags.seconds);
if (cmd === 'monitor' && flags.baud) toolArgs.baud = Number(flags.baud);
if (cmd === 'guidance' && flags.limit) toolArgs.limit = Number(flags.limit);
if (cmd === 'calibrate' && typeof flags.args === 'string') toolArgs.args = flags.args.split(',');
if (cmd === 'kernel' && flags.seconds !== undefined) toolArgs.seconds = Number(flags.seconds);
if (SNAKE_VERBS.has(cmd)) {
let snake = toSchemaArgs(flags);
if (cmd === 'train-gate' && typeof flags.file === 'string') {
snake = JSON.parse(readFileSync(flags.file, 'utf8'));
}
if (cmd === 'esp32' && flags.watch === true) return watchCapture(snake, flags);
const res = await callTool(tool, snake);
emit(tool, res, flags);
return res.ok ? 0 : 1;
}
if (cmd === 'spaces') {
if (flags['credentials-path'] !== undefined) toolArgs.credentials_path = flags['credentials-path'];
delete toolArgs['credentials-path'];
if (flags.limit !== undefined) toolArgs.limit = Number(flags.limit);
}
const res = await callTool(tool, toolArgs);
emit(tool, res, flags);
return res.ok ? 0 : 1;
}
switch (cmd) {
case 'doctor': return doctor(flags);
case 'skills': console.log(listSkills().join('\n') || '(none)'); return 0;
case 'skill': {
const n = rest[0];
const p = n && join(SKILLS_DIR, `${n}.md`);
if (!p || !existsSync(p)) { console.error(`No skill "${n}". Try: ${listSkills().join(', ')}`); return 2; }
console.log(readFileSync(p, 'utf8'));
return 0;
}
case 'mcp': return mcp(rest, flags);
case 'agent': {
if (rest[0] !== 'run') { console.error('Usage: ruview agent run --host claude-code|codex --prompt "..." [--repo <dir>]'); return 2; }
const [{ findRepoRoot }, { getHost }] = await Promise.all([tools(), import('./hosts/index.js')]);
const hostName = String(flags.host || 'codex');
const prompt = String(flags.prompt || '');
const repo = flags.repo ? resolve(flags.repo) : findRepoRoot();
if (!repo) { console.error('agent run: trusted RuView repo not found; pass --repo <root>.'); return 2; }
if (!prompt.trim()) { console.error('agent run: --prompt is required.'); return 2; }
const allowWrite = flags['allow-write'] === true;
if (allowWrite && flags.confirm !== true) { console.error('agent run: --allow-write also requires --confirm.'); return 2; }
try {
const result = await getHost(hostName).run({
prompt, repoRoot: repo, trustedRoot: repo, allowWrite, confirm: flags.confirm === true,
});
pjson({ ok: true, host: hostName, mode: allowWrite ? 'workspace-write' : 'read-only', stdout: result.stdout, stderr: result.stderr });
return 0;
} catch (error) {
pjson({ ok: false, host: hostName, error: error.message });
return 1;
}
}
case 'brain': {
const { makeProposal, searchBrain, verifyBrain } = await import('./brain.js');
const action = rest[0] || 'search';
if (action === 'search') {
const query = String(flags.query || '');
if (!query.trim()) { console.error('brain search: --query is required.'); return 2; }
pjson({ ok: true, results: searchBrain(query, { limit: flags.limit }) }); return 0;
}
if (action === 'verify') {
const { findRepoRoot } = await tools();
const repo = flags.repo ? resolve(flags.repo) : findRepoRoot();
if (!repo) { console.error('brain verify: RuView repo not found.'); return 2; }
const result = verifyBrain({ repo }); pjson(result); return result.ok ? 0 : 1;
}
if (action === 'propose') {
const result = makeProposal(flags); pjson(result); return result.ok ? 0 : 1;
}
console.error('Usage: ruview brain search|verify|propose'); return 2;
}
case 'install': {
const host = flags.host || 'claude-code';
if (!['claude-code', 'codex'].includes(host)) {
console.error(`Host "${host}" is not implemented. Supported: claude-code, codex.`);
return 2;
}
try {
const { getHost } = await import('./hosts/index.js');
const adapter = getHost(host);
console.log(`Projecting RuView harness for host "${host}" via ${adapter.name}.`);
console.log('Add to your host config — MCP server command: npx -y ruview mcp start');
console.log('Skills:', listSkills().join(', '));
return 0;
} catch {
console.error(`Host adapter "${host}" is unavailable.`);
return 1;
}
}
case 'tools': { const { listTools } = await tools(); pjson(listTools()); return 0; }
case 'call': {
// Generic, schema-validated tool call (ADR-374). `--read-only` is what
// remote hosts receive: it refuses anything but read-only tools.
const { isRemoteReadOnlyTool, resolveToolName } = await tools();
const name = resolveToolName(rest[0] || '');
if (!name) { console.error('Usage: ruview call <tool> [--read-only] [--args-json \'{...}\']'); return 2; }
if (flags['read-only'] === true && !isRemoteReadOnlyTool(name)) {
pjson({ ok: false, reason: 'remote_tool_not_allowed', name }); return 1;
}
let toolArgs = {};
if (typeof flags['args-json'] === 'string') {
try { toolArgs = JSON.parse(flags['args-json']); } catch { pjson({ ok: false, reason: 'invalid_arguments', errors: ['--args-json is not valid JSON'] }); return 2; }
}
const res = await callTool(name, toolArgs);
// Remote hosts parse this stream: `call` always prints JSON.
pjson(res);
return res.ok ? 0 : 1;
}
case 'hosts': {
const action = rest[0] || 'list';
if (action === 'list') { const res = await callTool('ruview_host_list', {}); pjson(res); return res.ok ? 0 : 1; }
if (action === 'add') {
const { saveHost } = await import('./remote.js');
try {
const out = saveHost({ name: flags.name, ssh: flags.ssh, port: flags.port ? Number(flags.port) : undefined, version: flags.version, description: flags.description });
pjson({ ok: true, ...out, next: `Verify the host key once: ssh ${out.host.ssh} true` }); return 0;
} catch (error) { pjson({ ok: false, reason: error.reason || 'invalid_host', detail: error.message }); return 2; }
}
if (action === 'run') {
const { resolveToolName } = await tools();
let toolArgs = {};
if (typeof flags['args-json'] === 'string') {
try { toolArgs = JSON.parse(flags['args-json']); } catch { console.error('--args-json is not valid JSON'); return 2; }
}
const tool = resolveToolName(String(flags.tool || '')) || String(flags.tool || '');
const res = await callTool('ruview_host_run', { host: String(flags.host || ''), tool, args: toolArgs });
pjson(res); return res.ok ? 0 : 1;
}
console.error('Usage: ruview hosts list | add --name pi --ssh user@pi.local [--port 22] [--version X.Y.Z] | run --host pi --tool ruview_devices_scan [--args-json {...}]');
return 2;
}
case '--version': case '-v': {
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
console.log(pkg.version); return 0;
}
case '--help': case '-h': return help();
default:
console.error(`Unknown command: ${cmd}. Try \`${NAME} --help\`.`);
return 2;
}
}
+1 -1
View File
@@ -345,7 +345,7 @@ export function captureEsp32(args = {}, deps = {}) {
if (packets === 0) {
failure = { reason: 'no_packets', remedy: `No node packets reached ${bind}:${port}. Check the node's target IP/port (provision.py --target-ip/--target-port), that this host is on the same network, and the firewall (UDP ${port}).` };
} else if (NODE_PACKETS(summary) === 0 && !summary.meshMessages && summary.heartbeats > 0) {
failure = { reason: 'heartbeat_only', remedy: 'Realtek nodes are alive (RHB1 heartbeats) but no RAC1 CSI arrived. Reset the board; if it persists, check its serial log at 1500000 baud for csi_sequence and reduce channel contention (ADR-323).' };
failure = { reason: 'heartbeat_only', remedy: 'Realtek nodes are alive (RHB1 heartbeats) but no RAC1 CSI arrived. Run `ruview monitor --port <p> --baud 1500000`: "lack of csi buf" lines mean CSI report-buffer starvation in the firmware (reset the board); otherwise check csi_sequence and channel contention (ADR-323).' };
} else if (decoded === 0) {
failure = { reason: 'no_decodable_packets', remedy: 'Packets arrived but none matched a known RuView format (see unknownMagics). Check the sender and firmware version.' };
}
+120
View File
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: MIT
// MCP over Streamable HTTP (ADR-375) — what ChatGPT and other remote MCP
// clients connect to. Same handler and FIFO tool chain as stdio, plus:
// - authentication on every request: `Authorization: Bearer <token>` or the
// secret-path form `/mcp/<token>` (ChatGPT connectors cannot send a static
// bearer header), compared in constant time;
// - loopback bind by default; a non-loopback bind is the operator's choice;
// - Origin allowlist (DNS-rebinding defence): browser origins are refused
// unless listed; server-to-server clients send no Origin;
// - write grants (workspace-write, hardware-write) are never honoured over
// HTTP, so flashing and calibration stay stdio/CLI-only;
// - single JSON responses (no SSE stream), bounded bodies, no sessions.
import http from 'node:http';
import { randomBytes, timingSafeEqual, createHash } from 'node:crypto';
import { createDispatcher, MAX_REQUEST_BYTES, parseGrants, SERVER_INFO, SUPPORTED_PROTOCOLS } from './mcp-server.js';
export const DEFAULT_HTTP_PORT = 8790;
export const HTTP_WRITE_GRANTS = Object.freeze(['workspace-write', 'hardware-write']);
const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']);
const digest = (s) => createHash('sha256').update(String(s)).digest();
function sameSecret(a, b) {
return typeof a === 'string' && a.length > 0 && timingSafeEqual(digest(a), digest(b));
}
export function generateToken() {
return randomBytes(24).toString('base64url');
}
/** Grants usable over HTTP: everything the operator granted except writes. */
export function httpGrants(grants) {
return grants.filter((g) => !HTTP_WRITE_GRANTS.includes(g));
}
function send(res, status, body, headers = {}) {
const payload = body === undefined ? '' : JSON.stringify(body);
res.writeHead(status, {
'content-type': 'application/json',
'cache-control': 'no-store',
'x-content-type-options': 'nosniff',
...(payload ? { 'content-length': Buffer.byteLength(payload) } : {}),
...headers,
});
res.end(payload);
}
/**
* Start the HTTP transport. opts: { host='127.0.0.1', port=8790, token,
* allowOrigins=[], grants=env RUVIEW_MCP_GRANTS, log }. Resolves to
* { server, url, secretUrl, token, grants, close() }.
*/
export function startMcpHttp(opts = {}) {
const host = opts.host || '127.0.0.1';
const port = opts.port ?? DEFAULT_HTTP_PORT;
const token = opts.token || generateToken();
if (token.length < 16) return Promise.reject(new Error('token must be at least 16 characters'));
const allowOrigins = new Set(opts.allowOrigins || []);
const requested = opts.grants || parseGrants();
const grants = httpGrants(requested);
const log = opts.log || ((...a) => process.stderr.write('[ruview-mcp-http] ' + a.join(' ') + '\n'));
const dropped = requested.filter((g) => !grants.includes(g));
if (dropped.length) log(`write grants are never honoured over HTTP; ignoring: ${dropped.join(', ')}`);
const { dispatch } = createDispatcher({ source: 'mcp', transport: 'http', grants });
const server = http.createServer((req, res) => {
const url = new URL(req.url || '/', 'http://localhost');
if (req.method === 'GET' && url.pathname === '/healthz') return send(res, 200, { ok: true, name: SERVER_INFO.name, version: SERVER_INFO.version });
const segments = url.pathname.split('/').filter(Boolean);
if (segments[0] !== 'mcp' || segments.length > 2) return send(res, 404, { error: 'not_found' });
const auth = /^Bearer\s+(.+)$/i.exec(req.headers.authorization || '');
if (!sameSecret(segments[1] || (auth && auth[1].trim()), token)) {
return send(res, 401, { error: 'unauthorized' }, { 'www-authenticate': 'Bearer realm="ruview"' });
}
const origin = req.headers.origin;
if (origin && !allowOrigins.has(origin)) return send(res, 403, { error: 'origin_not_allowed' });
if (req.method !== 'POST') return send(res, 405, { error: 'method_not_allowed' }, { allow: 'POST' });
const version = req.headers['mcp-protocol-version'];
if (version && !SUPPORTED_PROTOCOLS.includes(version)) return send(res, 400, { error: 'unsupported_protocol_version', supported: SUPPORTED_PROTOCOLS });
let size = 0;
const chunks = [];
let aborted = false;
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_REQUEST_BYTES) { aborted = true; send(res, 413, { error: 'request_too_large' }); req.destroy(); return; }
chunks.push(chunk);
});
req.on('end', async () => {
if (aborted) return;
let msg;
try { msg = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {
return send(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
}
if (Array.isArray(msg)) return send(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Batches are not supported' } });
const reply = await dispatch(msg);
if (!reply) { res.writeHead(202, { 'cache-control': 'no-store' }); return res.end(); }
send(res, 200, reply);
});
});
// Tool runs (captures, analysis) can take minutes; headers must still arrive fast.
server.headersTimeout = 10_000;
server.requestTimeout = 15 * 60_000;
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, host, () => {
server.off('error', reject);
const address = server.address();
const shownHost = address.family === 'IPv6' ? `[${address.address}]` : address.address;
const base = `http://${shownHost}:${address.port}/mcp`;
resolve({
server, token, grants, loopback: LOOPBACK.has(host),
url: base, secretUrl: `${base}/${token}`,
close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }),
});
});
});
}
+114 -86
View File
@@ -1,148 +1,176 @@
// SPDX-License-Identifier: MIT
// RuView harness — minimal MCP stdio server (JSON-RPC 2.0 over stdin/stdout).
// RuView harness — dependency-free MCP server core (JSON-RPC 2.0).
//
// Dependency-free on purpose: a published `npx ruview` must `mcp start` without
// pulling the full MCP SDK. Implements the subset hosts use: `initialize`,
// `tools/list`, `tools/call`, `ping`, empty `resources/list`/`prompts/list`
// stubs, and the `notifications/initialized` ack. Logs go to stderr ONLY —
// stdout is the JSON-RPC channel and must stay clean.
// pulling the full MCP SDK. `handleRpc` is transport-neutral; stdio (this file)
// and Streamable HTTP (mcp-http.js, ADR-375) both drive it. Logs go to stderr
// ONLY — on stdio, stdout is the JSON-RPC channel and must stay clean.
//
// ADR-263 O2: `tools/call` is dispatched asynchronously — a long-running
// verify/calibrate no longer blocks ping/tools/list, so hosts that health-check
// mid-run see a live server. Responses may therefore arrive out of request
// order, which JSON-RPC permits (ids correlate them).
//
// ADR-375: results carry `structuredContent` (2025-06-18) next to a compact
// JSON text block, and the UI tools point at a self-contained `ui://` widget
// for MCP Apps hosts and ChatGPT.
import { createInterface } from 'node:readline';
import { readFileSync } from 'node:fs';
import { listTools, runTool } from './tools.js';
import { listTools, resolveToolName, runTool } from './tools.js';
import { CONSOLE_HTML, CONSOLE_RESOURCE, CONSOLE_RESOURCE_META } from './ui/console-widget.js';
const PROTOCOL_VERSION = '2024-11-05';
const MAX_REQUEST_BYTES = 256 * 1024;
/** Newest first; an unknown client version gets the newest we speak. */
export const SUPPORTED_PROTOCOLS = Object.freeze(['2025-06-18', '2025-03-26', '2024-11-05']);
export const MAX_REQUEST_BYTES = 256 * 1024;
const MAX_QUEUED_TOOL_CALLS = 20;
// Single-source the version from package.json (ADR-263 O6).
const PKG = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
const SERVER_INFO = { name: 'ruview', version: PKG.version };
export const SERVER_INFO = Object.freeze({ name: 'ruview', title: 'RuView', version: PKG.version });
function send(msg) {
process.stdout.write(JSON.stringify(msg) + '\n');
const INSTRUCTIONS = 'RuView WiFi-sensing operator tools. All results are fail-closed; accuracy claims must pass ruview_claim_check. Credentialed external reads are denied without an operator grant; ruview_spaces_list requires credential-use. Node captures, device scans and doctor results render in the ruview console widget.';
export function negotiateProtocol(requested) {
return SUPPORTED_PROTOCOLS.includes(requested) ? requested : SUPPORTED_PROTOCOLS[0];
}
function result(id, res) { send({ jsonrpc: '2.0', id, result: res }); }
function error(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
const fail = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
function log(...a) { process.stderr.write('[ruview-mcp] ' + a.join(' ') + '\n'); }
async function handle(msg, context = {}) {
/**
* Shape a tool result as an MCP CallToolResult: structured content for hosts
* and widgets, plus compact JSON text for clients that read only `content`
* (compact, not pretty-printed: the same data in fewer model tokens).
*/
export function toCallToolResult(name, out) {
const structured = out && typeof out === 'object' && !Array.isArray(out) ? out : { value: out };
return {
content: [{ type: 'text', text: JSON.stringify(structured) }],
structuredContent: structured,
isError: structured.ok === false,
_meta: { 'ruview/tool': name },
};
}
/**
* Handle one JSON-RPC message. Returns the response object, or null for a
* notification (no response is sent).
*/
export async function handleRpc(msg, context = {}) {
if (!msg || typeof msg !== 'object' || Array.isArray(msg) || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
return fail(msg && typeof msg === 'object' && 'id' in msg ? msg.id : null, -32600, 'Invalid Request');
}
const { id, method, params } = msg;
const isNotification = id === undefined;
switch (method) {
case 'initialize':
return result(id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
return ok(id, {
protocolVersion: negotiateProtocol(params?.protocolVersion),
capabilities: { tools: { listChanged: false }, resources: { listChanged: false } },
serverInfo: SERVER_INFO,
instructions: 'RuView WiFi-sensing operator tools. All results are fail-closed; accuracy claims must pass ruview_claim_check. Credentialed external reads are denied without an operator grant; ruview_spaces_list requires credential-use.',
instructions: INSTRUCTIONS,
});
case 'notifications/initialized':
case 'initialized':
return; // notifications — no response
return null;
case 'notifications/cancelled':
if (context.queuedIds?.has(params?.requestId)) context.cancelled?.add(params.requestId);
return; // queued requests are cancelled before execution
return null;
case 'ping':
return result(id, {});
return ok(id, {});
case 'tools/list':
return result(id, { tools: listTools() });
return ok(id, { tools: listTools() });
case 'resources/list':
return result(id, { resources: [] });
return ok(id, { resources: [{ ...CONSOLE_RESOURCE, _meta: CONSOLE_RESOURCE_META }] });
case 'resources/templates/list':
return ok(id, { resourceTemplates: [] });
case 'resources/read': {
if (params?.uri !== CONSOLE_RESOURCE.uri) return fail(id, -32002, `Resource not found: ${String(params?.uri).slice(0, 200)}`);
return ok(id, { contents: [{ uri: CONSOLE_RESOURCE.uri, mimeType: CONSOLE_RESOURCE.mimeType, text: CONSOLE_HTML, _meta: CONSOLE_RESOURCE_META }] });
}
case 'prompts/list':
return result(id, { prompts: [] });
return ok(id, { prompts: [] });
case 'tools/call': {
const name = params?.name;
const args = params?.arguments || {};
log('audit', JSON.stringify({ event: 'tools/call', id, name }));
log('audit', JSON.stringify({ event: 'tools/call', id, name, transport: context.transport || 'stdio' }));
const out = await runTool(name, args, context);
// MCP content envelope: text block with the JSON, isError reflects ok=false.
return result(id, {
content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
isError: out && out.ok === false,
});
return ok(id, toCallToolResult(resolveToolName(name) || name, out));
}
default:
if (id !== undefined) error(id, -32601, `Method not found: ${method}`);
return isNotification ? null : fail(id, -32601, `Method not found: ${method}`);
}
}
export function startMcpServer() {
log(`starting v${SERVER_INFO.version} (protocol ${PROTOCOL_VERSION}, ${listTools().length} tools)`);
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
// tools/call runs are serialized through a FIFO promise chain: hardware/mutating
// tools (calibrate, serial monitor, flash) must never overlap. ping/tools/list/
// initialize/resources/prompts stay immediate (ADR-263 O2 — a health check must
// answer during a long tool run). `toolChain` also lets stdin-close drain the
// in-flight call so its response is flushed instead of dropped by process.exit.
let toolChain = Promise.resolve();
let queuedToolCalls = 0;
/**
* Serialize tools/call through one FIFO chain: hardware/mutating tools
* (calibrate, serial monitor, flash) must never overlap. Everything else
* answers immediately (ADR-263 O2). Shared by the stdio and HTTP transports.
*/
export function createDispatcher(baseContext) {
let chain = Promise.resolve();
let queued = 0;
const cancelled = new Set();
const queuedIds = new Set();
const grants = String(process.env.RUVIEW_MCP_GRANTS || '').split(',').map((v) => v.trim()).filter(Boolean);
const dispatch = (msg) => handle(msg, { source: 'mcp', grants, cancelled, queuedIds }).catch((err) => {
if (msg && msg.id !== undefined) error(msg.id, -32603, String(err && err.message || err));
const context = { ...baseContext, cancelled, queuedIds };
const run = (msg) => handleRpc(msg, context).catch((err) => {
log('handler error:', String(err));
return msg && msg.id !== undefined ? fail(msg.id, -32603, String(err && err.message || err)) : null;
});
function dispatch(msg) {
if (!msg || msg.method !== 'tools/call') return run(msg);
const validId = typeof msg.id === 'string' || (typeof msg.id === 'number' && Number.isFinite(msg.id));
if (!validId) return Promise.resolve(fail(msg?.id ?? null, -32600, 'tools/call requires a finite string or number id'));
if (queuedIds.has(msg.id)) return Promise.resolve(fail(msg.id, -32600, 'Duplicate in-flight request id'));
if (queued >= MAX_QUEUED_TOOL_CALLS) { log('tool queue full:', String(msg.id)); return Promise.resolve(fail(msg.id, -32000, 'Tool queue is full')); }
queued += 1;
queuedIds.add(msg.id);
const result = chain.then(async () => {
try {
if (cancelled.delete(msg.id)) return fail(msg.id, -32800, 'Request cancelled');
return await run(msg);
} finally {
cancelled.delete(msg.id);
queuedIds.delete(msg.id);
queued -= 1;
}
});
chain = result.then(() => {}, () => {});
return result;
}
return { dispatch, idle: () => chain };
}
export function parseGrants(env = process.env) {
return String(env.RUVIEW_MCP_GRANTS || '').split(',').map((v) => v.trim()).filter(Boolean);
}
export function startMcpServer() {
log(`starting v${SERVER_INFO.version} (protocol ${SUPPORTED_PROTOCOLS[0]}, ${listTools().length} tools, stdio)`);
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
const { dispatch, idle } = createDispatcher({ source: 'mcp', transport: 'stdio', grants: parseGrants() });
const send = (res) => { if (res) process.stdout.write(JSON.stringify(res) + '\n'); };
rl.on('line', (line) => {
if (Buffer.byteLength(line, 'utf8') > MAX_REQUEST_BYTES) {
log('oversized JSON-RPC line dropped');
return;
}
if (Buffer.byteLength(line, 'utf8') > MAX_REQUEST_BYTES) { log('oversized JSON-RPC line dropped'); return; }
const s = line.trim();
if (!s) return;
let msg;
try { msg = JSON.parse(s); } catch { return log('bad JSON line dropped'); }
if (msg && msg.method === 'tools/call') {
const validId = typeof msg.id === 'string' || (typeof msg.id === 'number' && Number.isFinite(msg.id));
if (!validId) {
error(msg?.id ?? null, -32600, 'tools/call requires a finite string or number id');
return;
}
if (queuedIds.has(msg.id)) {
error(msg.id, -32600, 'Duplicate in-flight request id');
return;
}
if (queuedToolCalls >= MAX_QUEUED_TOOL_CALLS) {
if (msg.id !== undefined) error(msg.id, -32000, 'Tool queue is full');
log('tool queue full:', String(msg.id));
return;
}
queuedToolCalls += 1;
queuedIds.add(msg.id);
toolChain = toolChain.then(async () => {
try {
if (cancelled.delete(msg.id)) {
if (msg.id !== undefined) error(msg.id, -32800, 'Request cancelled');
return;
}
await dispatch(msg);
} finally {
cancelled.delete(msg.id);
queuedIds.delete(msg.id);
queuedToolCalls -= 1;
}
}); // one tool at a time
} else {
dispatch(msg); // health/list/handshake answer immediately, even mid tool run
}
try { msg = JSON.parse(s); } catch { log('bad JSON line dropped'); return; }
dispatch(msg).then(send);
});
rl.on('close', () => {
// Wait for any queued/in-flight tool call to settle (its response written)
// before exiting — fire-and-forget used to race this and drop the response.
toolChain.then(() => {
idle().then(() => setImmediate(() => {
log('stdin closed — exiting');
const done = () => process.exit(0);
// Pipe writes are async; flush buffered stdout before exit.
if (process.stdout.writableLength) process.stdout.once('drain', done);
else done();
});
}));
});
}
+16 -7
View File
@@ -28,6 +28,7 @@ import { execTool } from './exec.js';
import { BAUD_RATES, FIRMWARE_VARIANTS, flashFirmware, listSerialPorts } from './firmware.js';
import { SPLITS, TRAIN_MODES, runTraining, trainingGate } from './training.js';
import { DOCTOR_GROUPS, runDoctor } from './doctor.js';
import { UI_TOOLS, uiToolMeta } from './ui/console-widget.js';
import { scanDevices } from './devices/registry.js';
import { captureEsp32 } from './devices/esp32.js';
import { MMWAVE_MODELS, readMmwave } from './devices/mmwave.js';
@@ -146,7 +147,7 @@ const ONBOARD_PATHS = {
// Read-only serial monitor script; the port arrives via sys.argv (ADR-263 O5 —
// never spliced into interpreter source).
const MONITOR_SCRIPT = [
export const MONITOR_SCRIPT = [
'import sys,time',
'try:',
' import serial',
@@ -161,7 +162,7 @@ const MONITOR_SCRIPT = [
'ser.port=port; ser.baudrate=baud; ser.timeout=1',
'ser.dtr=False; ser.rts=False',
'ser.open()',
'csi=0; n=0; t=time.time()',
'csi=0; n=0; starved=0; t=time.time()',
'while time.time()-t<dur:',
' ln=ser.readline()',
' if not ln: continue',
@@ -169,8 +170,9 @@ const MONITOR_SCRIPT = [
' n+=1',
" if 'CSI cb' in s or 'csi_collector' in s or 'RUVIEW_CSI: frame #' in s: csi+=1",
" if 'MGMT+DATA' in s: print('UPGRADE_MGMT_DATA')",
" if 'lack of csi buf' in s or 'csi buf not enough' in s: starved+=1",
'ser.close()',
"print(f'LINES={n} CSI={csi}')",
"print(f'LINES={n} CSI={csi} STARVED={starved}')",
].join('\n');
/**
@@ -263,9 +265,14 @@ export const TOOLS = {
if (!r.ok) return { ok: false, reason: 'port_error', stderr: r.stderr, error: r.error };
const csi = Number((r.stdout.match(/CSI=(\d+)/) || [])[1] || 0);
const lines = Number((r.stdout.match(/LINES=(\d+)/) || [])[1] || 0);
const starved = Number((r.stdout.match(/STARVED=(\d+)/) || [])[1] || 0);
const upgraded = r.stdout.includes('UPGRADE_MGMT_DATA');
const out = { ok: csi > 0, csi_callbacks: csi, lines, baud, mgmt_data_upgrade: upgraded, reset_on_open: false, raw: r.stdout.trim() };
if (csi === 0) {
const out = { ok: csi > 0 && starved === 0, csi_callbacks: csi, lines, baud, mgmt_data_upgrade: upgraded, reset_on_open: false, raw: r.stdout.trim() };
if (starved) {
out.csi_buffer_starved = starved;
out.reason = 'csi_buffer_starvation';
out.hint = `${starved} 'lack of csi buf' lines: the radio is generating CSI reports but the firmware is not returning report buffers, so CSI stops while heartbeats continue (heartbeat-only on UDP). Reset the node now; the lasting fix is in the firmware's CSI report handling (buffer release/drain rate vs. CSI_REPORT_BUF_NUM).`;
} else if (csi === 0) {
out.hint = lines === 0
? 'No console output on this port. ESP32-C6/S3 builds with CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG log on the native USB port (303A:1001), not the UART bridge: attach that port, or confirm CSI over the network with `ruview esp32 --seconds 10`. Realtek Ameba boards log at --baud 1500000.'
: 'Console output but no CSI log lines in the window. CSI logs are rate-limited (ESP32: first 3, then every 100th callback); lengthen --seconds or confirm over the network with `ruview esp32 --seconds 10`.';
@@ -652,9 +659,11 @@ export async function runTool(name, args, context = {}) {
}
}
/** MCP-shaped tool list: [{name, description, inputSchema}]. */
/** MCP-shaped tool list: name, title, schema, annotations, and ui:// metadata for UI tools (ADR-375). */
export function listTools() {
return Object.entries(TOOLS).map(([name, t]) => ({
name, description: t.description, inputSchema: t.inputSchema, annotations: mcpAnnotations(name),
name, ...(t.title ? { title: t.title } : {}), description: t.description, inputSchema: t.inputSchema,
annotations: { ...(t.title ? { title: t.title } : {}), ...mcpAnnotations(name) },
...(UI_TOOLS.includes(name) ? { _meta: uiToolMeta() } : {}),
}));
}
+204
View File
@@ -0,0 +1,204 @@
// SPDX-License-Identifier: MIT
// RuView console widget (ADR-375): one self-contained HTML resource rendered by
// MCP Apps hosts (`_meta.ui.resourceUri`, mimeType text/html;profile=mcp-app)
// and ChatGPT (`openai/outputTemplate`). No external requests, no innerHTML:
// every value from a tool result reaches the DOM through textContent only.
export const CONSOLE_URI = 'ui://ruview/console-v1.html';
export const UI_MIME = 'text/html;profile=mcp-app';
export const CONSOLE_HTML = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>RuView console</title>
<style>
:root{color-scheme:dark;--bg:#090b0d;--panel:#0f1312;--text:#eef0e9;--muted:#9ca49e;--line:#29302d;--acid:#d0ff72;--warn:#ffcf5c;--bad:#ff7a6b;--mono:ui-monospace,Consolas,Menlo,monospace}
:root[data-theme=light]{color-scheme:light;--bg:#f7f8f4;--panel:#fff;--text:#121512;--muted:#5b625d;--line:#dde2da;--acid:#4a7a00;--warn:#8a5d00;--bad:#b42318}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,Arial,sans-serif}
main{padding:16px;max-width:880px;margin:0 auto}
.top{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px}
.eyebrow,.mono,.badge,.chip,th{font-family:var(--mono);font-size:11px;letter-spacing:1.2px;text-transform:uppercase}
.cmd{font-family:var(--mono);font-size:12px;overflow-wrap:anywhere}.id{font-family:var(--mono);font-size:12px;white-space:nowrap}
.eyebrow{color:var(--muted);flex:1;min-width:0}
.badge{border:1px solid var(--line);border-radius:3px;padding:2px 8px;color:var(--muted)}
.badge[data-s=ok]{color:var(--acid);border-color:var(--acid)}.badge[data-s=warn]{color:var(--warn);border-color:var(--warn)}.badge[data-s=bad]{color:var(--bad);border-color:var(--bad)}
button{font:inherit;font-size:12px;background:none;color:var(--text);border:1px solid var(--line);border-radius:3px;padding:5px 11px;cursor:pointer}
button:focus-visible{outline:2px solid var(--acid);outline-offset:2px}button:disabled{opacity:.5;cursor:wait}
.alert{border:1px solid var(--bad);border-left-width:4px;border-radius:4px;padding:10px 12px;margin:0 0 12px}
.alert.warn{border-color:var(--warn)}.alert b{display:block}.alert p{margin:4px 0 0;color:var(--muted)}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin-bottom:12px}
.stat,.card{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px 12px}
.stat .v{font-size:20px;font-weight:600}.stat .k,.k{color:var(--muted);font-size:12px}
.cards{display:grid;gap:10px}.card h3{margin:0 0 8px;font-size:15px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px}
.meter{height:4px;background:var(--line);border-radius:2px;margin-top:4px;overflow:hidden}.meter i{display:block;height:100%;background:var(--acid)}
.chip{display:inline-block;border:1px solid var(--line);border-radius:3px;padding:1px 6px;margin:2px 4px 0 0;color:var(--muted)}
.note{color:var(--muted);font-size:12px;margin:8px 0 0}
table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}th{color:var(--muted);font-weight:400}
pre{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px;overflow:auto;font:12px/1.45 var(--mono);max-height:340px}
.muted{color:var(--muted)}
</style></head>
<body><main id="app" aria-live="polite"><p class="muted">Waiting for a RuView result…</p></main>
<script>
(function(){
'use strict';
var app=document.getElementById('app');
var state={tool:null,args:null,result:null};
var pending={},rpcId=0;
function h(tag,props){
var el=document.createElement(tag);
if(props)Object.keys(props).forEach(function(k){
var v=props[k];if(v==null||v===false)return;
if(k==='class')el.className=v;else if(k.slice(0,2)==='on')el.addEventListener(k.slice(2),v);else el.setAttribute(k,v===true?'':String(v));
});
for(var i=2;i<arguments.length;i++)add(el,arguments[i]);
return el;
}
function add(el,c){if(c==null||c===false)return;if(Array.isArray(c)){c.forEach(function(x){add(el,x)});return}el.append(c instanceof Node?c:document.createTextNode(String(c)))}
function num(v,d){return typeof v==='number'&&isFinite(v)?v.toFixed(d==null?0:d):'—'}
function pct(v){return typeof v==='number'?(v*100).toFixed(v<0.01&&v>0?2:1)+'%':'—'}
function stat(k,v){return h('div',{class:'stat'},h('div',{class:'v'},v),h('div',{class:'k'},k))}
function meter(frac){var w=Math.max(0,Math.min(1,frac||0));return h('div',{class:'meter'},h('i',{style:'width:'+(w*100).toFixed(1)+'%'}))}
function metric(k,v,frac){return h('div',null,h('div',{class:'k'},k),h('div',null,v),frac==null?null:meter(frac))}
function alertBox(title,body,warn){return h('div',{class:'alert'+(warn?' warn':''),role:'alert'},h('b',null,title),body?h('p',null,body):null)}
function post(msg){if(window.parent&&window.parent!==window)window.parent.postMessage(msg,'*')}
function request(method,params){
var id=++rpcId;post({jsonrpc:'2.0',id:id,method:method,params:params||{}});
return new Promise(function(res,rej){pending[id]={res:res,rej:rej};setTimeout(function(){if(pending[id]){delete pending[id];rej(new Error('host did not answer '+method))}},20000)});
}
function applyTheme(t){if(t==='light'||t==='dark')document.documentElement.setAttribute('data-theme',t)}
function toolFrom(meta){return meta&&typeof meta['ruview/tool']==='string'?meta['ruview/tool']:null}
function setResult(r,tool){if(!r||typeof r!=='object')return;state.result=r;if(tool)state.tool=tool;render()}
function canRerun(){return !!(state.tool&&(window.openai&&window.openai.callTool||window.parent!==window))}
function rerun(btn){
btn.disabled=true;btn.textContent='Running…';
var call=window.openai&&window.openai.callTool?window.openai.callTool(state.tool,state.args||{}):request('tools/call',{name:state.tool,arguments:state.args||{}});
Promise.resolve(call).then(function(r){setResult(r&&(r.structuredContent||r),state.tool)}).catch(function(e){
btn.disabled=false;btn.textContent='Retry';app.prepend(alertBox('Refresh failed',e&&e.message||String(e),true));
});
}
function header(view,r){
var s=r.ok===false?'bad':(r.evidence&&/^SYNTHETIC/.test(r.evidence))?'warn':'ok';
var label=r.ok===false?(r.reason||'failed').replace(/_/g,' '):(r.evidence?r.evidence.split(':')[0]:'OK');
return h('div',{class:'top'},
h('div',{class:'eyebrow'},'RUVIEW / '+view),
h('span',{class:'badge','data-s':s},label),
canRerun()?h('button',{type:'button',onclick:function(e){rerun(e.currentTarget)}},'Refresh'):null);
}
function failure(r){return r.ok===false?alertBox((r.reason||'failed').replace(/_/g,' '),r.remedy||r.detail||r.hint||null):null}
function nodeCard(n,maxHz){
var src=n.source==='realtek'?'Realtek RTL8721Dx':'ESP32';
var csi=n.csi||{};
var shapes=(n.csiShapes||[]).map(function(s){return h('span',{class:'chip'},s.shape+' · '+s.frames)});
var seq=[];if(n.seqReordered)seq.push(n.seqReordered+' reordered');if(n.seqStrays)seq.push(n.seqStrays+' stray');if(n.seqResyncs)seq.push(n.seqResyncs+' resync');
var v=n.lastVitals;
return h('div',{class:'card'},
h('h3',null,src+' · node '+n.nodeId,h('span',{class:'chip'},Object.keys(n.packets||{}).map(function(k){return k+' '+n.packets[k]}).join(' · '))),
h('div',{class:'grid'},
metric('CSI rate',num(n.csiRateHz,1)+' Hz',maxHz?n.csiRateHz/maxHz:0),
h('div',null,h('div',{class:'k'},'Loss'),h('div',{style:n.csiLossFraction>0.05?'color:var(--warn)':null},pct(n.csiLossFraction))),
metric('RSSI',num(n.rssiMean,1)+' dBm',n.rssiMean==null?null:(n.rssiMean+95)/65),
metric('CSI shape',csi.shape?csi.shape+(csi.channel?' · ch '+csi.channel:csi.freqMhz?' · '+csi.freqMhz+' MHz':''):'—')),
shapes.length>1?h('div',{class:'note'},'Shapes: ',shapes):null,
seq.length?h('div',{class:'note'},'Sequence: '+seq.join(' · ')):null,
csi.synthetic?alertBox('SYNTHETIC frames','This node set the simulator flag; these are not live measurements.',true):null,
v?h('div',{class:'note'},'Device-reported (not validated): presence '+(v.presence?'yes':'no')+' · breathing '+num(v.breathingBpm,1)+' bpm · heart '+num(v.heartBpm,1)+' bpm'):null);
}
function analysis(a){
if(!a)return null;
if(a.ok===false)return alertBox('Kernel analysis: '+String(a.reason||'failed').replace(/_/g,' '),a.detail||a.remedy||null,true);
var s=a.summary||{},last=s.last||{},hr=last.heart||{},rr=last.respiratory||{},inp=a.input||{};
return h('div',{class:'card'},
h('h3',null,'Kernel analysis',h('span',{class:'chip'},(a.backend||'?')+' · '+(a.integrity||'?'))),
h('div',{class:'grid'},
metric('Input',(inp.frames||0)+' frames · '+num(inp.sampleRateHz,1)+' Hz'),
metric('Heart (est.)',num(hr.bpm,1)+' bpm · '+(hr.status||'—')),
metric('Breathing (est.)',rr.status==='unavailable'?'unavailable':num(rr.bpm,1)+' bpm · '+(rr.status||'—')),
metric('Signal quality',num(last.signal_quality,2))),
h('p',{class:'note'},a.note||'Estimates without a reference measurement.'));
}
function viewCapture(r){
var nodes=r.nodes||[],maxHz=nodes.reduce(function(m,n){return Math.max(m,n.csiRateHz||0)},0);
return [
failure(r),
(r.heartbeatOnlySenders||[]).length?alertBox('Heartbeat-only senders','Alive but no CSI: '+r.heartbeatOnlySenders.map(function(s){return s.address+' ('+s.heartbeats+')'}).join(', '),true):null,
h('div',{class:'stats'},stat('Packets',r.packets!=null?r.packets:'—'),stat('Decoded',r.decodedPackets!=null?r.decodedPackets:'—'),stat('CSI nodes',r.csiNodes!=null?r.csiNodes:'—'),stat('Window',(r.seconds||'—')+' s')),
h('div',{class:'cards'},nodes.map(function(n){return nodeCard(n,maxHz)}),analysis(r.analysis)),
r.unknownPackets?h('p',{class:'note'},'Unknown packets: '+r.unknownPackets+(r.unknownMagics?' ('+Object.keys(r.unknownMagics).join(', ')+')':'')):null,
r.listen?h('p',{class:'note cmd'},'listen '+r.listen):null];
}
function viewDevices(r){
return [failure(r),h('table',null,h('thead',null,h('tr',null,h('th',null,'Port'),h('th',null,'Bridge'),h('th',null,'Likely'),h('th',null,'Confirm with'))),
h('tbody',null,(r.devices||[]).filter(function(d){return !d.builtin}).map(function(d){
return h('tr',null,h('td',{class:'id'},d.port),h('td',null,d.bridge||d.usb||'unknown'),h('td',null,(d.likelyRoles||[]).map(function(x){return h('span',{class:'chip'},x)})),h('td',{class:'cmd'},(d.confirmWith||[])[0]||'—'))}))),
h('p',{class:'note'},r.note||'')];
}
function viewDoctor(r){
var s=r.summary||{};var rows=(r.checks||[]).map(function(c){
var st=c.status==='pass'?'ok':c.status==='fail'?'bad':c.status==='warn'?'warn':'';
return h('tr',null,h('td',null,h('span',{class:'badge','data-s':st},c.status)),h('td',{class:'id'},c.group+'/'+c.id),h('td',null,c.detail,c.remedy&&c.status!=='pass'?h('div',{class:'note'},'fix: '+c.remedy):null))});
return [h('div',{class:'stats'},stat('Pass',s.pass||0),stat('Warn',s.warn||0),stat('Fail',s.fail||0),stat('Skip',s.skip||0)),h('table',null,h('tbody',null,rows))];
}
function viewGeneric(r){return [failure(r),h('pre',null,JSON.stringify(r,null,2))]}
function render(){
var r=state.result;if(!r)return;
var t=state.tool||'';
var view=r.nodes||t==='ruview_esp32_capture'?['NODE STREAM',viewCapture]:r.devices?['DEVICES',viewDevices]:r.checks&&r.summary?['DOCTOR',viewDoctor]:[(t.replace(/^ruview_/,'').replace(/_/g,' ')||'RESULT').toUpperCase(),viewGeneric];
app.replaceChildren(header(view[0],r));add(app,view[1](r));
notifySize();
}
function notifySize(){var hgt=Math.ceil(document.documentElement.scrollHeight);if(window.openai&&typeof window.openai.notifyIntrinsicHeight==='function')window.openai.notifyIntrinsicHeight(hgt);else post({jsonrpc:'2.0',method:'ui/notifications/size-changed',params:{height:hgt}})}
window.addEventListener('message',function(e){
if(e.source!==window.parent)return;var m=e.data;if(!m||m.jsonrpc!=='2.0')return;
if(m.id!=null&&!m.method&&pending[m.id]){var p=pending[m.id];delete pending[m.id];if(m.error)p.rej(new Error(m.error.message||'host error'));else p.res(m.result);return}
var params=m.params||{};
if(m.method==='ui/notifications/tool-input')state.args=params.arguments||null;
else if(m.method==='ui/notifications/tool-result')setResult(params.structuredContent,toolFrom(params._meta));
else if(m.method==='ui/notifications/host-context-changed')applyTheme(params.theme);
});
function fromOpenAI(){var o=window.openai;if(!o)return;applyTheme(o.theme);if(o.toolInput)state.args=o.toolInput;if(o.toolOutput)setResult(o.toolOutput,toolFrom(o.toolResponseMetadata))}
window.addEventListener('openai:set_globals',fromOpenAI);
if(window.openai)fromOpenAI();
else request('ui/initialize',{protocolVersion:'2025-06-18',appInfo:{name:'ruview-console',version:'1'},appCapabilities:{}}).then(function(res){
applyTheme(res&&res.hostContext&&res.hostContext.theme);post({jsonrpc:'2.0',method:'ui/notifications/initialized',params:{}});
}).catch(function(){});
})();
</script></body></html>`;
/** The resource descriptor advertised by resources/list. */
export const CONSOLE_RESOURCE = Object.freeze({
uri: CONSOLE_URI,
name: 'ruview-console',
title: 'RuView console',
description: 'Interactive view of RuView node captures, device scans and diagnostics. Self-contained; makes no network requests.',
mimeType: UI_MIME,
});
/** Resource-level metadata: an empty CSP allowlist (no external origins) for both host families. */
export const CONSOLE_RESOURCE_META = Object.freeze({
ui: { csp: { connectDomains: [], resourceDomains: [] }, prefersBorder: true },
'openai/widgetDescription': 'Shows a RuView tool result: per-node CSI rate, loss and RSSI, device scans, or doctor checks, with a refresh button.',
'openai/widgetPrefersBorder': true,
'openai/widgetCSP': { connect_domains: [], resource_domains: [] },
});
/** Tools whose results render in the console widget. */
export const UI_TOOLS = Object.freeze(['ruview_esp32_capture', 'ruview_devices_scan', 'ruview_doctor']);
/** Tool-level metadata pointing a UI tool at the widget (MCP Apps + ChatGPT keys). */
export function uiToolMeta() {
return {
ui: { resourceUri: CONSOLE_URI },
'ui/resourceUri': CONSOLE_URI,
'openai/outputTemplate': CONSOLE_URI,
'openai/widgetAccessible': true,
'openai/toolInvocation/invoking': 'Reading RuView…',
'openai/toolInvocation/invoked': 'RuView result ready',
};
}
+96
View File
@@ -0,0 +1,96 @@
// ADR-375: terminal rendering. Pure functions, pinned without color; JSON stays
// the contract for pipes. Monitor starvation detection runs the real monitor
// script against a stand-in serial module.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { bar, colorEnabled, renderResult, sparkline, table, createStyle } from '../src/cli-ui.js';
import { MONITOR_SCRIPT } from '../src/tools.js';
const capture = {
ok: true, listen: '0.0.0.0:5005', seconds: 15, packets: 5199, decodedPackets: 5199, csiNodes: 2,
evidence: 'MEASURED: live UDP packets received and decoded on this host',
heartbeatOnlySenders: [{ address: '192.168.1.67', heartbeats: 50 }],
nodes: [
{ source: 'esp32', nodeId: 42, csiRateHz: 4.73, csiLossFraction: 0, rssiMean: -63.1, csi: { shape: '1x64' }, csiShapes: [{}, {}], packets: { csi: 71 } },
{ source: 'realtek', nodeId: 3, csiRateHz: 324.67, csiLossFraction: 0.1, rssiMean: -39.3, csi: { shape: '1x52', synthetic: false }, seqReordered: 37, seqStrays: 7, packets: { csi: 4870 } },
],
analysis: { ok: true, backend: 'wasm', integrity: 'verified', input: { frames: 12549, sampleRateHz: 313.7 }, summary: { last: { heart: { bpm: 99.06, status: 'unreliable' }, respiratory: { status: 'unavailable' } } }, note: 'no reference measurement' },
};
test('capture renders nodes, alerts and analysis without color codes', () => {
const out = renderResult('ruview_esp32_capture', capture, { color: false });
assert.doesNotMatch(out, /\x1b\[/);
assert.match(out, /^RUVIEW \/ NODE STREAM {3}● MEASURED/);
assert.match(out, /heartbeat-only: 192\.168\.1\.67 \(50 heartbeats, no CSI\)/);
assert.match(out, /esp32 +42 +4\.7 Hz/);
assert.match(out, /realtek +3 +324\.7 Hz +████████/);
assert.match(out, /10\.0%/);
assert.match(out, /1x64 \+1/);
assert.match(out, /37r 7s/);
assert.match(out, /heart 99\.1 bpm \(unreliable\) {3}breathing unavailable/);
const lines = out.split('\n');
const header = lines.find((l) => l.startsWith('SOURCE'));
const row = lines.find((l) => l.startsWith('realtek'));
assert.equal(row.indexOf('324.7'), header.indexOf('CSI RATE'), 'columns align');
});
test('failures lead with the reason and the fix; color is opt-in', () => {
const out = renderResult('ruview_esp32_capture', { ok: false, reason: 'heartbeat_only', remedy: 'reset the board', packets: 3 }, { color: false });
assert.match(out, /✖ heartbeat only/);
assert.match(out, /fix {2}reset the board/);
assert.match(renderResult('ruview_esp32_capture', capture, { color: true }), /\x1b\[38;5;155m/);
assert.equal(colorEnabled({ isTTY: true }, {}), true);
assert.equal(colorEnabled({ isTTY: true }, { NO_COLOR: '' }), false);
assert.equal(colorEnabled({ isTTY: true }, { TERM: 'dumb' }), false);
assert.equal(colorEnabled({ isTTY: false }, {}), false);
});
test('devices, monitor, flash and generic views', () => {
const dev = renderResult('ruview_devices_scan', { ok: true, devices: [{ port: 'COM10', bridge: 'Prolific PL2303GC', likelyRoles: ['realtek'], confirmWith: ['ruview monitor --port COM10 --baud 1500000'] }, { port: 'COM3', builtin: true }], note: 'hints' }, { color: false });
assert.match(dev, /COM10 +Prolific PL2303GC +realtek +ruview monitor --port COM10 --baud 1500000/);
assert.doesNotMatch(dev, /COM3/, 'built-in ports hidden');
const mon = renderResult('ruview_node_monitor', { ok: false, reason: 'csi_buffer_starvation', hint: 'reset', csi_callbacks: 0, lines: 629, baud: 1500000, reset_on_open: false }, { color: false });
assert.match(mon, /✖ csi buffer starvation/);
assert.match(mon, /reset on open: no/);
const flash = renderResult('ruview_firmware_plan', { ok: true, dryRun: true, plan: { chip: 'ESP32-C6', flashSize: '4MB', port: 'COM16', checksums: 'SHA256SUMS.txt', images: [{ offset: '0x0', role: 'bootloader', bytes: 19568, integrity: 'verified' }] } }, { color: false });
assert.match(flash, /RUVIEW \/ FLASH PLAN/);
assert.match(flash, /0x0 +bootloader +19568 +verified/);
const gen = renderResult('ruview_claim_check', { ok: true, verdict: 'PASS', findings: [] }, { color: false });
assert.match(gen, /RUVIEW \/ CLAIM CHECK/);
assert.match(gen, /verdict +PASS/);
assert.match(gen, /use --json/);
});
test('bar, sparkline and table primitives', () => {
assert.equal(bar(1, 4), '████');
assert.equal(bar(0, 4), ' ');
assert.equal(bar(0.5, 4).length, 4);
assert.equal(sparkline([1, 2, 3, 4, 5, 6, 7, 8]), '▁▂▃▄▅▆▇█');
assert.equal(sparkline([5, 5]), '▄▄');
assert.equal(sparkline([]), '');
assert.equal(table([['A', 'B'], ['xx', 'y']], createStyle(false)), 'A B\nxx y');
});
test('monitor script flags Ameba CSI buffer starvation', (t) => {
const py = ['python3', 'python'].find((p) => spawnSync(p, ['--version'], { stdio: 'ignore' }).status === 0);
if (!py) { t.skip('python not available'); return; }
const fake = mkdtempSync(join(tmpdir(), 'ruview-fakeserial-'));
try {
writeFileSync(join(fake, 'serial.py'), [
'class Serial:',
' def __init__(self, *a, **k): self.lines = [b"[WLAN-W] lack of csi buf!\\n", b"[WLAN-W] csi buf not enough\\n", b"RUVIEW_CSI: heartbeat tick=9\\n"]',
' def open(self): pass',
' def readline(self): return self.lines.pop(0) if self.lines else b""',
' def close(self): pass',
].join('\n'));
const r = spawnSync(py, ['-c', MONITOR_SCRIPT, 'COM1', '0.4', '1500000'], { env: { PATH: process.env.PATH, SYSTEMROOT: process.env.SYSTEMROOT || '', PYTHONPATH: fake }, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /LINES=3 CSI=0 STARVED=2/);
} finally {
rmSync(fake, { recursive: true, force: true });
}
});
+121
View File
@@ -0,0 +1,121 @@
// ADR-375: MCP Apps / ChatGPT widget, structured results, protocol
// negotiation, and the authenticated Streamable HTTP transport.
import test from 'node:test';
import assert from 'node:assert/strict';
import { handleRpc, negotiateProtocol, SUPPORTED_PROTOCOLS, toCallToolResult } from '../src/mcp-server.js';
import { generateToken, httpGrants, startMcpHttp } from '../src/mcp-http.js';
import { CONSOLE_HTML, CONSOLE_URI, UI_MIME, UI_TOOLS } from '../src/ui/console-widget.js';
const rpc = (method, params, id = 1) => handleRpc({ jsonrpc: '2.0', id, method, params });
test('initialize negotiates the protocol and advertises resources', async () => {
for (const v of SUPPORTED_PROTOCOLS) assert.equal((await rpc('initialize', { protocolVersion: v })).result.protocolVersion, v);
assert.equal(negotiateProtocol('1999-01-01'), SUPPORTED_PROTOCOLS[0]);
const init = (await rpc('initialize', {})).result;
assert.equal(init.protocolVersion, '2025-06-18');
assert.deepEqual(Object.keys(init.capabilities).sort(), ['resources', 'tools']);
assert.equal(init.serverInfo.title, 'RuView');
});
test('UI tools point at the console widget with MCP Apps and ChatGPT keys', async () => {
const tools = (await rpc('tools/list')).result.tools;
for (const name of UI_TOOLS) {
const t = tools.find((x) => x.name === name);
assert.ok(t, name);
assert.equal(t._meta.ui.resourceUri, CONSOLE_URI);
assert.equal(t._meta['openai/outputTemplate'], CONSOLE_URI);
assert.equal(t._meta['openai/widgetAccessible'], true);
assert.ok(t.title, `${name} has a title`);
}
const plain = tools.find((x) => x.name === 'ruview_claim_check');
assert.equal(plain._meta, undefined, 'non-UI tools carry no widget metadata');
assert.equal(tools.filter((t) => t._meta).length, UI_TOOLS.length);
});
test('resources list and read the self-contained widget; unknown URIs fail', async () => {
const [res] = (await rpc('resources/list')).result.resources;
assert.deepEqual([res.uri, res.mimeType], [CONSOLE_URI, UI_MIME]);
assert.deepEqual(res._meta.ui.csp, { connectDomains: [], resourceDomains: [] });
const read = (await rpc('resources/read', { uri: CONSOLE_URI })).result.contents[0];
assert.equal(read.text, CONSOLE_HTML);
assert.equal(read.mimeType, UI_MIME);
const missing = await rpc('resources/read', { uri: 'ui://ruview/nope.html' });
assert.equal(missing.error.code, -32002);
assert.deepEqual((await rpc('resources/templates/list')).result, { resourceTemplates: [] });
});
test('widget is self-contained and never writes HTML from data', () => {
assert.ok(Buffer.byteLength(CONSOLE_HTML) < 16 * 1024, 'widget stays under 16 KiB');
assert.doesNotMatch(CONSOLE_HTML, /https?:\/\//, 'no external origins');
assert.doesNotMatch(CONSOLE_HTML, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|eval\(|new Function/);
assert.doesNotMatch(CONSOLE_HTML, /<(link|iframe|img|object|embed)\b/i);
assert.match(CONSOLE_HTML, /e\.source!==window\.parent/, 'only the host frame may post messages');
assert.match(CONSOLE_HTML, /ui\/initialize/);
assert.match(CONSOLE_HTML, /openai:set_globals/);
});
test('tools/call returns structuredContent, compact text, and the canonical tool name', async () => {
const r = (await rpc('tools/call', { name: 'ruview.claim_check', arguments: { text: 'PCK 0.9 MEASURED with baseline' } })).result;
assert.equal(typeof r.structuredContent, 'object');
assert.equal(r.content[0].text, JSON.stringify(r.structuredContent), 'text is the compact serialization');
assert.equal(r._meta['ruview/tool'], 'ruview_claim_check');
assert.equal(r.isError, r.structuredContent.ok === false);
assert.deepEqual(toCallToolResult('x', [1, 2]).structuredContent, { value: [1, 2] }, 'structuredContent is always an object');
});
test('invalid messages and unknown methods get JSON-RPC errors; notifications get none', async () => {
assert.equal((await handleRpc({ id: 1, method: 'ping' })).error.code, -32600, 'jsonrpc field required');
assert.equal((await handleRpc([])).error.code, -32600);
assert.equal((await rpc('nope/method')).error.code, -32601);
assert.equal(await handleRpc({ jsonrpc: '2.0', method: 'notifications/initialized' }), null);
assert.equal(await handleRpc({ jsonrpc: '2.0', method: 'notifications/whatever' }), null);
});
test('HTTP grants never include writes', () => {
assert.deepEqual(httpGrants(['device-access', 'hardware-write', 'workspace-write', 'credential-use']), ['device-access', 'credential-use']);
assert.ok(generateToken().length >= 32);
});
test('HTTP transport: auth, origin, method, size, protocol header, notifications, write denial', async (t) => {
const token = generateToken();
const logs = [];
const srv = await startMcpHttp({ port: 0, token, grants: ['device-access', 'hardware-write'], allowOrigins: ['https://ok.example'], log: (m) => logs.push(m) });
t.after(() => srv.close());
const post = (path, body, headers = {}) => fetch(`${srv.url.replace(/\/mcp$/, '')}${path}`, {
method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body),
});
const init = { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18' } };
assert.deepEqual(srv.grants, ['device-access']);
assert.match(logs.join('\n'), /never honoured over HTTP; ignoring: hardware-write/);
assert.equal((await post('/mcp', init)).status, 401);
assert.equal((await post('/mcp', init, { authorization: 'Bearer wrong-token-xxxxxxxxxxxx' })).status, 401);
assert.equal((await post('/mcp/wrong-token-xxxxxxxxxxxx', init)).status, 401);
const bearer = await post('/mcp', init, { authorization: `Bearer ${token}` });
assert.equal(bearer.status, 200);
assert.equal((await bearer.json()).result.protocolVersion, '2025-06-18');
const secretPath = await post(`/mcp/${token}`, init);
assert.equal(secretPath.status, 200, 'secret-path form for clients without custom headers');
assert.equal((await post(`/mcp/${token}`, init, { origin: 'https://evil.example' })).status, 403);
assert.equal((await post(`/mcp/${token}`, init, { origin: 'https://ok.example' })).status, 200);
assert.equal((await fetch(`${srv.url}/${token}`)).status, 405);
assert.equal((await post(`/mcp/${token}`, init, { 'mcp-protocol-version': '1999-01-01' })).status, 400);
assert.equal((await post(`/mcp/${token}`, { jsonrpc: '2.0', method: 'notifications/initialized' })).status, 202);
assert.equal((await post(`/mcp/${token}`, 'not json')).status, 400);
assert.equal((await post(`/mcp/${token}`, [init])).status, 400, 'batches refused');
assert.equal((await post(`/mcp/${token}`, 'x'.repeat(300 * 1024))).status, 413);
assert.equal((await post('/elsewhere', init)).status, 404);
assert.equal((await fetch(srv.url.replace(/\/mcp$/, '/healthz'))).status, 200);
// Flash is a hardware-write tool: denied over HTTP even though the operator granted it.
const flash = await post(`/mcp/${token}`, { jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'ruview_node_flash', arguments: { port: 'COM9', bundle: '.', variant: 'c6', confirm: true } } });
const sc = (await flash.json()).result.structuredContent;
assert.deepEqual([sc.ok, sc.reason, sc.requiredGrant], [false, 'authority_denied', 'hardware-write']);
const deny = await post(`/mcp/${token}`, { jsonrpc: '2.0', id: 10, method: 'tools/call', params: { name: 'ruview_calibrate', arguments: { step: 'baseline', confirm: true } } });
assert.equal((await deny.json()).result.structuredContent.reason, 'authority_denied');
});
test('HTTP transport refuses short tokens', async () => {
await assert.rejects(startMcpHttp({ port: 0, token: 'short' }), /at least 16/);
});
+2 -1
View File
@@ -64,7 +64,8 @@ test('MCP handshake: initialize reports the package.json version; list endpoints
assert.equal(spaces.annotations.openWorldHint, true);
s.send({ jsonrpc: '2.0', id: 3, method: 'resources/list' });
assert.deepEqual((await s.next(3)).result, { resources: [] });
const resources = (await s.next(3)).result.resources;
assert.deepEqual(resources.map((r) => [r.uri, r.mimeType]), [['ui://ruview/console-v1.html', 'text/html;profile=mcp-app']]);
s.send({ jsonrpc: '2.0', id: 4, method: 'prompts/list' });
assert.deepEqual((await s.next(4)).result, { prompts: [] });