344 lines · 11.6 KB · TypeScript
RawDownload
import { describe, expect, it } from "@effect/vitest";import { Effect, Predicate, Schema, Semaphore } from "effect";import type { JsonSchemaType } from "@modelcontextprotocol/client";import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker"; 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 elicitationServerSemaphore = Semaphore.makeUnsafe(1);const serveElicitationTestServer = Effect.acquireRelease(elicitationServerSemaphore.take(1), () =>  elicitationServerSemaphore.release(1).pipe(Effect.asVoid),).pipe(Effect.flatMap(() => 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) =>  Effect.acquireRelease(    createExecutor(makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const })),    (executor) => executor.close().pipe(Effect.ignore),  ).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("reuses the session while installing each invoke's elicitation handler", () =>    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 acceptedMessages: string[] = [];      const declinedMessages: string[] = [];      let capturedAddress: string | undefined;      let capturedArgs: unknown;      let capturedRequest: unknown;       const options: InvokeOptions = {        onElicitation: (ctx) => {          capturedAddress = String(ctx.address);          capturedArgs = ctx.args;          capturedRequest = ctx.request;          if (isFormElicitation(ctx.request)) {            acceptedMessages.push(ctx.request.message);          }          return Effect.succeed(            ElicitationResponse.make({              action: "accept",              content: { approved: true },            }),          );        },      };       yield* server.clearRequests;      const accepted = yield* executor.execute(gatedEcho.address, { value: "hello" }, options);      const declined = yield* executor.execute(        gatedEcho.address,        { value: "nope" },        {          onElicitation: (ctx) => {            if (isFormElicitation(ctx.request)) {              declinedMessages.push(ctx.request.message);            }            return Effect.succeed(ElicitationResponse.make({ action: "decline" }));          },        },      );      yield* executor.close();       expect(accepted).toMatchObject({        ok: true,        data: { content: [{ type: "text", text: "approved:hello" }] },      });      expect(declined).toMatchObject({        ok: true,        data: { content: [{ type: "text", text: "denied:nope" }] },      });      expect(acceptedMessages).toEqual(['Approve echo for "hello"?']);      expect(declinedMessages).toEqual(['Approve echo for "nope"?']);      expect(capturedAddress).toBe(String(gatedEcho.address));      expect(capturedArgs).toEqual({ value: "hello" });      expect(isFormElicitation(capturedRequest)).toBe(true);      const form = capturedRequest as FormElicitation;      expect(form.message).toContain('Approve echo for "hello"?');      expect(form.requestedSchema).toEqual({        type: "object",        properties: {          approved: { type: "boolean", title: "Approve" },        },        required: ["approved"],      });      const sessionIds = new Set(        (yield* server.requests)          .map((request) => request.sessionId)          .filter(Predicate.isNotUndefined),      );      expect(sessionIds.size).toBe(1);    }),  );   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?: 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");    }),  );});