1957 lines · 65.0 KB · TypeScript
RawDownload
import { describe, expect, it } from "@effect/vitest";import { Data, Deferred, Effect } from "effect";import type * as Tracer from "effect/Tracer";import { Client } from "@modelcontextprotocol/sdk/client/index.js";import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";import type * as Cause from "effect/Cause"; import {  ElicitationId,  FormElicitation,  ToolAddress,  ToolResult,  UrlElicitation,} from "@executor-js/sdk";import type { ToolFileValue } from "@executor-js/sdk";import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; import {  createExecutorMcpServer,  formatMcpExecutionOutcome,  type ExecutorMcpServerConfig,} from "./tool-server"; // ---------------------------------------------------------------------------// Helpers// --------------------------------------------------------------------------- class TestExecutionError extends Data.TaggedError("TestExecutionError")<{  readonly message: string;}> {} const expectString: (value: unknown) => asserts value is string = (value) => {  expect(typeof value).toBe("string");}; const expectDefined: <T>(value: T) => asserts value is NonNullable<T> = (value) => {  expect(value).toBeDefined();}; const makeStubEngine = <E extends Cause.YieldableError = never>(overrides: {  execute?: ExecutionEngine<E>["execute"];  executeWithPause?: ExecutionEngine<E>["executeWithPause"];  resume?: ExecutionEngine<E>["resume"];  isExecutionSettled?: ExecutionEngine<E>["isExecutionSettled"];  description?: string;}): ExecutionEngine<E> => ({  execute: overrides.execute ?? (() => Effect.succeed({ result: "default" })),  executeWithPause:    overrides.executeWithPause ??    (() => Effect.succeed({ status: "completed", result: { result: "default" } })),  resume: overrides.resume ?? (() => Effect.succeed(null)),  isExecutionSettled: overrides.isExecutionSettled,  getPausedExecution: () => Effect.succeed(null),  pausedExecutionCount: () => Effect.succeed(0),  hasPausedExecutions: () => Effect.succeed(false),  getDescription: Effect.succeed(overrides.description ?? "test executor"),}); type TestServerConfig<E extends Cause.YieldableError> = Pick<  ExecutorMcpServerConfig<E>,  | "debug"  | "elicitationMode"  | "browserApprovalStore"  | "pausedExecutionHooks"  | "pausedExecutionLeaseMs"  | "resumeFallback">; /** Connect a real MCP Client to our executor MCP server over in-memory transports. */const withClient = async <E extends Cause.YieldableError>(  engine: ExecutionEngine<E>,  capabilities: ClientCapabilities,  fn: (client: Client) => Promise<void>,  config?: TestServerConfig<E> & { readonly tracer?: Tracer.Tracer },) => {  const { tracer, ...serverConfig } = config ?? {};  const create = createExecutorMcpServer({ engine, ...serverConfig });  const mcpServer = await Effect.runPromise(tracer ? Effect.withTracer(create, tracer) : create);  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();  const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities });  await mcpServer.connect(serverTransport);  await client.connect(clientTransport);  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper must close MCP transports after async client assertions  try {    await fn(client);  } finally {    await clientTransport.close();    await serverTransport.close();  }}; const withNativeClient = async <E extends Cause.YieldableError>(  engine: ExecutionEngine<E>,  capabilities: ClientCapabilities,  fn: (client: Client) => Promise<void>,) => withClient(engine, capabilities, fn, { elicitationMode: { mode: "native" } }); type RecordedSpan = {  readonly name: string;  readonly attributes: ReadonlyMap<string, unknown>;  ended: boolean;}; /** A tracer that records every span + its attributes and end state. */const makeRecordingTracer = (): { tracer: Tracer.Tracer; spans: RecordedSpan[] } => {  const spans: RecordedSpan[] = [];  const tracer: Tracer.Tracer = {    span: (options) => {      const attributes = new Map<string, unknown>();      const recorded: RecordedSpan = { name: options.name, attributes, ended: false };      spans.push(recorded);      let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime };      return {        _tag: "Span",        name: options.name,        spanId: `span-${spans.length}`,        traceId: "trace-1",        parent: options.parent,        annotations: options.annotations,        get status() {          return status;        },        attributes,        links: options.links,        sampled: options.sampled,        kind: options.kind,        end: (endTime, exit) => {          recorded.ended = true;          status = { _tag: "Ended", startTime: options.startTime, endTime, exit };        },        attribute: (key, value) => {          attributes.set(key, value);        },        event: () => undefined,        addLinks: () => undefined,      };    },  };  return { tracer, spans };}; /** withClient, but with a recording tracer installed for the whole server. */const withTracedClient = async <E extends Cause.YieldableError>(  engine: ExecutionEngine<E>,  fn: (client: Client, spans: RecordedSpan[]) => Promise<void>,  config?: TestServerConfig<E>,) => {  const { tracer, spans } = makeRecordingTracer();  await withClient(engine, NO_CAPS, (client) => fn(client, spans), { ...config, tracer });}; const ELICITATION_CAPS: ClientCapabilities = {  elicitation: { form: {}, url: {} },};const FORM_ONLY_CAPS: ClientCapabilities = { elicitation: { form: {} } };const NO_CAPS: ClientCapabilities = {}; /** Extract the first text content from a callTool result. */const textOf = (result: Awaited<ReturnType<Client["callTool"]>>): string =>  (result.content as Array<{ type: string; text: string }>)[0].text; const STUB_TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.t");const TEST_IMAGE_MIME_TYPE = "image/png";const TEST_IMAGE_PNG_BASE64 =  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8N1wwAAAABJRU5ErkJggg=="; /** Build a stub paused ExecutionResult with the given id and elicitation request. */const makePausedResult = (  id: string,  request: FormElicitation | UrlElicitation,): ExecutionResult => ({  status: "paused",  execution: {    id,    elicitationContext: { address: STUB_TOOL_ADDRESS, args: {}, request },  },}); const toolFile = (input: {  readonly name?: string;  readonly mimeType: string;  readonly data: string;  readonly byteLength: number;}): ToolFileValue => ({  _tag: "ToolFile",  ...(input.name ? { name: input.name } : {}),  mimeType: input.mimeType,  encoding: "base64",  data: input.data,  byteLength: input.byteLength,}); /** Build an engine whose execute triggers one elicitation and returns the handler's result. */const makeElicitingEngine = (  request: FormElicitation | UrlElicitation,  formatResult: (response: { action: string; content?: Record<string, unknown> }) => unknown = (    r,  ) => r.action,): ExecutionEngine =>  makeStubEngine({    execute: (_code, { onElicitation }) =>      Effect.gen(function* () {        const response = yield* onElicitation({          address: STUB_TOOL_ADDRESS,          args: {},          request,        });        return { result: formatResult(response) };      }),  }); // ---------------------------------------------------------------------------// Explicit native elicitation mode// --------------------------------------------------------------------------- describe("MCP host server — native elicitation mode", () => {  it("execute tool calls engine.execute and returns result", async () => {    const engine = makeStubEngine({      execute: (code) => Effect.succeed({ result: `ran: ${code}` }),    });     await withNativeClient(engine, ELICITATION_CAPS, async (client) => {      const result = await client.callTool({        name: "execute",        arguments: { code: "1+1" },      });      expect(result.content).toEqual([{ type: "text", text: "ran: 1+1" }]);      expect(result.isError).toBeFalsy();    });  });   it("execute tool renders emitted file image output as MCP images", async () => {    const engine = makeStubEngine({      execute: () =>        Effect.succeed({          result: { keptReturn: true },          output: [            {              type: "file",              file: toolFile({                name: "photo.png",                mimeType: "image/png",                data: "iVBORw0KGgo=",                byteLength: 8,              }),            },          ],        }),    });     await withNativeClient(engine, ELICITATION_CAPS, async (client) => {      const result = await client.callTool({        name: "execute",        arguments: { code: "emit(attachment.data);" },      });       const content = result.content as Array<Record<string, unknown>>;      expect(content[0]).toMatchObject({        type: "text",        text: "File output: photo.png (image/png, 8 bytes)",      });      expect(content[1]).toMatchObject({        type: "image",        data: "iVBORw0KGgo=",        mimeType: "image/png",      });      expect(result.structuredContent).toMatchObject({        status: "completed",        result: { keptReturn: true },      });      expect(result.isError).toBeFalsy();    });  });   it("execute tool keeps the returned value in content when output was also emitted", async () => {    const engine = makeStubEngine({      execute: () =>        Effect.succeed({          result: { subject: "Flight receipt", total: 42 },          output: [            {              type: "content",              content: {                type: "text",                text: "Flight receipt",              },            },          ],        }),    });     await withNativeClient(engine, ELICITATION_CAPS, async (client) => {      const result = await client.callTool({        name: "execute",        arguments: { code: "emit(subject); return receipt;" },      });       const content = result.content as Array<Record<string, unknown>>;      expect(content).toHaveLength(2);      expect(content[0]).toMatchObject({ type: "text", text: "Flight receipt" });      expect(content[1]).toMatchObject({        type: "text",        text: JSON.stringify({ subject: "Flight receipt", total: 42 }, null, 2),      });      expect(result.structuredContent).toMatchObject({        status: "completed",        result: { subject: "Flight receipt", total: 42 },      });      expect(result.isError).toBeFalsy();    });  });   it("execute tool renders emitted MCP image content as MCP images", async () => {    const engine = makeStubEngine({      execute: () =>        Effect.succeed({          result: null,          output: [            {              type: "content",              content: {                type: "text",                text: "Deterministic image fixture",              },            },            {              type: "content",              content: {                type: "image",                data: TEST_IMAGE_PNG_BASE64,                mimeType: TEST_IMAGE_MIME_TYPE,              },            },          ],        }),    });     await withNativeClient(engine, ELICITATION_CAPS, async (client) => {      const result = await client.callTool({        name: "execute",        arguments: {          code: "emit(result.content[1]);",        },      });       expect(result.content).toEqual([        {          type: "text",          text: "Deterministic image fixture",        },        {          type: "image",          data: TEST_IMAGE_PNG_BASE64,          mimeType: TEST_IMAGE_MIME_TYPE,        },      ]);      expect(result.structuredContent).toMatchObject({        status: "completed",        result: null,      });      expect(result.isError).toBeFalsy();    });  });   it("execute tool renders explicit upstream MCP content output unchanged", async () => {    const engine = makeStubEngine({      execute: () =>        Effect.succeed({          result: { forwarded: true },          output: [            {              type: "content",              content: {                type: "text",                text: "forwarded text",              },            },            {              type: "content",              content: {                type: "image",                data: TEST_IMAGE_PNG_BASE64,                mimeType: TEST_IMAGE_MIME_TYPE,              },            },            {              type: "content",              content: {                type: "audio",                data: "SUQz",                mimeType: "audio/mpeg",              },            },            {              type: "content",              content: {                type: "resource",                resource: {                  uri: "executor-file:///report.pdf",                  mimeType: "application/pdf",                  blob: "JVBERg==",                },              },            },            {              type: "content",              content: {                type: "resource_link",                uri: "executor-file:///remote.pdf",                name: "remote.pdf",                mimeType: "application/pdf",              },            },          ],        }),    });     await withNativeClient(engine, ELICITATION_CAPS, async (client) => {      const result = await client.callTool({        name: "execute",        arguments: {          code: "for (const block of result.data.content) emit(block);",        },      });       expect(result.content).toEqual([        {          type: "text",          text: "forwarded text",        },        {          type: "image",          data: TEST_IMAGE_PNG_BASE64,          mimeType: TEST_IMAGE_MIME_TYPE,        },        {          type: "audio",          data: "SUQz",          mimeType: "audio/mpeg",        },        {          type: "resource",          resource: {            uri: "executor-file:///report.pdf",            mimeType: "application/pdf",            blob: "JVBERg==",          },        },        {          type: "resource_link",          uri: "executor-file:///remote.pdf",          name: "remote.pdf",          mimeType: "application/pdf",        },        {          type: "text",          text: JSON.stringify({ forwarded: true }, null, 2),        },      ]);      expect(result.structuredContent).toMatchObject({        status: "completed",        result: { forwarded: true },      });      expect(result.isError).toBeFalsy();    });  });   it("execute tool does not render returned ToolFile values as files", async () => {    const engine = makeStubEngine({      execute: () =>        Effect.succeed({          result: ToolResult.ok(            toolFile({              name: "photo.png",              mimeType: "image/png",              data: "iVBORw0KGgo=",              byteLength: 8,            }),          ),        }),    });     await withNativeClient(engine, ELICITATION_CAPS, async (client) => {      const result = await client.callTool({        name: "execute",        arguments: { code: "return await tools.gmail.org.main.getAttachment({});" },      });       const content = result.content as Array<Record<string, unknown>>;      expect(content).toHaveLength(1);      expect(String(content[0]?.text ?? "")).toContain('"_tag": "ToolFile"');      expect(String(content[0]?.text ?? "")).toContain("iVBORw0KGgo=");      expect(result.structuredContent).toMatchObject({        status: "completed",      });      expect(result.isError).toBeFalsy();    });  });

Lines 1–500 of 1957.