mirror of
https://github.com/ruvnet/RuView
synced 2026-07-31 18:51:42 +00:00
2b7853b18f
* feat(ruview): add secure community metaharness flywheel * fix(ruview): canonicalize manifest line endings
149 lines
6.1 KiB
JavaScript
149 lines
6.1 KiB
JavaScript
// SPDX-License-Identifier: MIT
|
|
// RuView harness — minimal MCP stdio server (JSON-RPC 2.0 over stdin/stdout).
|
|
//
|
|
// 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.
|
|
//
|
|
// 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).
|
|
|
|
import { createInterface } from 'node:readline';
|
|
import { readFileSync } from 'node:fs';
|
|
import { listTools, runTool } from './tools.js';
|
|
|
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
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 };
|
|
|
|
function send(msg) {
|
|
process.stdout.write(JSON.stringify(msg) + '\n');
|
|
}
|
|
function result(id, res) { send({ jsonrpc: '2.0', id, result: res }); }
|
|
function error(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
|
|
function log(...a) { process.stderr.write('[ruview-mcp] ' + a.join(' ') + '\n'); }
|
|
|
|
async function handle(msg, context = {}) {
|
|
const { id, method, params } = msg;
|
|
switch (method) {
|
|
case 'initialize':
|
|
return result(id, {
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
capabilities: { tools: { listChanged: false } },
|
|
serverInfo: SERVER_INFO,
|
|
instructions: 'RuView WiFi-sensing operator tools. All results are fail-closed; accuracy claims must pass ruview_claim_check.',
|
|
});
|
|
case 'notifications/initialized':
|
|
case 'initialized':
|
|
return; // notifications — no response
|
|
case 'notifications/cancelled':
|
|
if (context.queuedIds?.has(params?.requestId)) context.cancelled?.add(params.requestId);
|
|
return; // queued requests are cancelled before execution
|
|
case 'ping':
|
|
return result(id, {});
|
|
case 'tools/list':
|
|
return result(id, { tools: listTools() });
|
|
case 'resources/list':
|
|
return result(id, { resources: [] });
|
|
case 'prompts/list':
|
|
return result(id, { prompts: [] });
|
|
case 'tools/call': {
|
|
const name = params?.name;
|
|
const args = params?.arguments || {};
|
|
log('audit', JSON.stringify({ event: 'tools/call', id, name }));
|
|
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,
|
|
});
|
|
}
|
|
default:
|
|
if (id !== undefined) error(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;
|
|
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));
|
|
log('handler error:', String(err));
|
|
});
|
|
|
|
rl.on('line', (line) => {
|
|
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
|
|
}
|
|
});
|
|
|
|
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(() => {
|
|
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();
|
|
});
|
|
});
|
|
}
|