From 9f8d1eae1bb8cc1f746a3e6db8d31cc25b463dbc Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 20 May 2026 09:53:46 -0700 Subject: [PATCH 1/8] add MCP output schema regression tests --- .../plugins/mcp/src/sdk/elicitation.test.ts | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/packages/plugins/mcp/src/sdk/elicitation.test.ts b/packages/plugins/mcp/src/sdk/elicitation.test.ts index 34b008f4b..347b8158b 100644 --- a/packages/plugins/mcp/src/sdk/elicitation.test.ts +++ b/packages/plugins/mcp/src/sdk/elicitation.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Schema } from "effect"; +import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker"; +import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types"; +import * as ts from "typescript"; import { createExecutor, FormElicitation, ElicitationResponse, + isToolResult, type InvokeOptions, } from "@executor-js/sdk"; import { makeTestConfig } from "@executor-js/sdk/testing"; @@ -16,6 +20,72 @@ const isFormElicitation = Schema.is(FormElicitation); const serveElicitationTestServer = serveMcpServer(makeElicitationMcpServer); +const schemaValidator = new CfWorkerJsonSchemaValidator({ shortcircuit: false }); + +const expectMatchesOutputSchema = (outputSchema: unknown, value: unknown): void => { + expect(outputSchema).toBeDefined(); + const result = schemaValidator.getValidator(outputSchema as JsonSchemaType)(value); + expect(result).toEqual({ + valid: true, + data: value, + errorMessage: undefined, + }); +}; + +const expectToolResultOkData = (result: unknown): unknown => { + expect(isToolResult(result)).toBe(true); + expect(result).toMatchObject({ ok: true }); + return (result as { readonly ok: true; readonly data: unknown }).data; +}; + +type OutputTypeScriptContract = { + readonly outputTypeScript?: string; + readonly typeScriptDefinitions?: Record; +}; + +const typeCheckOutput = ( + contract: OutputTypeScriptContract | null | undefined, + runtimeOutput: unknown, +): readonly string[] => { + if (!contract?.outputTypeScript) return ["missing outputTypeScript"]; + + const fileName = "mcp-output-contract.ts"; + const source = [ + ...Object.entries(contract.typeScriptDefinitions ?? {}).map( + ([name, definition]) => `type ${name} = ${definition};`, + ), + `type ToolOutput = ${contract.outputTypeScript};`, + `const invokedOutput: ToolOutput = ${JSON.stringify(runtimeOutput)};`, + "invokedOutput;", + ].join("\n"); + + const options: ts.CompilerOptions = { + module: ts.ModuleKind.ESNext, + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ES2022, + }; + const host = ts.createCompilerHost(options); + const originalGetSourceFile = host.getSourceFile.bind(host); + const originalReadFile = host.readFile.bind(host); + const originalFileExists = host.fileExists.bind(host); + + host.getSourceFile = (candidate, languageVersion, onError, shouldCreateNewSourceFile) => { + if (candidate === fileName) { + return ts.createSourceFile(candidate, source, languageVersion, true); + } + return originalGetSourceFile(candidate, languageVersion, onError, shouldCreateNewSourceFile); + }; + host.readFile = (candidate) => (candidate === fileName ? source : originalReadFile(candidate)); + host.fileExists = (candidate) => candidate === fileName || originalFileExists(candidate); + + const program = ts.createProgram([fileName], options, host); + return ts.getPreEmitDiagnostics(program).map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + ); +}; + // --------------------------------------------------------------------------- // Helper — create executor with MCP plugin pointed at test server // --------------------------------------------------------------------------- @@ -122,12 +192,64 @@ describe("MCP elicitation (end-to-end)", () => { }), ); + it.effect("registered tools without MCP outputSchema still describe CallToolResult", () => + Effect.gen(function* () { + const server = yield* serveElicitationTestServer; + const executor = yield* makeTestExecutor(server.url); + const tools = yield* executor.tools.list(); + const simpleEcho = tools.find((t) => t.name === "simple_echo")!; + const schema = yield* executor.tools.schema(simpleEcho.id); + + expect(schema?.outputSchema).toMatchObject({ + type: "object", + properties: { + content: { type: "array" }, + structuredContent: {}, + isError: { const: false }, + _meta: { type: "object" }, + }, + required: ["content"], + }); + expect(schema?.outputTypeScript).toContain("content: unknown[]"); + expect(schema?.outputTypeScript).toContain("structuredContent?: unknown"); + + const result = yield* executor.tools.invoke( + simpleEcho.id, + { value: "plain" }, + { onElicitation: "accept-all" }, + ); + + const data = expectToolResultOkData(result); + expectMatchesOutputSchema(schema?.outputSchema, data); + expect(typeCheckOutput(schema, data)).toEqual([]); + }), + ); + it.effect("successful tool invocation preserves structured MCP result fields", () => Effect.gen(function* () { const server = yield* serveElicitationTestServer; const executor = yield* makeTestExecutor(server.url); const tools = yield* executor.tools.list(); const structuredEcho = tools.find((t) => t.name === "structured_echo")!; + const schema = yield* executor.tools.schema(structuredEcho.id); + + expect(schema?.outputSchema).toMatchObject({ + type: "object", + properties: { + content: { type: "array" }, + structuredContent: { + type: "object", + properties: { + value: { type: "string" }, + upper: { type: "string" }, + }, + }, + _meta: { type: "object" }, + }, + required: ["content", "structuredContent"], + }); + expect(schema?.outputTypeScript).toContain("structuredContent"); + expect(schema?.outputTypeScript).toContain("value: string"); const result = yield* executor.tools.invoke( structuredEcho.id, @@ -143,6 +265,55 @@ describe("MCP elicitation (end-to-end)", () => { _meta: { trace: "kept" }, }, }); + const data = expectToolResultOkData(result); + expectMatchesOutputSchema(schema?.outputSchema, data); + expect(typeCheckOutput(schema, data)).toEqual([]); + }), + ); + + it.effect("refreshSource keeps MCP outputSchema nested under structuredContent", () => + Effect.gen(function* () { + const server = yield* serveElicitationTestServer; + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [mcpPlugin()] as const, + }), + ); + + yield* executor.mcp.addSource({ + transport: "remote", + scope: "test-scope", + name: "test-mcp", + namespace: "schema_refresh", + endpoint: server.url, + }); + yield* executor.mcp.refreshSource("schema_refresh", "test-scope"); + + const schema = yield* executor.tools.schema("schema_refresh.structured_echo"); + expect(schema?.outputSchema).toMatchObject({ + type: "object", + properties: { + content: { type: "array" }, + structuredContent: { + type: "object", + properties: { + value: { type: "string" }, + upper: { type: "string" }, + }, + }, + }, + required: ["content", "structuredContent"], + }); + expect(schema?.outputTypeScript).toContain("structuredContent"); + expect(schema?.outputTypeScript).toContain("upper: string"); + + const result = yield* executor.tools.invoke( + "schema_refresh.structured_echo", + { value: "plain" }, + { onElicitation: "accept-all" }, + ); + const data = expectToolResultOkData(result); + expect(typeCheckOutput(schema, data)).toEqual([]); }), ); From 8f1854e626a4aa0b5f15fb90521002d37c3df6ab Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 20 May 2026 10:00:46 -0700 Subject: [PATCH 2/8] extract tool output type contract helper --- .../core/execution/src/tool-invoker.test.ts | 48 ++------------ packages/core/sdk/src/testing.ts | 5 ++ .../src/testing/tool-output-contract.test.ts | 52 +++++++++++++++ .../sdk/src/testing/tool-output-contract.ts | 66 +++++++++++++++++++ .../plugins/mcp/src/sdk/elicitation.test.ts | 57 ++-------------- 5 files changed, 133 insertions(+), 95 deletions(-) create mode 100644 packages/core/sdk/src/testing/tool-output-contract.test.ts create mode 100644 packages/core/sdk/src/testing/tool-output-contract.ts diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index f688b5d38..4271fcea7 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Fiber, Schema } from "effect"; -import * as ts from "typescript"; import { ElicitationResponse, @@ -10,7 +9,7 @@ import { definePlugin, tool, } from "@executor-js/sdk"; -import { makeTestConfig } from "@executor-js/sdk/testing"; +import { makeTestConfig, typeCheckOutputTypeScript } from "@executor-js/sdk/testing"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { createExecutionEngine } from "./engine"; import { describeTool, makeExecutorToolInvoker, searchTools } from "./tool-invoker"; @@ -44,48 +43,13 @@ const typeCheckDescribedInvocation = ( described: DescribedToolContract, runtimeResult: unknown, consumerSource: string, -): readonly string[] => { - const fileName = "described-tool-contract.ts"; - const source = [ - ...Object.entries(described.typeScriptDefinitions).map(([name, definition]) => { - return `type ${name} = ${definition};`; - }), - `type ToolOutput = ${described.outputTypeScript};`, - `const invokedResult: ToolOutput = ${JSON.stringify(runtimeResult)};`, +): readonly string[] => + typeCheckOutputTypeScript(described, runtimeResult, { consumerSource, - ].join("\n"); - - const options: ts.CompilerOptions = { - module: ts.ModuleKind.ESNext, - noEmit: true, - skipLibCheck: true, - strict: true, - target: ts.ScriptTarget.ES2022, - }; - const host = ts.createCompilerHost(options); - const originalGetSourceFile = host.getSourceFile.bind(host); - const originalReadFile = host.readFile.bind(host); - const originalFileExists = host.fileExists.bind(host); - - host.getSourceFile = (candidate, languageVersion, onError, shouldCreateNewSourceFile) => { - if (candidate === fileName) { - return ts.createSourceFile(candidate, source, languageVersion, true); - } - return originalGetSourceFile(candidate, languageVersion, onError, shouldCreateNewSourceFile); - }; - host.readFile = (candidate) => (candidate === fileName ? source : originalReadFile(candidate)); - host.fileExists = (candidate) => candidate === fileName || originalFileExists(candidate); - - const program = ts.createProgram([fileName], options, host); - return ts.getPreEmitDiagnostics(program).map((diagnostic) => { - const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); - if (!diagnostic.file || diagnostic.start === undefined) { - return message; - } - const position = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); - return `${diagnostic.file.fileName}:${position.line + 1}:${position.character + 1} ${message}`; + fileName: "described-tool-contract.ts", + typeName: "ToolOutput", + valueName: "invokedResult", }); -}; // --------------------------------------------------------------------------- // Test plugins — each one declares a namespace as a static source with N diff --git a/packages/core/sdk/src/testing.ts b/packages/core/sdk/src/testing.ts index 49c1a4771..59014db2f 100644 --- a/packages/core/sdk/src/testing.ts +++ b/packages/core/sdk/src/testing.ts @@ -32,6 +32,11 @@ export { type OAuthTestServerShape, } from "./testing/oauth-test-server"; export { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; +export { + typeCheckOutputTypeScript, + type OutputTypeScriptContract, + type TypeCheckOutputTypeScriptOptions, +} from "./testing/tool-output-contract"; export class TestHttpServerAddressError extends Data.TaggedError("TestHttpServerAddressError")<{ readonly address: unknown; diff --git a/packages/core/sdk/src/testing/tool-output-contract.test.ts b/packages/core/sdk/src/testing/tool-output-contract.test.ts new file mode 100644 index 000000000..4c9226d84 --- /dev/null +++ b/packages/core/sdk/src/testing/tool-output-contract.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { typeCheckOutputTypeScript } from "./tool-output-contract"; + +describe("typeCheckOutputTypeScript", () => { + it("accepts runtime output that matches the described TypeScript contract", () => { + const diagnostics = typeCheckOutputTypeScript( + { + outputTypeScript: "{ ok: true; data: ResultData }", + typeScriptDefinitions: { + Payload: "{ answer: string }", + ResultData: + "{ content: readonly { type: \"text\"; text: string }[]; structuredContent: Payload }", + }, + }, + { + ok: true, + data: { + content: [{ type: "text", text: "done" }], + structuredContent: { answer: "done" }, + }, + }, + { + consumerSource: + "const answer: string = invokedOutput.data.structuredContent.answer; answer;", + }, + ); + + expect(diagnostics).toEqual([]); + }); + + it("reports when the described contract omits the runtime output wrapper", () => { + const diagnostics = typeCheckOutputTypeScript( + { + outputTypeScript: "{ ok: true; data: { answer: string } }", + }, + { + ok: true, + data: { + content: [{ type: "text", text: "done" }], + structuredContent: { answer: "done" }, + }, + }, + ); + + expect(diagnostics.join("\n")).toContain("answer"); + }); + + it("reports missing output TypeScript contracts", () => { + expect(typeCheckOutputTypeScript({}, { ok: true })).toEqual(["missing outputTypeScript"]); + }); +}); diff --git a/packages/core/sdk/src/testing/tool-output-contract.ts b/packages/core/sdk/src/testing/tool-output-contract.ts new file mode 100644 index 000000000..56000e1d3 --- /dev/null +++ b/packages/core/sdk/src/testing/tool-output-contract.ts @@ -0,0 +1,66 @@ +import * as ts from "typescript"; + +export type OutputTypeScriptContract = { + readonly outputTypeScript?: string; + readonly typeScriptDefinitions?: Record; +}; + +export type TypeCheckOutputTypeScriptOptions = { + readonly consumerSource?: string; + readonly fileName?: string; + readonly typeName?: string; + readonly valueName?: string; +}; + +export const typeCheckOutputTypeScript = ( + contract: OutputTypeScriptContract | null | undefined, + runtimeOutput: unknown, + options: TypeCheckOutputTypeScriptOptions = {}, +): readonly string[] => { + if (!contract?.outputTypeScript) { + return ["missing outputTypeScript"]; + } + + const fileName = options.fileName ?? "tool-output-contract.ts"; + const typeName = options.typeName ?? "ToolOutput"; + const valueName = options.valueName ?? "invokedOutput"; + const source = [ + ...Object.entries(contract.typeScriptDefinitions ?? {}).map( + ([name, definition]) => `type ${name} = ${definition};`, + ), + `type ${typeName} = ${contract.outputTypeScript};`, + `const ${valueName}: ${typeName} = ${JSON.stringify(runtimeOutput)};`, + options.consumerSource ?? `${valueName};`, + ].join("\n"); + + const compilerOptions: ts.CompilerOptions = { + module: ts.ModuleKind.ESNext, + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ES2022, + }; + const host = ts.createCompilerHost(compilerOptions); + const originalGetSourceFile = host.getSourceFile.bind(host); + const originalReadFile = host.readFile.bind(host); + const originalFileExists = host.fileExists.bind(host); + + host.getSourceFile = (candidate, languageVersion, onError, shouldCreateNewSourceFile) => { + if (candidate === fileName) { + return ts.createSourceFile(candidate, source, languageVersion, true); + } + return originalGetSourceFile(candidate, languageVersion, onError, shouldCreateNewSourceFile); + }; + host.readFile = (candidate) => (candidate === fileName ? source : originalReadFile(candidate)); + host.fileExists = (candidate) => candidate === fileName || originalFileExists(candidate); + + const program = ts.createProgram([fileName], compilerOptions, host); + return ts.getPreEmitDiagnostics(program).map((diagnostic) => { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); + if (!diagnostic.file || diagnostic.start === undefined) { + return message; + } + const position = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); + return `${diagnostic.file.fileName}:${position.line + 1}:${position.character + 1} ${message}`; + }); +}; diff --git a/packages/plugins/mcp/src/sdk/elicitation.test.ts b/packages/plugins/mcp/src/sdk/elicitation.test.ts index 347b8158b..6211a5e57 100644 --- a/packages/plugins/mcp/src/sdk/elicitation.test.ts +++ b/packages/plugins/mcp/src/sdk/elicitation.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Schema } from "effect"; import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker"; import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types"; -import * as ts from "typescript"; import { createExecutor, @@ -11,7 +10,7 @@ import { isToolResult, type InvokeOptions, } from "@executor-js/sdk"; -import { makeTestConfig } from "@executor-js/sdk/testing"; +import { makeTestConfig, typeCheckOutputTypeScript } from "@executor-js/sdk/testing"; import { mcpPlugin } from "./plugin"; import { makeElicitationMcpServer, serveMcpServer } from "../testing"; @@ -38,54 +37,6 @@ const expectToolResultOkData = (result: unknown): unknown => { return (result as { readonly ok: true; readonly data: unknown }).data; }; -type OutputTypeScriptContract = { - readonly outputTypeScript?: string; - readonly typeScriptDefinitions?: Record; -}; - -const typeCheckOutput = ( - contract: OutputTypeScriptContract | null | undefined, - runtimeOutput: unknown, -): readonly string[] => { - if (!contract?.outputTypeScript) return ["missing outputTypeScript"]; - - const fileName = "mcp-output-contract.ts"; - const source = [ - ...Object.entries(contract.typeScriptDefinitions ?? {}).map( - ([name, definition]) => `type ${name} = ${definition};`, - ), - `type ToolOutput = ${contract.outputTypeScript};`, - `const invokedOutput: ToolOutput = ${JSON.stringify(runtimeOutput)};`, - "invokedOutput;", - ].join("\n"); - - const options: ts.CompilerOptions = { - module: ts.ModuleKind.ESNext, - noEmit: true, - skipLibCheck: true, - strict: true, - target: ts.ScriptTarget.ES2022, - }; - const host = ts.createCompilerHost(options); - const originalGetSourceFile = host.getSourceFile.bind(host); - const originalReadFile = host.readFile.bind(host); - const originalFileExists = host.fileExists.bind(host); - - host.getSourceFile = (candidate, languageVersion, onError, shouldCreateNewSourceFile) => { - if (candidate === fileName) { - return ts.createSourceFile(candidate, source, languageVersion, true); - } - return originalGetSourceFile(candidate, languageVersion, onError, shouldCreateNewSourceFile); - }; - host.readFile = (candidate) => (candidate === fileName ? source : originalReadFile(candidate)); - host.fileExists = (candidate) => candidate === fileName || originalFileExists(candidate); - - const program = ts.createProgram([fileName], options, host); - return ts.getPreEmitDiagnostics(program).map((diagnostic) => - ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), - ); -}; - // --------------------------------------------------------------------------- // Helper — create executor with MCP plugin pointed at test server // --------------------------------------------------------------------------- @@ -221,7 +172,7 @@ describe("MCP elicitation (end-to-end)", () => { const data = expectToolResultOkData(result); expectMatchesOutputSchema(schema?.outputSchema, data); - expect(typeCheckOutput(schema, data)).toEqual([]); + expect(typeCheckOutputTypeScript(schema, data)).toEqual([]); }), ); @@ -267,7 +218,7 @@ describe("MCP elicitation (end-to-end)", () => { }); const data = expectToolResultOkData(result); expectMatchesOutputSchema(schema?.outputSchema, data); - expect(typeCheckOutput(schema, data)).toEqual([]); + expect(typeCheckOutputTypeScript(schema, data)).toEqual([]); }), ); @@ -313,7 +264,7 @@ describe("MCP elicitation (end-to-end)", () => { { onElicitation: "accept-all" }, ); const data = expectToolResultOkData(result); - expect(typeCheckOutput(schema, data)).toEqual([]); + expect(typeCheckOutputTypeScript(schema, data)).toEqual([]); }), ); From 445ae35388e21963c84a3bcc86da320bd5e69329 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 20 May 2026 10:08:31 -0700 Subject: [PATCH 3/8] fix MCP tool output schema wrapper --- packages/plugins/mcp/src/sdk/plugin.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index bcbf2ea41..366d6acc9 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -319,6 +319,26 @@ const normalizeNamespace = (config: McpSourceConfig): string => command: config.transport === "stdio" ? config.command : undefined, }); +const mcpCallToolResultOutputSchema = ( + structuredContentSchema?: unknown, +): Record => ({ + type: "object", + properties: { + content: { + type: "array", + items: {}, + }, + structuredContent: structuredContentSchema ?? {}, + isError: { const: false }, + _meta: { + type: "object", + additionalProperties: {}, + }, + }, + required: structuredContentSchema === undefined ? ["content"] : ["content", "structuredContent"], + additionalProperties: {}, +}); + const toBinding = (entry: McpToolManifestEntry): McpToolBinding => McpToolBinding.make({ toolId: entry.toolId, @@ -1440,7 +1460,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { name: e.toolId, description: e.description ?? `MCP tool: ${e.toolName}`, inputSchema: e.inputSchema, - outputSchema: e.outputSchema, + outputSchema: mcpCallToolResultOutputSchema(e.outputSchema), })), }); if (initialRemote && initialBindings.length > 0) { @@ -1582,7 +1602,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { name: e.toolId, description: e.description ?? `MCP tool: ${e.toolName}`, inputSchema: e.inputSchema, - outputSchema: e.outputSchema, + outputSchema: mcpCallToolResultOutputSchema(e.outputSchema), })), }); }), From 07d4aa002e4e4e63f3aff21695ed070e27191270 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 20 May 2026 10:18:04 -0700 Subject: [PATCH 4/8] derive MCP output schema from SDK --- bun.lock | 2 +- packages/plugins/mcp/package.json | 6 +-- .../plugins/mcp/src/sdk/elicitation.test.ts | 26 +++++++++++- packages/plugins/mcp/src/sdk/plugin.ts | 42 +++++++++++-------- 4 files changed, 52 insertions(+), 24 deletions(-) diff --git a/bun.lock b/bun.lock index 7533ce499..53ec43c7c 100644 --- a/bun.lock +++ b/bun.lock @@ -742,6 +742,7 @@ "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "effect": "catalog:", + "zod": "^4.3.6", }, "devDependencies": { "@effect/atom-react": "catalog:", @@ -754,7 +755,6 @@ "react": "catalog:", "tsup": "catalog:", "vitest": "catalog:", - "zod": "^4.3.6", }, "peerDependencies": { "@effect/atom-react": "catalog:", diff --git a/packages/plugins/mcp/package.json b/packages/plugins/mcp/package.json index f63a9d03b..84a79cacb 100644 --- a/packages/plugins/mcp/package.json +++ b/packages/plugins/mcp/package.json @@ -66,7 +66,8 @@ "@executor-js/config": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", - "effect": "catalog:" + "effect": "catalog:", + "zod": "^4.3.6" }, "devDependencies": { "@effect/atom-react": "catalog:", @@ -78,8 +79,7 @@ "bun-types": "catalog:", "react": "catalog:", "tsup": "catalog:", - "vitest": "catalog:", - "zod": "^4.3.6" + "vitest": "catalog:" }, "peerDependencies": { "@effect/atom-react": "catalog:", diff --git a/packages/plugins/mcp/src/sdk/elicitation.test.ts b/packages/plugins/mcp/src/sdk/elicitation.test.ts index 6211a5e57..6f22002ed 100644 --- a/packages/plugins/mcp/src/sdk/elicitation.test.ts +++ b/packages/plugins/mcp/src/sdk/elicitation.test.ts @@ -161,8 +161,30 @@ describe("MCP elicitation (end-to-end)", () => { }, required: ["content"], }); - expect(schema?.outputTypeScript).toContain("content: unknown[]"); - expect(schema?.outputTypeScript).toContain("structuredContent?: unknown"); + const outputSchema = schema?.outputSchema as { + readonly properties: { + readonly content: { + readonly items: { + readonly anyOf: readonly unknown[]; + }; + }; + }; + }; + expect(outputSchema.properties.content.items.anyOf).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + type: { const: "text", type: "string" }, + text: { type: "string" }, + }), + required: ["type", "text"], + }), + ]), + ); + expect(schema?.outputTypeScript).toContain('type: "text"'); + expect(schema?.outputTypeScript).toContain( + "structuredContent?: { [k: string]: unknown; }", + ); const result = yield* executor.tools.invoke( simpleEcho.id, diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 366d6acc9..03ba67313 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -14,6 +14,8 @@ import { import type { HttpClient } from "effect/unstable/http"; import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import * as z from "zod/v4"; import { type CredentialBindingRef, @@ -319,25 +321,29 @@ const normalizeNamespace = (config: McpSourceConfig): string => command: config.transport === "stdio" ? config.command : undefined, }); -const mcpCallToolResultOutputSchema = ( - structuredContentSchema?: unknown, -): Record => ({ - type: "object", - properties: { - content: { - type: "array", - items: {}, - }, - structuredContent: structuredContentSchema ?? {}, - isError: { const: false }, - _meta: { - type: "object", - additionalProperties: {}, +type JsonSchemaObject = Record & { + readonly properties?: Record; +}; + +const McpCallToolResultJsonSchema = z.toJSONSchema(CallToolResultSchema) as JsonSchemaObject; + +const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => { + const defaultStructuredContentSchema = + McpCallToolResultJsonSchema.properties?.structuredContent ?? {}; + + return { + ...McpCallToolResultJsonSchema, + properties: { + ...McpCallToolResultJsonSchema.properties, + structuredContent: + structuredContentSchema === undefined + ? defaultStructuredContentSchema + : structuredContentSchema, + isError: { const: false }, }, - }, - required: structuredContentSchema === undefined ? ["content"] : ["content", "structuredContent"], - additionalProperties: {}, -}); + required: structuredContentSchema === undefined ? ["content"] : ["content", "structuredContent"], + }; +}; const toBinding = (entry: McpToolManifestEntry): McpToolBinding => McpToolBinding.make({ From 40a2ef823fc94df9f046a89b80816fa3adb4b543 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 20 May 2026 10:23:06 -0700 Subject: [PATCH 5/8] format MCP output schema changes --- packages/core/sdk/src/testing/tool-output-contract.test.ts | 2 +- packages/plugins/mcp/src/sdk/elicitation.test.ts | 4 +--- packages/plugins/mcp/src/sdk/plugin.ts | 3 ++- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/core/sdk/src/testing/tool-output-contract.test.ts b/packages/core/sdk/src/testing/tool-output-contract.test.ts index 4c9226d84..200bfb4c9 100644 --- a/packages/core/sdk/src/testing/tool-output-contract.test.ts +++ b/packages/core/sdk/src/testing/tool-output-contract.test.ts @@ -10,7 +10,7 @@ describe("typeCheckOutputTypeScript", () => { typeScriptDefinitions: { Payload: "{ answer: string }", ResultData: - "{ content: readonly { type: \"text\"; text: string }[]; structuredContent: Payload }", + '{ content: readonly { type: "text"; text: string }[]; structuredContent: Payload }', }, }, { diff --git a/packages/plugins/mcp/src/sdk/elicitation.test.ts b/packages/plugins/mcp/src/sdk/elicitation.test.ts index 6f22002ed..fbb98a4b9 100644 --- a/packages/plugins/mcp/src/sdk/elicitation.test.ts +++ b/packages/plugins/mcp/src/sdk/elicitation.test.ts @@ -182,9 +182,7 @@ describe("MCP elicitation (end-to-end)", () => { ]), ); expect(schema?.outputTypeScript).toContain('type: "text"'); - expect(schema?.outputTypeScript).toContain( - "structuredContent?: { [k: string]: unknown; }", - ); + expect(schema?.outputTypeScript).toContain("structuredContent?: { [k: string]: unknown; }"); const result = yield* executor.tools.invoke( simpleEcho.id, diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 03ba67313..f32aada41 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -341,7 +341,8 @@ const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonS : structuredContentSchema, isError: { const: false }, }, - required: structuredContentSchema === undefined ? ["content"] : ["content", "structuredContent"], + required: + structuredContentSchema === undefined ? ["content"] : ["content", "structuredContent"], }; }; From c0c08c18784ee55d95a4679079b1aef22a6c0921 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 20 May 2026 11:06:01 -0700 Subject: [PATCH 6/8] Project typed MCP results for sandbox agents --- .../core/execution/src/tool-invoker.test.ts | 146 ++++++++++++++++++ packages/core/execution/src/tool-invoker.ts | 57 ++++++- 2 files changed, 199 insertions(+), 4 deletions(-) diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index 4271fcea7..9cc4bc7a6 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -4,6 +4,7 @@ import { Effect, Fiber, Schema } from "effect"; import { ElicitationResponse, FormElicitation, + type StaticToolSchema, ToolResult, createExecutor, definePlugin, @@ -34,6 +35,18 @@ const EmptyInputSchema = Schema.toStandardSchemaV1( const acceptAll = () => Effect.succeed(ElicitationResponse.make({ action: "accept" })); +const rawJsonSchema = (schema: Record): StaticToolSchema => ({ + "~standard": { + version: 1, + vendor: "executor-test", + validate: (value) => ({ value }), + jsonSchema: { + input: () => schema, + output: () => schema, + }, + }, +}); + type DescribedToolContract = { readonly outputTypeScript: string; readonly typeScriptDefinitions: Record; @@ -229,6 +242,76 @@ const structuredFailurePlugin = definePlugin(() => ({ ], })); +const mcpProjectionOutputSchema = rawJsonSchema({ + type: "object", + properties: { + content: { + type: "array", + items: { + type: "object", + properties: { + type: { const: "text" }, + text: { type: "string" }, + }, + required: ["type", "text"], + additionalProperties: true, + }, + }, + structuredContent: { + type: "object", + properties: { + value: { type: "string" }, + }, + required: ["value"], + additionalProperties: false, + }, + isError: { const: false }, + }, + required: ["content", "structuredContent"], + additionalProperties: true, +}); + +const mcpProjectionPlugin = definePlugin(() => ({ + id: "mcp-projection-test" as const, + storage: () => ({}), + staticSources: () => [ + { + id: "remote", + kind: "mcp", + name: "Remote MCP", + tools: [ + { + name: "typedStructured", + description: "Returns a normal MCP CallToolResult with structured content", + inputSchema: EmptyInputSchema, + outputSchema: mcpProjectionOutputSchema, + handler: () => + Effect.succeed( + ToolResult.ok({ + content: [{ type: "text", text: "fallback text" }], + structuredContent: { value: "projected" }, + isError: false, + _meta: { source: "test" }, + }), + ), + }, + { + name: "untypedStructured", + description: "Returns MCP-shaped data without a typed structured output", + inputSchema: EmptyInputSchema, + handler: () => + Effect.succeed( + ToolResult.ok({ + content: [{ type: "text", text: "fallback text" }], + structuredContent: { value: "raw" }, + }), + ), + }, + ], + }, + ], +})); + const makeSearchExecutor = () => createExecutor(makeTestConfig({ plugins: [githubPlugin(), crmPlugin()] as const })); @@ -551,6 +634,69 @@ describe("tool discovery", () => { }), ); + it.effect("projects typed MCP structuredContent for sandbox tool calls", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [mcpProjectionPlugin()] as const }), + ); + const engine = createExecutionEngine({ executor, codeExecutor }); + + const execution = yield* engine.execute( + [ + 'const details = await tools.describe.tool({ path: "remote.typedStructured" });', + "const result = await tools.remote.typedStructured({});", + "return {", + " outputTypeScript: details.outputTypeScript,", + " typeScriptDefinitions: details.typeScriptDefinitions,", + " result,", + "};", + ].join("\n"), + { onElicitation: acceptAll }, + ); + + expect(execution.error).toBeUndefined(); + const observed = execution.result as DescribedToolContract & { readonly result: unknown }; + expect(observed.result).toEqual({ ok: true, data: { value: "projected" } }); + expect(observed.outputTypeScript).toBe( + "{ ok: true; data: { value: string; } } | { ok: false; error: ToolError }", + ); + const diagnostics = typeCheckDescribedInvocation( + observed, + observed.result, + [ + "function readToolResult(result: ToolOutput): string {", + " if (!result.ok) return result.error.message;", + " return result.data.value;", + "}", + "readToolResult(invokedResult);", + ].join("\n"), + ); + expect(diagnostics).toEqual([]); + }), + ); + + it.effect("keeps untyped MCP-shaped sandbox results raw", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [mcpProjectionPlugin()] as const }), + ); + const engine = createExecutionEngine({ executor, codeExecutor }); + + const execution = yield* engine.execute("return await tools.remote.untypedStructured({});", { + onElicitation: acceptAll, + }); + + expect(execution.error).toBeUndefined(); + expect(execution.result).toEqual({ + ok: true, + data: { + content: [{ type: "text", text: "fallback text" }], + structuredContent: { value: "raw" }, + }, + }); + }), + ); + it.effect("rejects malformed discover calls inside the sandbox", () => Effect.gen(function* () { const executor = yield* makeSearchExecutor(); diff --git a/packages/core/execution/src/tool-invoker.ts b/packages/core/execution/src/tool-invoker.ts index 9d45c449b..e4c2be98e 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -8,7 +8,7 @@ import type { InvokeOptions, Source, } from "@executor-js/sdk/core"; -import { isToolResult, ToolResult } from "@executor-js/sdk/core"; +import { isToolResult, schemaToTypeScriptPreviewWithDefs, ToolResult } from "@executor-js/sdk/core"; import type { SandboxToolInvoker } from "@executor-js/codemode-core"; import { ExecutionToolError } from "./errors"; @@ -26,6 +26,40 @@ const withToolResultDefinitions = ( ToolError: TOOL_ERROR_TYPESCRIPT, }); +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const recordProperty = ( + value: Record, + key: string, +): Record | null => (isRecord(value[key]) ? value[key] : null); + +const stringArrayProperty = (value: Record, key: string): readonly string[] => + Array.isArray(value[key]) && value[key].every((item) => typeof item === "string") + ? value[key] + : []; + +const getRequiredMcpStructuredContentSchema = (schema: unknown): unknown | undefined => { + if (!isRecord(schema)) return undefined; + const properties = recordProperty(schema, "properties"); + if (!properties) return undefined; + const required = stringArrayProperty(schema, "required"); + if (!required.includes("content") || !required.includes("structuredContent")) return undefined; + if (!("content" in properties) || !("structuredContent" in properties)) return undefined; + return properties.structuredContent; +}; + +const projectMcpToolResultForSandbox = ( + result: ToolResult, + outputSchema: unknown, +): ToolResult => { + if (getRequiredMcpStructuredContentSchema(outputSchema) === undefined) return result; + if (!result.ok || !isRecord(result.data)) return result; + if (!Array.isArray(result.data.content)) return result; + if (!("structuredContent" in result.data) || result.data.structuredContent == null) return result; + return ToolResult.ok(result.data.structuredContent); +}; + const newCorrelationId = (): string => { // 8-hex-char correlation id; enough entropy to disambiguate within a // single deployment without leaking host process info. @@ -151,7 +185,8 @@ export const makeExecutorToolInvoker = ( // uniform without forcing every tiny test plugin to import // `ToolResult.ok`. if (isToolResult(result)) { - return result; + const schema = result.ok ? yield* executor.tools.schema(path) : null; + return projectMcpToolResultForSandbox(result, schema?.outputSchema); } return { ok: true, data: result }; }), @@ -546,12 +581,26 @@ export const describeTool = Effect.fn("executor.tools.describe")(function* ( // The schema's id is the tool path; name/description come from the // tool row which tools.schema() already loaded. + const projectedOutputSchema = getRequiredMcpStructuredContentSchema(schema.outputSchema); + const projectedOutputPreview = + projectedOutputSchema === undefined + ? null + : yield* Effect.promise(() => + schemaToTypeScriptPreviewWithDefs( + projectedOutputSchema, + new Map(Object.entries(schema.schemaDefinitions ?? {})), + ), + ); + return { path, name: schema.name ?? path, description: schema.description, inputTypeScript: schema.inputTypeScript, - outputTypeScript: wrapOutputTypeScript(schema.outputTypeScript), - typeScriptDefinitions: withToolResultDefinitions(schema.typeScriptDefinitions), + outputTypeScript: wrapOutputTypeScript(projectedOutputPreview?.type ?? schema.outputTypeScript), + typeScriptDefinitions: withToolResultDefinitions({ + ...(schema.typeScriptDefinitions ?? {}), + ...(projectedOutputPreview?.definitions ?? {}), + }), }; }); From 85e7c709c3ecf344a51359ccafb734389df196e5 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 20 May 2026 11:15:55 -0700 Subject: [PATCH 7/8] Align MCP invoke results with tool schema --- .../core/execution/src/tool-invoker.test.ts | 34 ----------- packages/core/execution/src/tool-invoker.ts | 57 ++----------------- packages/core/sdk/src/executor.ts | 49 ++++++++++++++-- .../plugins/mcp/src/sdk/elicitation.test.ts | 37 +++--------- 4 files changed, 57 insertions(+), 120 deletions(-) diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index 9cc4bc7a6..c546c8304 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -295,18 +295,6 @@ const mcpProjectionPlugin = definePlugin(() => ({ }), ), }, - { - name: "untypedStructured", - description: "Returns MCP-shaped data without a typed structured output", - inputSchema: EmptyInputSchema, - handler: () => - Effect.succeed( - ToolResult.ok({ - content: [{ type: "text", text: "fallback text" }], - structuredContent: { value: "raw" }, - }), - ), - }, ], }, ], @@ -675,28 +663,6 @@ describe("tool discovery", () => { }), ); - it.effect("keeps untyped MCP-shaped sandbox results raw", () => - Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ plugins: [mcpProjectionPlugin()] as const }), - ); - const engine = createExecutionEngine({ executor, codeExecutor }); - - const execution = yield* engine.execute("return await tools.remote.untypedStructured({});", { - onElicitation: acceptAll, - }); - - expect(execution.error).toBeUndefined(); - expect(execution.result).toEqual({ - ok: true, - data: { - content: [{ type: "text", text: "fallback text" }], - structuredContent: { value: "raw" }, - }, - }); - }), - ); - it.effect("rejects malformed discover calls inside the sandbox", () => Effect.gen(function* () { const executor = yield* makeSearchExecutor(); diff --git a/packages/core/execution/src/tool-invoker.ts b/packages/core/execution/src/tool-invoker.ts index e4c2be98e..9d45c449b 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -8,7 +8,7 @@ import type { InvokeOptions, Source, } from "@executor-js/sdk/core"; -import { isToolResult, schemaToTypeScriptPreviewWithDefs, ToolResult } from "@executor-js/sdk/core"; +import { isToolResult, ToolResult } from "@executor-js/sdk/core"; import type { SandboxToolInvoker } from "@executor-js/codemode-core"; import { ExecutionToolError } from "./errors"; @@ -26,40 +26,6 @@ const withToolResultDefinitions = ( ToolError: TOOL_ERROR_TYPESCRIPT, }); -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -const recordProperty = ( - value: Record, - key: string, -): Record | null => (isRecord(value[key]) ? value[key] : null); - -const stringArrayProperty = (value: Record, key: string): readonly string[] => - Array.isArray(value[key]) && value[key].every((item) => typeof item === "string") - ? value[key] - : []; - -const getRequiredMcpStructuredContentSchema = (schema: unknown): unknown | undefined => { - if (!isRecord(schema)) return undefined; - const properties = recordProperty(schema, "properties"); - if (!properties) return undefined; - const required = stringArrayProperty(schema, "required"); - if (!required.includes("content") || !required.includes("structuredContent")) return undefined; - if (!("content" in properties) || !("structuredContent" in properties)) return undefined; - return properties.structuredContent; -}; - -const projectMcpToolResultForSandbox = ( - result: ToolResult, - outputSchema: unknown, -): ToolResult => { - if (getRequiredMcpStructuredContentSchema(outputSchema) === undefined) return result; - if (!result.ok || !isRecord(result.data)) return result; - if (!Array.isArray(result.data.content)) return result; - if (!("structuredContent" in result.data) || result.data.structuredContent == null) return result; - return ToolResult.ok(result.data.structuredContent); -}; - const newCorrelationId = (): string => { // 8-hex-char correlation id; enough entropy to disambiguate within a // single deployment without leaking host process info. @@ -185,8 +151,7 @@ export const makeExecutorToolInvoker = ( // uniform without forcing every tiny test plugin to import // `ToolResult.ok`. if (isToolResult(result)) { - const schema = result.ok ? yield* executor.tools.schema(path) : null; - return projectMcpToolResultForSandbox(result, schema?.outputSchema); + return result; } return { ok: true, data: result }; }), @@ -581,26 +546,12 @@ export const describeTool = Effect.fn("executor.tools.describe")(function* ( // The schema's id is the tool path; name/description come from the // tool row which tools.schema() already loaded. - const projectedOutputSchema = getRequiredMcpStructuredContentSchema(schema.outputSchema); - const projectedOutputPreview = - projectedOutputSchema === undefined - ? null - : yield* Effect.promise(() => - schemaToTypeScriptPreviewWithDefs( - projectedOutputSchema, - new Map(Object.entries(schema.schemaDefinitions ?? {})), - ), - ); - return { path, name: schema.name ?? path, description: schema.description, inputTypeScript: schema.inputTypeScript, - outputTypeScript: wrapOutputTypeScript(projectedOutputPreview?.type ?? schema.outputTypeScript), - typeScriptDefinitions: withToolResultDefinitions({ - ...(schema.typeScriptDefinitions ?? {}), - ...(projectedOutputPreview?.definitions ?? {}), - }), + outputTypeScript: wrapOutputTypeScript(schema.outputTypeScript), + typeScriptDefinitions: withToolResultDefinitions(schema.typeScriptDefinitions), }; }); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 0d909e8f6..8b1cc6ef1 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -138,6 +138,7 @@ import { import { buildToolTypeScriptPreview, type ToolTypeScriptPreview } from "./schema-types"; import { collectReferencedDefinitions } from "./schema-refs"; import { assertExecutorScopePolicyTable, type ExecutorScopePolicyContext } from "./scope-policy"; +import { isToolResult, ToolResult } from "./tool-result"; import { validateHostedOutboundUrl } from "./hosted-http-client"; const MAX_ANNOTATION_GROUPS = 64; @@ -579,6 +580,40 @@ const toToolJsonSchema = ( }); }; +const isJsonRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const projectAgentOutputSchema = (schema: unknown): unknown => { + if (!isJsonRecord(schema)) return schema; + const properties = schema.properties; + if (!isJsonRecord(properties)) return schema; + const required = schema.required; + if ( + !Array.isArray(required) || + !required.includes("content") || + !required.includes("structuredContent") + ) { + return schema; + } + if (!("content" in properties) || !("structuredContent" in properties)) return schema; + return properties.structuredContent; +}; + +const isProjectedAgentOutputSchema = (schema: unknown): boolean => + projectAgentOutputSchema(schema) !== schema; + +const projectAgentOutputValue = (schema: unknown, value: unknown): unknown => { + if (!isProjectedAgentOutputSchema(schema)) return value; + if (isToolResult(value)) { + return value.ok ? ToolResult.ok(projectAgentOutputValue(schema, value.data)) : value; + } + if (!isJsonRecord(value)) return value; + if (!Array.isArray(value.content)) return value; + return "structuredContent" in value && value.structuredContent != null + ? value.structuredContent + : value; +}; + const toConfigureJsonSchema = ( schema: StaticToolSchema | Schema.Decoder | undefined, ): unknown => { @@ -3490,15 +3525,16 @@ export const createExecutor = (Object.entries(defs)); + const outputSchema = projectAgentOutputSchema(opts.rawOutput); const schemaDefinitions = collectReferencedDefinitions( - [opts.rawInput, opts.rawOutput], + [opts.rawInput, outputSchema], sourceDefsMap, ); const schemaDefsMap = new Map(Object.entries(schemaDefinitions)); const preview: ToolTypeScriptPreview = yield* Effect.promise(() => buildToolTypeScriptPreview({ inputSchema: opts.rawInput, - outputSchema: opts.rawOutput, + outputSchema, defs: schemaDefsMap, }), ).pipe( @@ -3518,7 +3554,7 @@ export const createExecutor = 0 ? schemaDefinitions : undefined, inputTypeScript: preview.inputTypeScript ?? undefined, @@ -3730,13 +3766,15 @@ export const createExecutor = { }), ); - it.effect("successful tool invocation preserves structured MCP result fields", () => + it.effect("successful tool schema describes the agent-visible structuredContent", () => Effect.gen(function* () { const server = yield* serveElicitationTestServer; const executor = yield* makeTestExecutor(server.url); @@ -207,19 +207,11 @@ describe("MCP elicitation (end-to-end)", () => { expect(schema?.outputSchema).toMatchObject({ type: "object", properties: { - content: { type: "array" }, - structuredContent: { - type: "object", - properties: { - value: { type: "string" }, - upper: { type: "string" }, - }, - }, - _meta: { type: "object" }, + value: { type: "string" }, + upper: { type: "string" }, }, - required: ["content", "structuredContent"], + required: ["value", "upper"], }); - expect(schema?.outputTypeScript).toContain("structuredContent"); expect(schema?.outputTypeScript).toContain("value: string"); const result = yield* executor.tools.invoke( @@ -230,11 +222,7 @@ describe("MCP elicitation (end-to-end)", () => { expect(result).toMatchObject({ ok: true, - data: { - content: [{ type: "text", text: "plain" }], - structuredContent: { value: "plain", upper: "PLAIN" }, - _meta: { trace: "kept" }, - }, + data: { value: "plain", upper: "PLAIN" }, }); const data = expectToolResultOkData(result); expectMatchesOutputSchema(schema?.outputSchema, data); @@ -242,7 +230,7 @@ describe("MCP elicitation (end-to-end)", () => { }), ); - it.effect("refreshSource keeps MCP outputSchema nested under structuredContent", () => + it.effect("refreshSource keeps agent-visible schema as structuredContent", () => Effect.gen(function* () { const server = yield* serveElicitationTestServer; const executor = yield* createExecutor( @@ -264,18 +252,11 @@ describe("MCP elicitation (end-to-end)", () => { expect(schema?.outputSchema).toMatchObject({ type: "object", properties: { - content: { type: "array" }, - structuredContent: { - type: "object", - properties: { - value: { type: "string" }, - upper: { type: "string" }, - }, - }, + value: { type: "string" }, + upper: { type: "string" }, }, - required: ["content", "structuredContent"], + required: ["value", "upper"], }); - expect(schema?.outputTypeScript).toContain("structuredContent"); expect(schema?.outputTypeScript).toContain("upper: string"); const result = yield* executor.tools.invoke( From 5e7f8078398a5ec1f9e925b31e9c9565602f698c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 20 May 2026 12:03:38 -0700 Subject: [PATCH 8/8] Keep MCP code mode result envelope consistent --- .../core/execution/src/tool-invoker.test.ts | 112 ------------------ packages/core/sdk/src/executor.ts | 49 +------- .../plugins/mcp/src/sdk/elicitation.test.ts | 37 ++++-- 3 files changed, 33 insertions(+), 165 deletions(-) diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index c546c8304..4271fcea7 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -4,7 +4,6 @@ import { Effect, Fiber, Schema } from "effect"; import { ElicitationResponse, FormElicitation, - type StaticToolSchema, ToolResult, createExecutor, definePlugin, @@ -35,18 +34,6 @@ const EmptyInputSchema = Schema.toStandardSchemaV1( const acceptAll = () => Effect.succeed(ElicitationResponse.make({ action: "accept" })); -const rawJsonSchema = (schema: Record): StaticToolSchema => ({ - "~standard": { - version: 1, - vendor: "executor-test", - validate: (value) => ({ value }), - jsonSchema: { - input: () => schema, - output: () => schema, - }, - }, -}); - type DescribedToolContract = { readonly outputTypeScript: string; readonly typeScriptDefinitions: Record; @@ -242,64 +229,6 @@ const structuredFailurePlugin = definePlugin(() => ({ ], })); -const mcpProjectionOutputSchema = rawJsonSchema({ - type: "object", - properties: { - content: { - type: "array", - items: { - type: "object", - properties: { - type: { const: "text" }, - text: { type: "string" }, - }, - required: ["type", "text"], - additionalProperties: true, - }, - }, - structuredContent: { - type: "object", - properties: { - value: { type: "string" }, - }, - required: ["value"], - additionalProperties: false, - }, - isError: { const: false }, - }, - required: ["content", "structuredContent"], - additionalProperties: true, -}); - -const mcpProjectionPlugin = definePlugin(() => ({ - id: "mcp-projection-test" as const, - storage: () => ({}), - staticSources: () => [ - { - id: "remote", - kind: "mcp", - name: "Remote MCP", - tools: [ - { - name: "typedStructured", - description: "Returns a normal MCP CallToolResult with structured content", - inputSchema: EmptyInputSchema, - outputSchema: mcpProjectionOutputSchema, - handler: () => - Effect.succeed( - ToolResult.ok({ - content: [{ type: "text", text: "fallback text" }], - structuredContent: { value: "projected" }, - isError: false, - _meta: { source: "test" }, - }), - ), - }, - ], - }, - ], -})); - const makeSearchExecutor = () => createExecutor(makeTestConfig({ plugins: [githubPlugin(), crmPlugin()] as const })); @@ -622,47 +551,6 @@ describe("tool discovery", () => { }), ); - it.effect("projects typed MCP structuredContent for sandbox tool calls", () => - Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ plugins: [mcpProjectionPlugin()] as const }), - ); - const engine = createExecutionEngine({ executor, codeExecutor }); - - const execution = yield* engine.execute( - [ - 'const details = await tools.describe.tool({ path: "remote.typedStructured" });', - "const result = await tools.remote.typedStructured({});", - "return {", - " outputTypeScript: details.outputTypeScript,", - " typeScriptDefinitions: details.typeScriptDefinitions,", - " result,", - "};", - ].join("\n"), - { onElicitation: acceptAll }, - ); - - expect(execution.error).toBeUndefined(); - const observed = execution.result as DescribedToolContract & { readonly result: unknown }; - expect(observed.result).toEqual({ ok: true, data: { value: "projected" } }); - expect(observed.outputTypeScript).toBe( - "{ ok: true; data: { value: string; } } | { ok: false; error: ToolError }", - ); - const diagnostics = typeCheckDescribedInvocation( - observed, - observed.result, - [ - "function readToolResult(result: ToolOutput): string {", - " if (!result.ok) return result.error.message;", - " return result.data.value;", - "}", - "readToolResult(invokedResult);", - ].join("\n"), - ); - expect(diagnostics).toEqual([]); - }), - ); - it.effect("rejects malformed discover calls inside the sandbox", () => Effect.gen(function* () { const executor = yield* makeSearchExecutor(); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 8b1cc6ef1..0d909e8f6 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -138,7 +138,6 @@ import { import { buildToolTypeScriptPreview, type ToolTypeScriptPreview } from "./schema-types"; import { collectReferencedDefinitions } from "./schema-refs"; import { assertExecutorScopePolicyTable, type ExecutorScopePolicyContext } from "./scope-policy"; -import { isToolResult, ToolResult } from "./tool-result"; import { validateHostedOutboundUrl } from "./hosted-http-client"; const MAX_ANNOTATION_GROUPS = 64; @@ -580,40 +579,6 @@ const toToolJsonSchema = ( }); }; -const isJsonRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -const projectAgentOutputSchema = (schema: unknown): unknown => { - if (!isJsonRecord(schema)) return schema; - const properties = schema.properties; - if (!isJsonRecord(properties)) return schema; - const required = schema.required; - if ( - !Array.isArray(required) || - !required.includes("content") || - !required.includes("structuredContent") - ) { - return schema; - } - if (!("content" in properties) || !("structuredContent" in properties)) return schema; - return properties.structuredContent; -}; - -const isProjectedAgentOutputSchema = (schema: unknown): boolean => - projectAgentOutputSchema(schema) !== schema; - -const projectAgentOutputValue = (schema: unknown, value: unknown): unknown => { - if (!isProjectedAgentOutputSchema(schema)) return value; - if (isToolResult(value)) { - return value.ok ? ToolResult.ok(projectAgentOutputValue(schema, value.data)) : value; - } - if (!isJsonRecord(value)) return value; - if (!Array.isArray(value.content)) return value; - return "structuredContent" in value && value.structuredContent != null - ? value.structuredContent - : value; -}; - const toConfigureJsonSchema = ( schema: StaticToolSchema | Schema.Decoder | undefined, ): unknown => { @@ -3525,16 +3490,15 @@ export const createExecutor = (Object.entries(defs)); - const outputSchema = projectAgentOutputSchema(opts.rawOutput); const schemaDefinitions = collectReferencedDefinitions( - [opts.rawInput, outputSchema], + [opts.rawInput, opts.rawOutput], sourceDefsMap, ); const schemaDefsMap = new Map(Object.entries(schemaDefinitions)); const preview: ToolTypeScriptPreview = yield* Effect.promise(() => buildToolTypeScriptPreview({ inputSchema: opts.rawInput, - outputSchema, + outputSchema: opts.rawOutput, defs: schemaDefsMap, }), ).pipe( @@ -3554,7 +3518,7 @@ export const createExecutor = 0 ? schemaDefinitions : undefined, inputTypeScript: preview.inputTypeScript ?? undefined, @@ -3766,15 +3730,13 @@ export const createExecutor = { }), ); - it.effect("successful tool schema describes the agent-visible structuredContent", () => + it.effect("successful tool invocation preserves structured MCP result fields", () => Effect.gen(function* () { const server = yield* serveElicitationTestServer; const executor = yield* makeTestExecutor(server.url); @@ -207,11 +207,19 @@ describe("MCP elicitation (end-to-end)", () => { expect(schema?.outputSchema).toMatchObject({ type: "object", properties: { - value: { type: "string" }, - upper: { type: "string" }, + content: { type: "array" }, + structuredContent: { + type: "object", + properties: { + value: { type: "string" }, + upper: { type: "string" }, + }, + }, + _meta: { type: "object" }, }, - required: ["value", "upper"], + required: ["content", "structuredContent"], }); + expect(schema?.outputTypeScript).toContain("structuredContent"); expect(schema?.outputTypeScript).toContain("value: string"); const result = yield* executor.tools.invoke( @@ -222,7 +230,11 @@ describe("MCP elicitation (end-to-end)", () => { expect(result).toMatchObject({ ok: true, - data: { value: "plain", upper: "PLAIN" }, + data: { + content: [{ type: "text", text: "plain" }], + structuredContent: { value: "plain", upper: "PLAIN" }, + _meta: { trace: "kept" }, + }, }); const data = expectToolResultOkData(result); expectMatchesOutputSchema(schema?.outputSchema, data); @@ -230,7 +242,7 @@ describe("MCP elicitation (end-to-end)", () => { }), ); - it.effect("refreshSource keeps agent-visible schema as structuredContent", () => + it.effect("refreshSource keeps MCP outputSchema nested under structuredContent", () => Effect.gen(function* () { const server = yield* serveElicitationTestServer; const executor = yield* createExecutor( @@ -252,11 +264,18 @@ describe("MCP elicitation (end-to-end)", () => { expect(schema?.outputSchema).toMatchObject({ type: "object", properties: { - value: { type: "string" }, - upper: { type: "string" }, + content: { type: "array" }, + structuredContent: { + type: "object", + properties: { + value: { type: "string" }, + upper: { type: "string" }, + }, + }, }, - required: ["value", "upper"], + required: ["content", "structuredContent"], }); + expect(schema?.outputTypeScript).toContain("structuredContent"); expect(schema?.outputTypeScript).toContain("upper: string"); const result = yield* executor.tools.invoke(