From e7b8cc373c6477af6d430b011ce42a34d0093783 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 15 Nov 2025 18:18:14 +0100 Subject: [PATCH] Add JSON validation to MCP tool responses --- .../server/src/server/agent/mcp-server.ts | 46 +++++++------- packages/server/src/server/json-utils.ts | 60 +++++++++++++++++++ .../server/src/server/terminal-mcp/server.ts | 11 ++-- plan.md | 14 ++++- 4 files changed, 103 insertions(+), 28 deletions(-) create mode 100644 packages/server/src/server/json-utils.ts diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 475b001dd..25c99603b 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { homedir } from "node:os"; import { resolve } from "node:path"; +import { ensureValidJson } from "../json-utils.js"; import type { AgentPromptInput, AgentProvider, @@ -294,14 +295,14 @@ export async function createAgentMcpServer( return { content: [], - structuredContent: { + structuredContent: ensureValidJson({ agentId: snapshot.id, type: provider, status: snapshot.status, cwd: snapshot.cwd, currentModeId: snapshot.currentModeId, availableModes: snapshot.availableModes, - }, + }), }; } ); @@ -333,12 +334,12 @@ export async function createAgentMcpServer( return { content: [], - structuredContent: { + structuredContent: ensureValidJson({ agentId, status: result.status, permission: result.permission, activity, - }, + }), }; } ); @@ -372,10 +373,10 @@ export async function createAgentMcpServer( return { content: [], - structuredContent: { + structuredContent: ensureValidJson({ success: true, status: snapshot?.status ?? "idle", - }, + }), }; } ); @@ -400,15 +401,16 @@ export async function createAgentMcpServer( throw new Error(`Agent ${agentId} not found`); } + const structuredSnapshot = await serializeSnapshotWithMetadata( + agentRegistry, + snapshot + ); return { content: [], - structuredContent: { + structuredContent: ensureValidJson({ status: snapshot.status, - snapshot: await serializeSnapshotWithMetadata( - agentRegistry, - snapshot - ), - }, + snapshot: structuredSnapshot, + }), }; } ); @@ -432,7 +434,7 @@ export async function createAgentMcpServer( ); return { content: [], - structuredContent: { agents }, + structuredContent: ensureValidJson({ agents }), }; } ); @@ -454,7 +456,7 @@ export async function createAgentMcpServer( const success = await agentManager.cancelAgentRun(agentId); return { content: [], - structuredContent: { success }, + structuredContent: ensureValidJson({ success }), }; } ); @@ -475,7 +477,7 @@ export async function createAgentMcpServer( await agentManager.closeAgent(agentId); return { content: [], - structuredContent: { success: true }, + structuredContent: ensureValidJson({ success: true }), }; } ); @@ -513,14 +515,14 @@ export async function createAgentMcpServer( if (format === "curated") { return { content: [], - structuredContent: { + structuredContent: ensureValidJson({ agentId, format: "curated" as const, updateCount: timeline.length, currentModeId: snapshot?.currentModeId ?? null, content: curateAgentActivity(timeline), updates: null, - }, + }), }; } @@ -529,14 +531,14 @@ export async function createAgentMcpServer( : timeline; return { content: [], - structuredContent: { + structuredContent: ensureValidJson({ agentId, format: "raw" as const, updateCount: timeline.length, currentModeId: snapshot?.currentModeId ?? null, content: "", updates: entries, - }, + }), }; } ); @@ -560,7 +562,7 @@ export async function createAgentMcpServer( await agentManager.setAgentMode(agentId, modeId); return { content: [], - structuredContent: { success: true, newMode: modeId }, + structuredContent: ensureValidJson({ success: true, newMode: modeId }), }; } ); @@ -593,7 +595,7 @@ export async function createAgentMcpServer( return { content: [], - structuredContent: { permissions }, + structuredContent: ensureValidJson({ permissions }), }; } ); @@ -617,7 +619,7 @@ export async function createAgentMcpServer( await agentManager.respondToPermission(agentId, requestId, response); return { content: [], - structuredContent: { success: true }, + structuredContent: ensureValidJson({ success: true }), }; } ); diff --git a/packages/server/src/server/json-utils.ts b/packages/server/src/server/json-utils.ts new file mode 100644 index 000000000..3e82e3e3e --- /dev/null +++ b/packages/server/src/server/json-utils.ts @@ -0,0 +1,60 @@ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Ensure the provided value only contains JSON-safe primitives. + * Throws when undefined, functions, symbols, BigInts, etc. are found. + */ +export function ensureValidJson(value: T, rootLabel: string = "root"): T { + const seen = new Set(); + + const validate = (current: unknown, path: string): void => { + if (current === undefined) { + throw new Error(`Invalid JSON value at ${path}: undefined`); + } + + if (current === null) { + return; + } + + const type = typeof current; + + if (type === "string" || type === "number" || type === "boolean") { + return; + } + + if (Array.isArray(current)) { + current.forEach((item, index) => + validate(item, `${path}[${index}]`) + ); + return; + } + + if (type === "object") { + const asObject = current as Record; + if (!seen.has(asObject)) { + seen.add(asObject); + for (const [key, val] of Object.entries(asObject)) { + const nextPath = path === rootLabel ? key : `${path}.${key}`; + validate(val, nextPath); + } + seen.delete(asObject); + } + return; + } + + throw new Error( + `Invalid JSON value at ${path}: ${ + type === "symbol" ? "symbol" : String(current) + }` + ); + }; + + validate(value, rootLabel); + return value; +} diff --git a/packages/server/src/server/terminal-mcp/server.ts b/packages/server/src/server/terminal-mcp/server.ts index 5216a8fd7..b550d3e8a 100644 --- a/packages/server/src/server/terminal-mcp/server.ts +++ b/packages/server/src/server/terminal-mcp/server.ts @@ -1,5 +1,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import { ensureValidJson } from "../json-utils.js"; import { TerminalManager } from "./terminal-manager.js"; export interface TerminalMcpServerOptions { @@ -98,7 +99,7 @@ export async function createTerminalMcpServer( const output = { commands }; return { content: [], - structuredContent: output, + structuredContent: ensureValidJson(output), }; } ); @@ -131,7 +132,7 @@ export async function createTerminalMcpServer( const result = await terminalManager.captureCommand(commandId, lines); return { content: [], - structuredContent: result, + structuredContent: ensureValidJson(result), }; } ); @@ -190,7 +191,7 @@ export async function createTerminalMcpServer( const result = { output: output || null }; return { content: [], - structuredContent: result, + structuredContent: ensureValidJson(result), }; } ); @@ -238,7 +239,7 @@ export async function createTerminalMcpServer( const result = { output: output || null }; return { content: [], - structuredContent: result, + structuredContent: ensureValidJson(result), }; } ); @@ -272,7 +273,7 @@ export async function createTerminalMcpServer( const output = { success: true, killedCount }; return { content: [], - structuredContent: output, + structuredContent: ensureValidJson(output), }; } ); diff --git a/plan.md b/plan.md index 35708cf88..2fc4043a1 100644 --- a/plan.md +++ b/plan.md @@ -49,4 +49,16 @@ - AgentInput now inspects the agent’s status and swaps the right-hand controls accordingly: send replaces dictate once text/images exist, running agents expose a new Cancel pill, and the realtime toggle is always available (it now starts/stops realtime rather than disappearing). Cancel dispatches a new `cancel_agent_request` websocket message that the server routes through `interruptAgentIfRunning`, so make sure any future API clients send that message when wiring similar controls. (FYI `npm run typecheck --workspace=@voice-dev/app` still fails in the existing stream harness suites for unrelated reasons.) - [x] Implement long press on the agent list item to opena a light bottom sheet with some actions, for now: "Delete agent" all this will do is remove it from our own persistence, but the agent will still exist in the claude/codex persistance and thats out of scope to remove. Its esentially just to remove it from the list, disown it. - Added a `delete_agent_request` flow: the session now closes the agent, removes its registry entry, and emits a dedicated `agent_deleted` event that the client listens for to prune the agents map/stream state. Long-pressing any agent row opens a lightweight bottom sheet with a Delete action wired to that new context method, so deleting disowns the agent locally without touching the Claude/Codex session. -- [ ] Implement a function `ensureValidJson` that walks an object and validates that all values are valid JSON (no undefined, null, or non-string values). Use this for all return valies in the MCP server tools. +- [x] Implement a function `ensureValidJson` that walks an object and validates that all values are valid JSON (no undefined, null, or non-string values). Use this for all return valies in the MCP server tools. + - Added `ensureValidJson` (`packages/server/src/server/json-utils.ts`) and wrapped every MCP server tool payload in both `terminal-mcp/server.ts` and `agent/mcp-server.ts`, so tool responses now get validated before streaming; `tsc` still fails earlier in the tree due to `packages/app/src/types/stream.ts` references (see npm run typecheck --workspace=@voice-dev/server). + +## Voice Interruptions & Streaming + +- [ ] Wire interrupt signal on speech start so VAD immediately issues `abort_request` to the server before any audio segment is sent. +- [ ] Abort server-side playback/LLM as soon as a realtime audio chunk arrives; set a speech-in-progress flag that pauses new TTS until the turn completes. +- [ ] Interrupt the currently focused agent whenever a realtime user turn begins, ensuring the next prompt starts fresh. +- [ ] Harden playback stop/queue clearing so speech detection purges suppressed audio and the server stops emitting TTS while the user is talking. +- [ ] Prototype chunked audio input: emit smaller PCM frames with `isLast=false`, update `handleAudioChunk` to buffer and trigger STT once the minimum duration is reached. +- [ ] Investigate streaming STT/server-side VAD (Whisper or equivalent) and document requirements for production use. +- [ ] Prototype streaming TTS output (chunked `audio_output` messages) and update the player to start playback as soon as chunks arrive. +- [ ] Add telemetry for barge-in latency (speech start → playback stop, chunk arrival → LLM abort) to measure improvements per milestone.