From a8b03026e6e0bf3e9821e8204f66fb780e217095 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 16 Aug 2026 09:54:13 +0200 Subject: [PATCH 1/5] fix(tool): preserve remote MCP error diagnostics --- .../legacy-run-read-adapter.test.ts | 12 +- src/tool/remote-mcp.test.ts | 84 +++++++++++++- src/tool/remote-mcp.ts | 103 +++++++++++++++++- 3 files changed, 189 insertions(+), 10 deletions(-) diff --git a/src/agent/conversation/legacy-run-read-adapter.test.ts b/src/agent/conversation/legacy-run-read-adapter.test.ts index d5807aee2f..30cc56cd86 100644 --- a/src/agent/conversation/legacy-run-read-adapter.test.ts +++ b/src/agent/conversation/legacy-run-read-adapter.test.ts @@ -455,7 +455,13 @@ describe("conversation run lifecycle read adapter", () => { } }); - it("round trips provider-executed error results through durable v2 as AG-UI tool errors", () => { + it("round trips provider-executed structured errors through durable v2", () => { + const diagnostic = { + error: "invalid_skill", + code: "invalid_skill", + message: "Skill validation failed", + request_id: "request-123", + }; const durableEvents = writeDurableEvents(frames([ { event: { @@ -479,7 +485,7 @@ describe("conversation run lifecycle read adapter", () => { type: "provider_tool_result", toolCallId: "provider-err", toolName: "web_search", - output: "provider failed", + output: diagnostic, isError: true, providerExecuted: true, }, @@ -495,7 +501,7 @@ describe("conversation run lifecycle read adapter", () => { event: "ToolCallResult", payload: { toolCallId: "provider-err", - result: "provider failed", + result: diagnostic, isError: true, }, }], diff --git a/src/tool/remote-mcp.test.ts b/src/tool/remote-mcp.test.ts index b335018b0f..56a5862684 100644 --- a/src/tool/remote-mcp.test.ts +++ b/src/tool/remote-mcp.test.ts @@ -16,6 +16,7 @@ import { MAX_REMOTE_MCP_TOOL_LIST_PAGES, MAX_REMOTE_MCP_TOOL_LIST_RESPONSE_BYTES, } from "./remote-mcp.ts"; +import { getToolResultError } from "./result.ts"; describe("tool/remote-mcp", () => { it("uses host transport only for an exact trusted endpoint", async () => { @@ -522,6 +523,37 @@ describe("tool/remote-mcp", () => { }); }); + it("falls back to MCP text diagnostics when structuredContent is empty", async () => { + const source = createRemoteMCPToolSource({ + id: "veryfront-mcp", + endpoint: "https://93.184.216.34/mcp", + }); + + const result = await withMockFetch(async () => + Response.json({ + jsonrpc: "2.0", + id: "veryfront-mcp:tools:call:create_agent", + result: { + isError: true, + structuredContent: {}, + content: [{ + text: "[already_exists] Agent already exists", + }], + }, + }), async () => + await source.executeTool("create_agent", { id: "writer" }, { + toolCallId: "tool-call-123", + })); + + assertEquals(result, { + error: "already_exists", + code: "already_exists", + message: "[already_exists] Agent already exists", + correlation_id: "tool-call-123", + }); + assertEquals(getToolResultError(result), "[already_exists] Agent already exists"); + }); + it("wraps non-object structured MCP errors with a canonical marker", async () => { const source = createRemoteMCPToolSource({ id: "docs", @@ -565,7 +597,8 @@ describe("tool/remote-mcp", () => { }), async () => await source.executeTool("search_docs", { query: "auth" })); assertEquals(result, { - error: "tool_error", + error: "rate_limited", + code: "rate_limited", message: "Try again later", }); }); @@ -633,6 +666,37 @@ describe("tool/remote-mcp", () => { }); }); + it("returns structured JSON-RPC tool errors with correlation context", async () => { + const source = createRemoteMCPToolSource({ + id: "veryfront-mcp", + endpoint: "https://93.184.216.34/mcp", + }); + + const result = await withMockFetch(async () => + Response.json({ + jsonrpc: "2.0", + id: "veryfront-mcp:tools:call:update_skill", + error: { + code: -32602, + message: "Skill validation failed", + data: { + code: "invalid_skill", + request_id: "request-123", + field: "instructions", + }, + }, + }), async () => await source.executeTool("update_skill", { skill_id: "writer" })); + + assertEquals(result, { + error: "invalid_skill", + code: "invalid_skill", + message: "Skill validation failed", + request_id: "request-123", + json_rpc_code: -32602, + }); + assertEquals(getToolResultError(result), "Skill validation failed"); + }); + it("normalizes HTTP invalid_grant failures into reconnect-required tool output", async () => { const source = createRemoteMCPToolSource({ id: "veryfront-mcp", @@ -676,6 +740,24 @@ describe("tool/remote-mcp", () => { assertEquals(error.message, "Remote MCP request failed (500)"); }); + it("keeps unexpected tool transport failures on the exception path", async () => { + const source = createRemoteMCPToolSource({ + id: "veryfront-mcp", + endpoint: "https://93.184.216.34/mcp", + }); + + const error = await assertRejects( + () => + withMockFetch( + async () => new Response("private payload ", { status: 503 }), + async () => await source.executeTool("create_agent", { id: "writer" }), + ), + Error, + ); + + assertEquals(error.message, "Remote MCP request failed (503)"); + }); + it("preserves caller accept types while adding the MCP-required media types", async () => { let acceptHeader = ""; diff --git a/src/tool/remote-mcp.ts b/src/tool/remote-mcp.ts index 8cfab202a2..2e13b02479 100644 --- a/src/tool/remote-mcp.ts +++ b/src/tool/remote-mcp.ts @@ -30,6 +30,7 @@ const MAX_REMOTE_MCP_TOOL_SCHEMA_BYTES = 16_384; const MAX_REMOTE_MCP_TOOL_SCHEMA_DEPTH = 64; const MAX_REMOTE_MCP_TOOL_SCHEMA_NODES = 4_096; const MAX_REMOTE_MCP_CURSOR_LENGTH = 4_096; +const MAX_REMOTE_MCP_CORRELATION_ID_LENGTH = 256; const UTF8_ENCODER = new TextEncoder(); class RemoteMCPHttpError extends Error { @@ -58,10 +59,18 @@ export interface RemoteMCPToolSourceConfig { } interface JsonRpcErrorObject { + code?: unknown; message?: unknown; data?: unknown; } +const JSON_RPC_TOOL_ERROR_RESULT = Symbol("veryfront.remote-mcp.json-rpc-tool-error"); + +interface JsonRpcToolErrorResult { + [JSON_RPC_TOOL_ERROR_RESULT]: true; + result: Record; +} + interface JsonRpcCallToolContentItem { text?: unknown; } @@ -74,6 +83,12 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isJsonRpcToolErrorResult(value: unknown): value is JsonRpcToolErrorResult { + if (!isRecord(value)) return false; + const candidate = value as unknown as Partial; + return candidate[JSON_RPC_TOOL_ERROR_RESULT] === true && isRecord(candidate.result); +} + function isResolver( value: ResolvableValue, ): value is (context?: ToolExecutionContext) => T | Promise { @@ -275,6 +290,28 @@ function parseJsonText(text: string): unknown | undefined { return snapshot.success ? snapshot.value : undefined; } +const MCP_ERROR_CODE = /^[a-z][a-z0-9_-]{0,63}$/; +const MCP_ERROR_CODE_PREFIX = /^\[([a-z][a-z0-9_-]{0,63})\](?:\s|$)/; + +function parseCallToolErrorText( + text: string, + context?: ToolExecutionContext, + fallbackCode?: string, +): unknown { + const parsed = parseJsonText(text); + if (parsed !== undefined) return parsed; + + const code = fallbackCode && MCP_ERROR_CODE.test(fallbackCode) + ? fallbackCode + : MCP_ERROR_CODE_PREFIX.exec(text)?.[1] ?? "tool_error"; + return { + error: code, + code, + message: text, + ...(context?.toolCallId ? { correlation_id: context.toolCallId } : {}), + }; +} + function isOauthExpiredMessage(value: unknown): boolean { const snapshot = snapshotBoundedJsonValue(value); const safeValue = snapshot.success ? snapshot.value : undefined; @@ -416,6 +453,33 @@ function extractJsonRpcErrorMessage(payload: Record): string { return "Remote MCP server returned an error"; } +function extractJsonRpcToolError(payload: Record): Record { + const rawError = isRecord(payload.error) ? payload.error as JsonRpcErrorObject : {}; + const dataSnapshot = snapshotBoundedJsonValue(rawError.data); + const data = dataSnapshot.success ? dataSnapshot.value : undefined; + const dataRecord = isRecord(data) ? data : undefined; + const dataCode = typeof dataRecord?.code === "string" && MCP_ERROR_CODE.test(dataRecord.code) + ? dataRecord.code + : undefined; + const rawRequestId = typeof dataRecord?.request_id === "string" + ? dataRecord.request_id + : typeof dataRecord?.requestId === "string" + ? dataRecord.requestId + : undefined; + const requestId = rawRequestId && rawRequestId.length <= MAX_REMOTE_MCP_CORRELATION_ID_LENGTH + ? rawRequestId + : undefined; + const code = dataCode ?? "remote_mcp_json_rpc_error"; + + return { + error: code, + code, + message: extractJsonRpcErrorMessage(payload).slice(0, MAX_ERROR_BODY_LENGTH), + ...(requestId ? { request_id: requestId } : {}), + ...(typeof rawError.code === "number" ? { json_rpc_code: rawError.code } : {}), + }; +} + function parseSseEvents(text: string): SseEvent[] { const events: SseEvent[] = []; let currentEvent: SseEvent = { data: [] }; @@ -724,7 +788,11 @@ async function postJsonRpc( } } -function getJsonRpcResult(payload: unknown, expectedId: string): unknown { +function getJsonRpcResult( + payload: unknown, + expectedId: string, + preserveToolErrors = false, +): unknown { if (!isRecord(payload)) { throw NETWORK_ERROR.create({ detail: "Remote MCP response was not a JSON object" }); } @@ -744,6 +812,14 @@ function getJsonRpcResult(payload: unknown, expectedId: string): unknown { throw protocolError("Remote MCP response cannot include both result and error"); } if (hasError) { + if (preserveToolErrors) { + // Tool execution errors are recoverable model-visible results. Discovery + // and other protocol operations keep the existing NETWORK_ERROR path. + return { + [JSON_RPC_TOOL_ERROR_RESULT]: true, + result: extractJsonRpcToolError(payload), + } satisfies JsonRpcToolErrorResult; + } throw NETWORK_ERROR.create({ detail: extractJsonRpcErrorMessage(payload) }); } @@ -776,9 +852,20 @@ function normalizeCallToolResult(input: { ); if (isError) { - const errorBody = "structuredContent" in result - ? result.structuredContent - : parseJsonText(text) ?? { error: "tool_error", message: text }; + const contentErrorBody = parseCallToolErrorText( + text, + input.context, + typeof result.error === "string" && result.error.trim().length > 0 + ? result.error + : undefined, + ); + const structuredErrorBody = result.structuredContent; + const hasStructuredErrorDetails = Object.hasOwn(result, "structuredContent") && + structuredErrorBody !== undefined && + structuredErrorBody !== null && + !(typeof structuredErrorBody === "string" && structuredErrorBody.trim().length === 0) && + !(isRecord(structuredErrorBody) && Object.keys(structuredErrorBody).length === 0); + const errorBody = hasStructuredErrorDetails ? structuredErrorBody : contentErrorBody; return preserveToolExecutionErrorMarker( normalizeKnownToolError(errorBody, input.toolName, input.endpoint, input.context), ); @@ -956,7 +1043,12 @@ function createRemoteMCPToolSourceWithFetch( MAX_REMOTE_MCP_CALL_RESPONSE_BYTES, ); - const result = getJsonRpcResult(payload, requestId); + const result = getJsonRpcResult(payload, requestId, true); + if (isJsonRpcToolErrorResult(result)) { + return preserveToolExecutionErrorMarker( + normalizeKnownToolError(result.result, toolName, endpoint, context), + ); + } const resultSnapshot = snapshotBoundedJsonValue(result); if (!resultSnapshot.success) { throw protocolError("Remote MCP tools/call returned an unbounded JSON result"); @@ -972,7 +1064,6 @@ function createRemoteMCPToolSourceWithFetch( if (normalizedError) { return normalizedError; } - throw error; } }, From ef5a700028965c71cd10fac23a3ecd89a80eed62 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 16 Aug 2026 09:58:34 +0200 Subject: [PATCH 2/5] fix(tool): validate JSON-RPC error envelopes --- src/tool/remote-mcp.test.ts | 31 +++++++++++++++++++++++++++++++ src/tool/remote-mcp.ts | 13 +++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/tool/remote-mcp.test.ts b/src/tool/remote-mcp.test.ts index 56a5862684..cbd1c21d74 100644 --- a/src/tool/remote-mcp.test.ts +++ b/src/tool/remote-mcp.test.ts @@ -697,6 +697,37 @@ describe("tool/remote-mcp", () => { assertEquals(getToolResultError(result), "Skill validation failed"); }); + it("rejects malformed JSON-RPC tool error envelopes", async () => { + const source = createRemoteMCPToolSource({ + id: "veryfront-mcp", + endpoint: "https://93.184.216.34/mcp", + }); + + for ( + const error of [ + null, + "failed", + { message: "Missing error code" }, + { code: -32603, message: " " }, + ] + ) { + await assertRejects( + () => + withMockFetch( + async () => + Response.json({ + jsonrpc: "2.0", + id: "veryfront-mcp:tools:call:update_skill", + error, + }), + async () => await source.executeTool("update_skill", {}), + ), + Error, + "malformed JSON-RPC error object", + ); + } + }); + it("normalizes HTTP invalid_grant failures into reconnect-required tool output", async () => { const source = createRemoteMCPToolSource({ id: "veryfront-mcp", diff --git a/src/tool/remote-mcp.ts b/src/tool/remote-mcp.ts index 2e13b02479..abff2f9e7c 100644 --- a/src/tool/remote-mcp.ts +++ b/src/tool/remote-mcp.ts @@ -83,6 +83,16 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isJsonRpcErrorObject( + value: unknown, +): value is JsonRpcErrorObject & { code: number; message: string } { + return isRecord(value) && + typeof value.code === "number" && + Number.isInteger(value.code) && + typeof value.message === "string" && + value.message.trim().length > 0; +} + function isJsonRpcToolErrorResult(value: unknown): value is JsonRpcToolErrorResult { if (!isRecord(value)) return false; const candidate = value as unknown as Partial; @@ -812,6 +822,9 @@ function getJsonRpcResult( throw protocolError("Remote MCP response cannot include both result and error"); } if (hasError) { + if (!isJsonRpcErrorObject(payload.error)) { + throw protocolError("Remote MCP response included a malformed JSON-RPC error object"); + } if (preserveToolErrors) { // Tool execution errors are recoverable model-visible results. Discovery // and other protocol operations keep the existing NETWORK_ERROR path. From eec1d28d1b26a0d40459cdf60d5b6f2f1b051247 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 16 Aug 2026 10:04:14 +0200 Subject: [PATCH 3/5] fix(tool): bound MCP error correlation ids --- src/tool/remote-mcp.test.ts | 28 ++++++++++++++++++++++++++++ src/tool/remote-mcp.ts | 6 +++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/tool/remote-mcp.test.ts b/src/tool/remote-mcp.test.ts index cbd1c21d74..43cfcbcf6a 100644 --- a/src/tool/remote-mcp.test.ts +++ b/src/tool/remote-mcp.test.ts @@ -554,6 +554,34 @@ describe("tool/remote-mcp", () => { assertEquals(getToolResultError(result), "[already_exists] Agent already exists"); }); + it("bounds local tool-call correlation ids in text error results", async () => { + const source = createRemoteMCPToolSource({ + id: "veryfront-mcp", + endpoint: "https://93.184.216.34/mcp", + }); + const oversizedToolCallId = `tool-${"x".repeat(252)}`; + + const result = await withMockFetch(async () => + Response.json({ + jsonrpc: "2.0", + id: "veryfront-mcp:tools:call:create_agent", + result: { + isError: true, + content: [{ text: "Agent already exists" }], + }, + }), async () => + await source.executeTool("create_agent", { id: "writer" }, { + toolCallId: oversizedToolCallId, + })); + + assertEquals(result, { + error: "tool_error", + code: "tool_error", + message: "Agent already exists", + correlation_id: oversizedToolCallId.slice(0, 256), + }); + }); + it("wraps non-object structured MCP errors with a canonical marker", async () => { const source = createRemoteMCPToolSource({ id: "docs", diff --git a/src/tool/remote-mcp.ts b/src/tool/remote-mcp.ts index abff2f9e7c..433314f4a7 100644 --- a/src/tool/remote-mcp.ts +++ b/src/tool/remote-mcp.ts @@ -318,7 +318,11 @@ function parseCallToolErrorText( error: code, code, message: text, - ...(context?.toolCallId ? { correlation_id: context.toolCallId } : {}), + ...(context?.toolCallId + ? { + correlation_id: context.toolCallId.slice(0, MAX_REMOTE_MCP_CORRELATION_ID_LENGTH), + } + : {}), }; } From c1829316fef7848b8d616b11c025c4a5ddcf12ce Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 16 Aug 2026 10:18:25 +0200 Subject: [PATCH 4/5] test: narrow remote MCP transport error type --- src/tool/remote-mcp.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tool/remote-mcp.test.ts b/src/tool/remote-mcp.test.ts index 43cfcbcf6a..8eadf92c54 100644 --- a/src/tool/remote-mcp.test.ts +++ b/src/tool/remote-mcp.test.ts @@ -814,6 +814,7 @@ describe("tool/remote-mcp", () => { Error, ); + assertInstanceOf(error, Error); assertEquals(error.message, "Remote MCP request failed (503)"); }); From d50b1a12d92382ebee03ab8be2dad9931fdbe6d4 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 16 Aug 2026 10:28:46 +0200 Subject: [PATCH 5/5] docs: refresh tool API reference --- docs/api-reference/veryfront/tool.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api-reference/veryfront/tool.md b/docs/api-reference/veryfront/tool.md index 1b8055667a..ca8ac8d150 100644 --- a/docs/api-reference/veryfront/tool.md +++ b/docs/api-reference/veryfront/tool.md @@ -127,8 +127,8 @@ Create a typed tool definition. | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `createContext7ToolSource` | Create context7 tool source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/context7.ts#L28) | | `createProjectScopedRemoteToolCatalog` | Create project scoped remote tool catalog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/project-scoped-remote-tools.ts#L348) | -| `createRemoteMCPToolSource` | Create a remote MCP source with the framework's guarded outbound transport. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L983) | -| `createRemoteMCPToolSourceFactoryWithTransport` | Create a remote MCP source factory with narrowly scoped host transport. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L1038) | +| `createRemoteMCPToolSource` | Create a remote MCP source with the framework's guarded outbound transport. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L1091) | +| `createRemoteMCPToolSourceFactoryWithTransport` | Create a remote MCP source factory with narrowly scoped host transport. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L1146) | | `createSleepTool` | Create sleep tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/sleep.ts#L54) | | `createToolsFromHostDefinitions` | Create tools from host definitions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/host-tools.ts#L96) | | `createToolsFromRemoteDefinitions` | Create tools from remote definitions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-source-tools.ts#L29) | @@ -169,8 +169,8 @@ Create a typed tool definition. | `ProjectScopedRemoteToolExecution` | Public API contract for project scoped remote tool execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/project-scoped-remote-tools.ts#L45) | | `ProjectScopedRemoteToolExecutionInput` | Input payload for project scoped remote tool execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/project-scoped-remote-tools.ts#L38) | | `ProjectScopedRemoteToolOptions` | Options accepted by project scoped remote tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/project-scoped-remote-tools.ts#L6) | -| `RemoteMCPToolSourceConfig` | Configuration used by remote MCP tool source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L52) | -| `RemoteMCPToolSourceTransportOptions` | Deployment-owned transport policy for trusted MCP endpoint roots. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L990) | +| `RemoteMCPToolSourceConfig` | Configuration used by remote MCP tool source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L53) | +| `RemoteMCPToolSourceTransportOptions` | Deployment-owned transport policy for trusted MCP endpoint roots. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L1098) | | `RemoteToolMaterializationOptions` | Options accepted by remote tool materialization. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-source-tools.ts#L8) | | `RemoteToolSource` | Remote tool source loaded dynamically at runtime. Hosts can provide these to expose tools from remote MCP-compatible systems without registering those tools globally inside the framework. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/types.ts#L231) | | `SleepToolInput` | Input payload for sleep tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/sleep.ts#L45) |