376 lines · 14.1 KB · TypeScript
RawDownload
import { beforeAll, describe, expect, it } from "@effect/vitest";import { Effect, Predicate } from "effect";import { HttpServerResponse } from "effect/unstable/http";// oxlint-disable-next-line executor/no-vitest-import -- boundary: fake-clock coverage for the active-work deadlineimport { afterEach, vi } from "vitest"; import {  ProtocolError,  SdkErrorCode,  SdkHttpError,  type OAuthClientProvider,  type ClientContext,} from "@modelcontextprotocol/client";import { ElicitationResponse } from "@executor-js/sdk";import { serveTestHttpApp } from "@executor-js/sdk/testing"; import { loadMcpClientSdk } from "./client-module";import { createMcpConnector, type McpConnection, type McpConnector } from "./connection"; // Classification consults the lazily-loaded client module (client-module.ts);// in prod every SDK error is preceded by a connect, which loads it. Mirror// that precondition here — these tests construct SDK errors directly.beforeAll(() => loadMcpClientSdk());import { McpInvocationError, McpOAuthReauthorizationRequired } from "./errors";import { invokeMcpTool, makeActiveWorkDeadline, MCP_ACTIVE_WORK_TIMEOUT_MS } from "./invoke"; const acceptAll = () => Effect.succeed(ElicitationResponse.make({ action: "accept" })); const rejectingConnector = (cause: unknown): McpConnector =>  Effect.succeed({    // oxlint-disable-next-line executor/no-double-cast -- boundary: minimal fake MCP client implements only the methods invokeMcpTool calls    client: {      setRequestHandler: () => undefined,      // oxlint-disable-next-line executor/no-promise-reject -- boundary: fake MCP client rejects to exercise invocation error wrapping      callTool: () => Promise.reject(cause),    } as unknown as McpConnection["client"],    close: () => Promise.resolve(),  }); const reauthorizationProvider: OAuthClientProvider = {  get redirectUrl() {    return "http://localhost/oauth/callback";  },  get clientMetadata() {    return {      redirect_uris: ["http://localhost/oauth/callback"],      grant_types: ["authorization_code", "refresh_token"] as string[],      response_types: ["code"] as string[],      token_endpoint_auth_method: "none" as const,      client_name: "Executor",    };  },  clientInformation: () => ({ client_id: "test-client" }),  saveClientInformation: () => undefined,  tokens: () => ({ access_token: "expired-token", token_type: "Bearer" }),  saveTokens: () => undefined,  redirectToAuthorization: async () => {    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK OAuthClientProvider callback can only signal reauthorization by throwing    throw new McpOAuthReauthorizationRequired({ message: "reauthorization required" });  },  saveCodeVerifier: () => undefined,  codeVerifier: () => "unused",  saveDiscoveryState: () => undefined,  discoveryState: () => undefined,}; const serveReauthorizationChallengeServer = () =>  serveTestHttpApp((request) =>    Effect.sync(() => {      const origin = `http://${request.headers.host ?? "127.0.0.1"}`;      const requestUrl = new URL(request.url, origin);       if (requestUrl.pathname.startsWith("/.well-known/oauth-protected-resource")) {        return HttpServerResponse.jsonUnsafe({          resource: `${origin}/mcp`,          authorization_servers: [origin],          bearer_methods_supported: ["header"],          scopes_supported: ["read"],        });      }       if (        requestUrl.pathname === "/.well-known/oauth-authorization-server" ||        requestUrl.pathname === "/.well-known/openid-configuration"      ) {        return HttpServerResponse.jsonUnsafe({          issuer: origin,          authorization_endpoint: `${origin}/authorize`,          token_endpoint: `${origin}/token`,          response_types_supported: ["code"],          grant_types_supported: ["authorization_code", "refresh_token"],          code_challenge_methods_supported: ["S256"],          token_endpoint_auth_methods_supported: ["none"],          scopes_supported: ["read"],        });      }       if (requestUrl.pathname === "/mcp" && request.method === "GET") {        return HttpServerResponse.text("SSE disabled", { status: 405 });      }       return HttpServerResponse.jsonUnsafe(        { error: "invalid_token" },        {          status: 401,          headers: {            "www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`,          },        },      );    }),  ); // The status-extraction cases share one shape: dial a connector that rejects// with `cause`, then assert the surfaced failure is a sanitized// McpInvocationError carrying the expected HTTP status (or none) and never the// upstream body. Each `cause` embeds a "do-not-leak" sentinel.const invocationRejectionCases = [  {    name: "wraps callTool rejection with a stable message and status",    toolId: "blocked",    transport: "streamable-http",    cause: new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, "token=do-not-leak", {      status: 401,    }),    expectedStatus: 401 as number | undefined,    expectedProtocolError: undefined as { code: number; message: string } | undefined,    expectedSdkFailure: { name: "SdkHttpError", code: SdkErrorCode.ClientHttpAuthentication } as {      name: string;      code?: string | number;    },  },  {    // The JSON-RPC error is the server's own answer to the call: its code is    // not an HTTP status, and its message is kept (structurally, beside the    // sanitized invocation message) so the plugin can hand it to the caller.    name: "does not treat MCP protocol error codes as HTTP statuses",    toolId: "protocol_error",    transport: "streamable-http",    cause: new ProtocolError(401, "application-level do-not-leak"),    expectedStatus: undefined,    expectedProtocolError: { code: 401, message: "application-level do-not-leak" },    expectedSdkFailure: { name: "ProtocolError", code: 401 },  },  {    name: "does not invent a status from non-HTTP rejection shapes",    toolId: "network",    transport: "streamable-http",    cause: { code: -1, message: "socket said do-not-leak" },    expectedStatus: undefined,    expectedProtocolError: undefined,    expectedSdkFailure: { name: "object", code: -1 },  },  {    name: "extracts the status from the SDK SSE POST error prefix without leaking the body",    toolId: "sse_blocked",    transport: "sse",    cause: {      message: "Error POSTing to endpoint (HTTP 403): do-not-leak: upstream auth challenge",    },    expectedStatus: 403,    expectedProtocolError: undefined,    expectedSdkFailure: { name: "object" },  },]; describe("invokeMcpTool", () => {  afterEach(() => vi.useRealTimers());   it("pauses the active-work deadline across overlapping elicitations", () => {    vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });    const deadline = makeActiveWorkDeadline(100);     vi.advanceTimersByTime(40);    deadline.pause();    deadline.pause();    vi.advanceTimersByTime(1_000);    expect(deadline.signal.aborted).toBe(false);     deadline.resume();    vi.advanceTimersByTime(100);    expect(deadline.signal.aborted).toBe(false);     deadline.resume();    vi.advanceTimersByTime(59);    expect(deadline.signal.aborted).toBe(false);    vi.advanceTimersByTime(1);    expect(deadline.signal.aborted).toBe(true);    deadline.dispose();  });   it("uses the active signal for a tool call and excludes elicitation from its deadline", async () => {    vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });     let requestHandler:      | ((request: { params: unknown }, context: ClientContext) => Promise<unknown>)      | undefined;    let callOptions: { signal: AbortSignal; timeout: number } | undefined;    let finishElicitation: (() => void) | undefined;    let resolveElicitationStarted: (() => void) | undefined;    const elicitationStarted = new Promise<void>((resolve) => {      resolveElicitationStarted = resolve;    });    const connectionAbort = new AbortController();     const client = {      setRequestHandler: (_method: string, handler: unknown) => {        requestHandler = handler as typeof requestHandler;      },      callTool: async (_request: unknown, options: { signal: AbortSignal; timeout: number }) => {        callOptions = options;        await requestHandler!(          {            params: { mode: "form", message: "Approve?", requestedSchema: {} },          },          { mcpReq: { signal: connectionAbort.signal } } as ClientContext,        );        // oxlint-disable-next-line executor/no-promise-reject -- boundary: fake MCP client models SDK abort rejection        return await new Promise<never>((_resolve, reject) => {          // oxlint-disable-next-line executor/no-promise-reject -- boundary: fake MCP client models SDK abort rejection          options.signal.addEventListener("abort", () => reject(options.signal.reason), {            once: true,          });        });      },    };     const invocation = Effect.runPromise(      invokeMcpTool({        toolId: "slow",        toolName: "slow",        args: {},        transport: "streamable-http",        connector: Effect.succeed({          // oxlint-disable-next-line executor/no-double-cast -- boundary: minimal fake MCP client implements only invokeMcpTool's surface          client: client as unknown as McpConnection["client"],          close: () => Promise.resolve(),        }),        elicit: () =>          Effect.callback((resume) => {            resolveElicitationStarted!();            finishElicitation = () =>              resume(Effect.succeed(ElicitationResponse.make({ action: "accept" })));          }),      }),    ).then(      () => "completed" as const,      () => "failed" as const,    );     await elicitationStarted;    expect(callOptions?.timeout).toBeGreaterThan(MCP_ACTIVE_WORK_TIMEOUT_MS);    vi.advanceTimersByTime(MCP_ACTIVE_WORK_TIMEOUT_MS);    expect(callOptions?.signal.aborted).toBe(false);     finishElicitation!();    await Promise.resolve();    await Promise.resolve();    vi.advanceTimersByTime(MCP_ACTIVE_WORK_TIMEOUT_MS);    expect(callOptions?.signal.aborted).toBe(true);    expect(await invocation).toBe("failed");  });   it("interrupts an elicitation when the MCP connection closes", async () => {    let requestHandler:      | ((request: { params: unknown }, context: ClientContext) => Promise<unknown>)      | undefined;    const connectionAbort = new AbortController();    const client = {      setRequestHandler: (_method: string, handler: unknown) => {        requestHandler = handler as typeof requestHandler;      },      callTool: async () => {        await requestHandler!(          {            params: { mode: "form", message: "Approve?", requestedSchema: {} },          },          { mcpReq: { signal: connectionAbort.signal } } as ClientContext,        );        return { content: [] };      },    };     const invocation = Effect.runPromise(      invokeMcpTool({        toolId: "closed",        toolName: "closed",        args: {},        transport: "streamable-http",        connector: Effect.succeed({          // oxlint-disable-next-line executor/no-double-cast -- boundary: minimal fake MCP client implements only invokeMcpTool's surface          client: client as unknown as McpConnection["client"],          close: () => Promise.resolve(),        }),        elicit: () => Effect.callback(() => undefined),      }),    ).then(      () => "completed" as const,      () => "failed" as const,    );     await Promise.resolve();    connectionAbort.abort();    expect(await invocation).toBe("failed");  });   for (const testCase of invocationRejectionCases) {    it.effect(testCase.name, () =>      Effect.gen(function* () {        const error = yield* invokeMcpTool({          toolId: testCase.toolId,          toolName: testCase.toolId,          args: {},          transport: testCase.transport,          connector: rejectingConnector(testCase.cause),          elicit: acceptAll,        }).pipe(Effect.flip);         expect(Predicate.isTagged(error, "McpInvocationError")).toBe(true);        const invocation = error as McpInvocationError;        expect(invocation.toolName).toBe(testCase.toolId);        expect(invocation.message.startsWith(`MCP tool call failed for ${testCase.toolId} (`)).toBe(          true,        );        expect(invocation.sdkFailure).toEqual(testCase.expectedSdkFailure);        expect(invocation).toMatchObject({          message: expect.not.stringContaining("do-not-leak"),        });        expect(invocation.status).toBe(testCase.expectedStatus);        expect("cause" in invocation).toBe(false);        expect(invocation.protocolError).toEqual(testCase.expectedProtocolError);      }),    );  }   it.effect("marks OAuth reauthorization rejections without leaking SDK details", () =>    Effect.gen(function* () {      const error = yield* invokeMcpTool({        toolId: "oauth_scope",        toolName: "oauth_scope",        args: {},        transport: "streamable-http",        connector: rejectingConnector(          new McpOAuthReauthorizationRequired({ message: "redirect to do-not-leak" }),        ),        elicit: acceptAll,      }).pipe(Effect.flip);       expect(Predicate.isTagged(error, "McpOAuthReauthorizationRequired")).toBe(true);      expect(error).toMatchObject({ message: expect.not.stringContaining("do-not-leak") });      expect("cause" in error).toBe(false);    }),  );   it.effect("preserves OAuth reauthorization required during auto connection setup", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveReauthorizationChallengeServer();        const error = yield* invokeMcpTool({          toolId: "oauth_scope",          toolName: "oauth_scope",          args: {},          transport: "auto",          connector: createMcpConnector({            transport: "remote",            endpoint: server.url("/mcp"),            authProvider: reauthorizationProvider,          }),          elicit: acceptAll,        }).pipe(Effect.flip);         expect(Predicate.isTagged(error, "McpOAuthReauthorizationRequired")).toBe(true);      }),    ),  );});