feat(ruview-mcp): M3+M4 — schema validation + train_count wired (#708)
- 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
This commit is contained in:
parent
6b35896847
commit
2a2f16a380
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const w = (d["window"] as Record<string, unknown>);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue