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 { AuthTemplateSlug, ConnectionName, createExecutor, FormElicitation, ElicitationResponse, IntegrationSlug, isToolResult, ToolAddress, type InvokeOptions, type Tool, } from "@executor-js/sdk"; import { makeTestConfig, memoryCredentialsPlugin, typeCheckOutputTypeScript, } from "@executor-js/sdk/testing"; import { mcpPlugin } from "./plugin"; import { makeElicitationMcpServer, serveMcpServer } from "../testing"; 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; }; const INTEG = IntegrationSlug.make("test_mcp"); const CONNECTION = ConnectionName.make("main"); const TEMPLATE = AuthTemplateSlug.make("none"); // --------------------------------------------------------------------------- // Helper — register an MCP integration pointed at the test server and create // an (open) connection so its tools are produced. // --------------------------------------------------------------------------- const makeTestExecutor = (serverUrl: string) => createExecutor( makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), ).pipe( Effect.tap((executor) => Effect.gen(function* () { yield* executor.mcp.addServer({ name: "test-mcp", endpoint: serverUrl, slug: String(INTEG), }); yield* executor.connections.create({ owner: "org", name: CONNECTION, integration: INTEG, template: TEMPLATE, value: "", }); }), ), ); const findTool = (tools: readonly Tool[], name: string): Tool => tools.find((t) => String(t.name) === name)!; // --------------------------------------------------------------------------- // Tests — everything goes through executor.execute() // --------------------------------------------------------------------------- describe("MCP elicitation (end-to-end)", () => { it.effect("form elicitation accepted → tool returns approved result", () => Effect.gen(function* () { const server = yield* serveElicitationTestServer; const executor = yield* makeTestExecutor(server.url); const tools = yield* executor.tools.list(); const gatedEcho = findTool(tools, "gated_echo"); const elicitationMessages: string[] = []; const options: InvokeOptions = { onElicitation: (ctx) => { if (isFormElicitation(ctx.request)) { elicitationMessages.push(ctx.request.message); } return Effect.succeed( ElicitationResponse.make({ action: "accept", content: { approved: true }, }), ); }, }; const result = yield* executor.execute(gatedEcho.address, { value: "hello" }, options); expect(result).toMatchObject({ ok: true, data: { content: [{ type: "text", text: "approved:hello" }] }, }); expect(elicitationMessages.length).toBeGreaterThanOrEqual(1); expect(elicitationMessages.some((m) => m.includes('Approve echo for "hello"?'))).toBe(true); }), ); it.effect("form elicitation declined → tool returns denied result", () => Effect.gen(function* () { const server = yield* serveElicitationTestServer; const executor = yield* makeTestExecutor(server.url); const tools = yield* executor.tools.list(); const gatedEcho = findTool(tools, "gated_echo"); const result = yield* executor.execute( gatedEcho.address, { value: "nope" }, { onElicitation: () => Effect.succeed(ElicitationResponse.make({ action: "decline" })), }, ); expect(result).toMatchObject({ ok: true, data: { content: [{ type: "text", text: "denied:nope" }] }, }); }), ); it.effect("tool without elicitation works normally", () => Effect.gen(function* () { const server = yield* serveElicitationTestServer; const executor = yield* makeTestExecutor(server.url); const tools = yield* executor.tools.list(); const simpleEcho = findTool(tools, "simple_echo"); const result = yield* executor.execute( simpleEcho.address, { value: "plain" }, { onElicitation: "accept-all" }, ); expect(result).toMatchObject({ ok: true, data: { content: [{ type: "text", text: "plain" }] }, }); }), ); 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 = findTool(tools, "simple_echo"); const schema = yield* executor.tools.schema(simpleEcho.address); expect(schema?.outputSchema).toMatchObject({ type: "object", properties: { content: { type: "array" }, structuredContent: {}, isError: { const: false }, _meta: { type: "object" }, }, required: ["content"], }); 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.execute( simpleEcho.address, { value: "plain" }, { onElicitation: "accept-all" }, ); const data = expectToolResultOkData(result); expectMatchesOutputSchema(schema?.outputSchema, data); expect(typeCheckOutputTypeScript(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 = findTool(tools, "structured_echo"); const schema = yield* executor.tools.schema(structuredEcho.address); 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.execute( structuredEcho.address, { value: "plain" }, { onElicitation: "accept-all" }, ); expect(result).toMatchObject({ ok: true, data: { content: [{ type: "text", text: "plain" }], structuredContent: { value: "plain", upper: "PLAIN" }, _meta: { trace: "kept" }, }, }); const data = expectToolResultOkData(result); expectMatchesOutputSchema(schema?.outputSchema, data); expect(typeCheckOutputTypeScript(schema, data)).toEqual([]); }), ); it.effect("connections.refresh keeps MCP outputSchema nested under structuredContent", () => Effect.gen(function* () { const server = yield* serveElicitationTestServer; const executor = yield* makeTestExecutor(server.url); yield* executor.connections.refresh({ owner: "org", integration: INTEG, name: CONNECTION, }); const address = ToolAddress.make("tools.test_mcp.org.main.structured_echo"); const schema = yield* executor.tools.schema(address); 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.execute( address, { value: "plain" }, { onElicitation: "accept-all" }, ); const data = expectToolResultOkData(result); expect(typeCheckOutputTypeScript(schema, data)).toEqual([]); }), ); it.effect("addServer preserves the configured display name as the integration description", () => Effect.gen(function* () { const server = yield* serveElicitationTestServer; const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); yield* executor.mcp.addServer({ name: "Gmail", endpoint: server.url, slug: "gmail", }); const integration = yield* executor.integrations.get(IntegrationSlug.make("gmail")); expect(integration?.description).toBe("Gmail"); }), ); it.effect("handler receives correct address, args, and FormElicitation schema", () => Effect.gen(function* () { const server = yield* serveElicitationTestServer; const executor = yield* makeTestExecutor(server.url); const tools = yield* executor.tools.list(); const gatedEcho = findTool(tools, "gated_echo"); let capturedAddress: string | undefined; let capturedArgs: unknown; let capturedRequest: unknown; yield* executor.execute( gatedEcho.address, { value: "ctx-test" }, { onElicitation: (ctx) => { capturedAddress = String(ctx.address); capturedArgs = ctx.args; capturedRequest = ctx.request; return Effect.succeed( ElicitationResponse.make({ action: "accept", content: { approved: true }, }), ); }, }, ); expect(capturedAddress).toBe(String(gatedEcho.address)); expect(capturedArgs).toEqual({ value: "ctx-test" }); expect(isFormElicitation(capturedRequest)).toBe(true); const form = capturedRequest as FormElicitation; expect(form.message).toContain('Approve echo for "ctx-test"?'); expect(form.requestedSchema).toEqual({ type: "object", properties: { approved: { type: "boolean", title: "Approve" }, }, required: ["approved"], }); }), ); // removed: "connection is reused across multiple tool calls" — v2 does not // cache MCP client connections across invocations (Hyperdrive request-scoped // rule; each invoke dials and closes its own connection). Session reuse is no // longer a property of the plugin, so the assertion no longer applies. });