From 365bef90965e9a2a19bc4694f7e0bf7f570c9a04 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 00:20:39 +0000 Subject: [PATCH 1/3] test(mcp): invalid tool arguments reach Cursor only as "Tool execution error" Failing repro for #35713: over HTTP MCP 2025-06-18, T3 answers invalid tool arguments with a JSON-RPC -32602 error instead of an isError tool result, and Cursor reduces protocol errors to a generic message. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpHttpServer.test.ts | 91 +++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 54d3470800280..788bd4d630b7f 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1133,6 +1133,97 @@ it.effect("answers 2026-07-28 discovery and keeps 2025-06-18 as the initialize f ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +// Cursor reduces JSON-RPC errors to "Tool execution error", so the model never sees why. +it.effect("returns invalid tool arguments as a readable tool error over HTTP", () => + Effect.scoped( + Effect.gen(function* () { + const token = "providerTokenWithoutDots"; + const scope: McpInvocationContext.McpInvocationScope = { + environmentId, + requestNamespace: "provider-session", + thread: { + threadId: ThreadId.make("thread-provider"), + providerSessionId: "provider-session", + providerInstanceId: ProviderInstanceId.make("cursor"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const ProbeToolkit = Toolkit.make( + Tool.make("probe", { + description: "Reports success.", + parameters: Schema.Struct({ + options: Schema.optional(Schema.Record(Schema.String, Schema.String)), + }), + success: Schema.Struct({ ok: Schema.Boolean }), + }), + ); + const layerServer = McpHttpServer.layerMcpTransport( + McpHttpServer.toolkitRegistration( + ProbeToolkit, + McpToolAccess.toLayer(ProbeToolkit, { + probe: McpToolAccess.reads(() => Effect.succeed({ ok: true })), + }), + ), + ).pipe( + Layer.provide(ServerSettings.layerTest()), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + resolve: (presented) => + Effect.succeed( + presented === token + ? (scope as McpInvocationContext.McpThreadInvocationScope) + : undefined, + ), + }), + ), + ); + yield* HttpRouter.serve(layerServer, { + disableListenLog: true, + disableLogger: true, + }).pipe(Layer.build); + const httpClient = (yield* HttpClient.HttpClient).pipe( + HttpClient.mapRequest(HttpClientRequest.bearerToken(token)), + ); + + const initializeResponse = yield* httpClient.post("/mcp", { + headers: { accept: "application/json, text/event-stream" }, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"mcp-test","version":"1.0.0"}}}`, + "application/json", + ), + }); + expect(initializeResponse.status).toBe(200); + const sessionId = initializeResponse.headers["mcp-session-id"]!; + + const callResponse = yield* httpClient.post("/mcp", { + headers: { + accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-06-18", + }, + body: HttpBody.text( + JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "probe", arguments: { options: '{"reasoningEffort":"high"}' } }, + }), + "application/json", + ), + }); + const call = (yield* callResponse.json) as { + error?: unknown; + result?: { isError?: boolean; content?: ReadonlyArray<{ text?: string }> }; + }; + expect(call.error).toBeUndefined(); + expect(call.result?.isError).toBe(true); + expect(call.result?.content?.[0]?.text).toContain(`at ["options"]`); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("admits provider and OAuth client credentials and points only clients at OAuth", () => Effect.scoped( Effect.gen(function* () { From f997adc167f0c17d90b4ef05925de811cd18207f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 00:31:05 +0000 Subject: [PATCH 2/3] fix(server): agents read why a T3 tool rejected their arguments Under MCP 2025-06-18, which Cursor negotiates, invalid tool arguments went back as a JSON-RPC -32602 error. Cursor reduces that to "Tool execution error", so models never saw what to fix (for example target.options sent as a string to delegate_task). Toolkit tools now return the validation message as an isError tool result on every protocol version, as MCP 2025-11-25 specifies and the hand-registered image tools already do. Fixes pingdotgg/t3code#35713 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpHttpServer.test.ts | 32 ++++++++++++++------ apps/server/src/mcp/McpHttpServer.ts | 37 ++++++++++++++++++++++- apps/server/src/mcp/toolkits/core.test.ts | 5 ++- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 788bd4d630b7f..f1ebf468259a8 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -650,16 +650,25 @@ it.effect("decodes null optional arguments the way it decodes omitted ones", () it.effect("rejects arguments a tool does not declare instead of dropping them", () => { const calls: Array = []; return Effect.gen(function* () { - const launch = yield* Effect.flip( - callTool("t3_thread_launch", { title: "Repro", scratch: true, prompt: "say hi" }), - ); - const wait = yield* Effect.flip( - callTool("t3_thread_wait", { threadId: "thread-excess-arguments", timeout: 5_000 }), - ); + const launch = yield* callTool("t3_thread_launch", { + title: "Repro", + scratch: true, + prompt: "say hi", + }); + const wait = yield* callTool("t3_thread_wait", { + threadId: "thread-excess-arguments", + timeout: 5_000, + }); expect(calls).toEqual([]); - expect(launch.message).toContain('Unknown argument "prompt"'); - expect(wait.message).toContain('Unknown argument "timeout"'); + expect(launch.isError).toBe(true); + expect(launch.content).toEqual([ + { type: "text", text: expect.stringContaining('Unknown argument "prompt"') }, + ]); + expect(wait.isError).toBe(true); + expect(wait.content).toEqual([ + { type: "text", text: expect.stringContaining('Unknown argument "timeout"') }, + ]); }).pipe(Effect.provide(layerRecordingTools(calls))); }); @@ -718,9 +727,12 @@ it.effect.each(["limit", "runLimit", "maxCharsPerItem"])( const rejected = yield* callTool("t3_thread_read", { threadId: "thread-below-minimum", [field]: 0, - }).pipe(Effect.flip); + }); - expect(String(rejected)).toContain("greater than or equal to 1"); + expect(rejected.isError).toBe(true); + expect(rejected.content).toEqual([ + { type: "text", text: expect.stringContaining("greater than or equal to 1") }, + ]); expect(calls).toEqual([]); }).pipe(Effect.provide(layerRecordingTools(calls))); }, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 39f679154bcfe..05673896e50c7 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -1172,6 +1172,38 @@ const withAdvertisedSchemas = Effect.fn("McpHttpServer.withAdvertisedSchemas")(f return yield* registration.pipe(Effect.provideService(McpServer.McpServer, advertisingServer)); }); +/** + * `registerToolkit` raises `InvalidParams` only for invalid arguments. MCP before 2025-11-25 sends + * that as a JSON-RPC error, which Cursor shows models as "Tool execution error", so it goes back + * as a tool result the model can read and correct. + */ +const withReadableInvalidArguments = Effect.fn("McpHttpServer.withReadableInvalidArguments")( + function* (registration: Effect.Effect) { + const server = yield* McpServer.McpServer; + const readableServer = McpServer.McpServer.of({ + ...server, + addTool: Effect.fn("McpServer.addTool")(function (options) { + return server.addTool({ + ...options, + handle: (payload) => + options.handle(payload).pipe( + Effect.catchTags({ + InvalidParams: (error) => + Effect.succeed( + new McpSchema.CallToolResult({ + isError: true, + content: [{ type: "text", text: error.message }], + }), + ), + }), + ), + }); + }), + }); + return yield* registration.pipe(Effect.provideService(McpServer.McpServer, readableServer)); + }, +); + /** Hands each call the services its registration was built with; the request's own still win. */ const provideRegistrationServices = () => McpServer.middleware<{ provides: Services }>()( @@ -1196,7 +1228,10 @@ export const toolkitRegistration = , EX, Layer.effectDiscard( withToolAnalytics( withToolSpans( - withAdvertisedSchemas(toolkit, McpServer.registerToolkit(decodeAsAdvertised(toolkit))), + withAdvertisedSchemas( + toolkit, + withReadableInvalidArguments(McpServer.registerToolkit(decodeAsAdvertised(toolkit))), + ), ), ), ).pipe( diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 66897e9c39059..ce6f4aa29e91c 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -479,12 +479,11 @@ it.effect("rejects an unknown organize target instead of archiving the calling t .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, scope), Effect.provideService(McpSchema.McpServerClient, client), - Effect.result, ); expect(dispatched).toEqual([]); expect(outcome).toMatchObject({ - _tag: "Failure", - failure: { message: expect.stringContaining('Unknown argument "threadIds"') }, + isError: true, + content: [{ type: "text", text: expect.stringContaining('Unknown argument "threadIds"') }], }); }).pipe( Effect.provide( From 018a653dd06f2ad316f9c862334101fe39af7f41 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:43:39 +0000 Subject: [PATCH 3/3] test(server): drop HTTP case that repeats the callTool invalid-argument checks Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpHttpServer.test.ts | 91 ----------------------- 1 file changed, 91 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 9d8df2e5b8076..e50795e0bc847 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1144,97 +1144,6 @@ it.effect("answers 2026-07-28 discovery and keeps 2025-06-18 as the initialize f ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); -// Cursor reduces JSON-RPC errors to "Tool execution error", so the model never sees why. -it.effect("returns invalid tool arguments as a readable tool error over HTTP", () => - Effect.scoped( - Effect.gen(function* () { - const token = "providerTokenWithoutDots"; - const scope: McpInvocationContext.McpInvocationScope = { - environmentId, - requestNamespace: "provider-session", - thread: { - threadId: ThreadId.make("thread-provider"), - providerSessionId: "provider-session", - providerInstanceId: ProviderInstanceId.make("cursor"), - }, - client: undefined, - capabilities: new Set(["orchestration"]), - issuedAt: 1, - }; - const ProbeToolkit = Toolkit.make( - Tool.make("probe", { - description: "Reports success.", - parameters: Schema.Struct({ - options: Schema.optional(Schema.Record(Schema.String, Schema.String)), - }), - success: Schema.Struct({ ok: Schema.Boolean }), - }), - ); - const layerServer = McpHttpServer.layerMcpTransport( - McpHttpServer.toolkitRegistration( - ProbeToolkit, - McpToolAccess.toLayer(ProbeToolkit, { - probe: McpToolAccess.reads(() => Effect.succeed({ ok: true })), - }), - ), - ).pipe( - Layer.provide(ServerSettings.layerTest()), - Layer.provide( - Layer.mock(McpSessionRegistry.McpSessionRegistry)({ - resolve: (presented) => - Effect.succeed( - presented === token - ? (scope as McpInvocationContext.McpThreadInvocationScope) - : undefined, - ), - }), - ), - ); - yield* HttpRouter.serve(layerServer, { - disableListenLog: true, - disableLogger: true, - }).pipe(Layer.build); - const httpClient = (yield* HttpClient.HttpClient).pipe( - HttpClient.mapRequest(HttpClientRequest.bearerToken(token)), - ); - - const initializeResponse = yield* httpClient.post("/mcp", { - headers: { accept: "application/json, text/event-stream" }, - body: HttpBody.text( - `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"mcp-test","version":"1.0.0"}}}`, - "application/json", - ), - }); - expect(initializeResponse.status).toBe(200); - const sessionId = initializeResponse.headers["mcp-session-id"]!; - - const callResponse = yield* httpClient.post("/mcp", { - headers: { - accept: "application/json, text/event-stream", - "mcp-session-id": sessionId, - "mcp-protocol-version": "2025-06-18", - }, - body: HttpBody.text( - JSON.stringify({ - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "probe", arguments: { options: '{"reasoningEffort":"high"}' } }, - }), - "application/json", - ), - }); - const call = (yield* callResponse.json) as { - error?: unknown; - result?: { isError?: boolean; content?: ReadonlyArray<{ text?: string }> }; - }; - expect(call.error).toBeUndefined(); - expect(call.result?.isError).toBe(true); - expect(call.result?.content?.[0]?.text).toContain(`at ["options"]`); - }), - ).pipe(Effect.provide(NodeHttpServer.layerTest)), -); - it.effect("admits provider and OAuth client credentials and points only clients at OAuth", () => Effect.scoped( Effect.gen(function* () {