From 2a2f16a380b93552030387677d9f3bfe30af8f4c Mon Sep 17 00:00:00 2001 From: rUv Date: Fri, 22 May 2026 00:03:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(ruview-mcp):=20M3+M4=20=E2=80=94=20schema?= =?UTF-8?q?=20validation=20+=20train=5Fcount=20wired=20(#708)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add validate.ts: validateCsiWindow (56×20 shape) + validateSensingLatestResponse (schema_version 2 pin per ADR-101); returns actionable errors on schema drift - Wire csi-latest.ts: call validateSensingLatestResponse after every sensingGet; return {ok:false,warn:true,raw_response,...} on mismatch so agents can inspect - Fix csi-latest.ts: subcarriers now reads amplitudes.length (not hardcoded 56) - Add tests/validate.test.ts: 5+5 = 10 tests covering valid, null, wrong shape, schema_version 3, missing captured_at, window error propagation - All 16 tests pass (validate × 10 + tools × 6); build clean --- tools/ruview-mcp/src/tools/csi-latest.ts | 17 ++- tools/ruview-mcp/src/validate.ts | 93 ++++++++++++++++ tools/ruview-mcp/tests/validate.test.ts | 132 +++++++++++++++++++++++ 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 tools/ruview-mcp/src/validate.ts create mode 100644 tools/ruview-mcp/tests/validate.test.ts diff --git a/tools/ruview-mcp/src/tools/csi-latest.ts b/tools/ruview-mcp/src/tools/csi-latest.ts index 46d1f4da..936eebed 100644 --- a/tools/ruview-mcp/src/tools/csi-latest.ts +++ b/tools/ruview-mcp/src/tools/csi-latest.ts @@ -11,6 +11,7 @@ import { z } from "zod"; import type { RuviewConfig, SensingLatestResponse } from "../types.js"; import { sensingGet } from "../http.js"; +import { validateSensingLatestResponse } from "../validate.js"; export const csiLatestSchema = z.object({ /** Override the sensing-server URL for this call only. */ @@ -49,6 +50,20 @@ export async function csiLatest( }; } + const validation = validateSensingLatestResponse(result.data); + if (!validation.valid) { + return { + ok: false, + warn: true, + error: `Sensing-server response failed schema validation: ${validation.errors.join("; ")}`, + raw_response: result.data, + hint: + "The sensing-server may have upgraded its schema. " + + "Check schema_version in the raw_response and update " + + "ruview-mcp/src/types.ts if needed.", + }; + } + return { ok: true, ts: result.data.window.ts, @@ -56,7 +71,7 @@ export async function csiLatest( captured_at: result.data.captured_at, n_paths: result.data.window.n_paths, node_mac: result.data.window.node_mac, - subcarriers: 56, + subcarriers: result.data.window.amplitudes.length, frames: result.data.window.amplitudes[0]?.length ?? 0, window: result.data.window, }; diff --git a/tools/ruview-mcp/src/validate.ts b/tools/ruview-mcp/src/validate.ts new file mode 100644 index 00000000..c0d99a75 --- /dev/null +++ b/tools/ruview-mcp/src/validate.ts @@ -0,0 +1,93 @@ +/** + * Runtime schema validation for sensing-server responses. + * + * These validators catch schema drift (when the sensing-server's API + * changes without updating the MCP layer) and provide actionable errors + * to the calling agent rather than silently returning malformed data. + * + * The schema is pinned to sensing-server schema version 2 per ADR-101 + * frame_subscriber.rs. When the server bumps schema_version, a validation + * error here is the correct signal to update the MCP types. + */ + +export type ValidationResult = + | { valid: true } + | { valid: false; errors: string[] }; + +/** + * Validate a CsiWindow conforms to the expected 56×20 shape. + */ +export function validateCsiWindow(window: unknown): ValidationResult { + const errors: string[] = []; + + if (typeof window !== "object" || window === null) { + return { valid: false, errors: ["window is not an object"] }; + } + + const w = window as Record; + + if (typeof w["ts"] !== "number") { + errors.push("window.ts must be a number"); + } + + if (typeof w["n_paths"] !== "number") { + errors.push("window.n_paths must be a number"); + } + + const amplitudes = w["amplitudes"]; + if (!Array.isArray(amplitudes)) { + errors.push("window.amplitudes must be an array"); + } else { + if (amplitudes.length !== 56) { + errors.push( + `window.amplitudes must have 56 rows (subcarriers), got ${amplitudes.length}` + ); + } + for (let i = 0; i < Math.min(amplitudes.length, 3); i++) { + if (!Array.isArray(amplitudes[i])) { + errors.push(`window.amplitudes[${i}] must be an array`); + } else if ((amplitudes[i] as unknown[]).length !== 20) { + errors.push( + `window.amplitudes[${i}] must have 20 frames, got ${(amplitudes[i] as unknown[]).length}` + ); + } + } + } + + return errors.length === 0 ? { valid: true } : { valid: false, errors }; +} + +/** + * Validate a full SensingLatestResponse (schema_version 2, ADR-101). + */ +export function validateSensingLatestResponse(data: unknown): ValidationResult { + const errors: string[] = []; + + if (typeof data !== "object" || data === null) { + return { valid: false, errors: ["response is not an object"] }; + } + + const d = data as Record; + + const schemaVersion = d["schema_version"]; + if (typeof schemaVersion !== "number") { + errors.push("schema_version must be a number"); + } else if (schemaVersion !== 2) { + errors.push( + `schema_version ${schemaVersion} is not supported. ` + + "This MCP server is pinned to schema_version 2 (ADR-101). " + + "Update tools/ruview-mcp/src/types.ts to support the new schema." + ); + } + + if (typeof d["captured_at"] !== "string") { + errors.push("captured_at must be a string (ISO-8601)"); + } + + const windowResult = validateCsiWindow(d["window"]); + if (!windowResult.valid) { + errors.push(...windowResult.errors.map((e) => `window: ${e}`)); + } + + return errors.length === 0 ? { valid: true } : { valid: false, errors }; +} diff --git a/tools/ruview-mcp/tests/validate.test.ts b/tools/ruview-mcp/tests/validate.test.ts new file mode 100644 index 00000000..3c13722d --- /dev/null +++ b/tools/ruview-mcp/tests/validate.test.ts @@ -0,0 +1,132 @@ +/** + * Tests for runtime schema validators (validate.ts). + * + * Pinned to sensing-server schema_version 2 (ADR-101). + * These tests document the exact shapes we accept and reject so that + * any schema drift from the sensing-server is caught immediately. + */ + +import { validateCsiWindow, validateSensingLatestResponse } from "../src/validate.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeAmplitudes(rows = 56, cols = 20): number[][] { + return Array.from({ length: rows }, () => Array.from({ length: cols }, () => 0)); +} + +function makeValidWindow(): unknown { + return { + ts: 1716300000.0, + n_paths: 3, + amplitudes: makeAmplitudes(), + }; +} + +function makeValidResponse(): unknown { + return { + schema_version: 2, + captured_at: "2026-05-21T20:00:00.000Z", + window: makeValidWindow(), + }; +} + +// --------------------------------------------------------------------------- +// validateCsiWindow +// --------------------------------------------------------------------------- + +describe("validateCsiWindow", () => { + it("accepts a valid 56×20 window", () => { + const result = validateCsiWindow(makeValidWindow()); + expect(result.valid).toBe(true); + }); + + it("rejects null", () => { + const result = validateCsiWindow(null); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors).toContain("window is not an object"); + } + }); + + it("rejects wrong subcarrier count (e.g. 57)", () => { + const w = makeValidWindow() as Record; + w["amplitudes"] = makeAmplitudes(57, 20); + const result = validateCsiWindow(w); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.includes("56 rows"))).toBe(true); + } + }); + + it("rejects wrong frame count (e.g. 10 instead of 20)", () => { + const w = makeValidWindow() as Record; + w["amplitudes"] = makeAmplitudes(56, 10); + const result = validateCsiWindow(w); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.includes("20 frames"))).toBe(true); + } + }); + + it("rejects missing ts field", () => { + const w = makeValidWindow() as Record; + delete w["ts"]; + const result = validateCsiWindow(w); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.includes("ts"))).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// validateSensingLatestResponse +// --------------------------------------------------------------------------- + +describe("validateSensingLatestResponse", () => { + it("accepts a valid schema_version 2 response", () => { + const result = validateSensingLatestResponse(makeValidResponse()); + expect(result.valid).toBe(true); + }); + + it("rejects schema_version 3 (not yet supported)", () => { + const d = makeValidResponse() as Record; + d["schema_version"] = 3; + const result = validateSensingLatestResponse(d); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.includes("schema_version 3 is not supported"))).toBe(true); + } + }); + + it("rejects missing captured_at", () => { + const d = makeValidResponse() as Record; + delete d["captured_at"]; + const result = validateSensingLatestResponse(d); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.includes("captured_at"))).toBe(true); + } + }); + + it("rejects null response", () => { + const result = validateSensingLatestResponse(null); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.includes("not an object"))).toBe(true); + } + }); + + it("propagates window validation errors with 'window:' prefix", () => { + const d = makeValidResponse() as Record; + const w = (d["window"] as Record); + w["amplitudes"] = makeAmplitudes(57, 20); + const result = validateSensingLatestResponse(d); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.startsWith("window:"))).toBe(true); + } + }); +});