2555 lines · 90.1 KB · TypeScript
RawDownload
import { describe, expect, it, vi } from "@effect/vitest";import { Data, Effect } from "effect";import { Client } from "@modelcontextprotocol/sdk/client/index.js";import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";import type * as Cause from "effect/Cause"; import {  ArtifactId,  FormElicitation,  type Artifact,  type ArtifactBindings,  type ArtifactSummary,} from "@executor-js/sdk";import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; import type { BindableConnection } from "./artifact-bindings";import { readArtifactsEnabled } from "./browser-approval";import { MCP_APPS_SHELL_RESOURCE_URI, artifactUrlFor } from "./create-artifact";import { createExecutorMcpServer, type ExecutorMcpServerConfig } from "./tool-server"; /** The caller's connection inventory, as `create-artifact` sees it when it *  binds an artifact's integration roles. */const connectionsPort = (available: readonly BindableConnection[]) => ({  list: (): Effect.Effect<readonly BindableConnection[]> => Effect.succeed(available),}); const conn = (integration: string, owner: "user" | "org", name: string): BindableConnection => ({  integration,  owner,  name,}); // ---------------------------------------------------------------------------// Harness// --------------------------------------------------------------------------- class TestArtifactError extends Data.TaggedError("TestArtifactError")<{  readonly message: string;}> {} const makeStubEngine = <E extends Cause.YieldableError = never>(overrides: {  executeWithPause?: ExecutionEngine<E>["executeWithPause"];  resume?: ExecutionEngine<E>["resume"];}): ExecutionEngine<E> => ({  execute: () => Effect.succeed({ result: "default" }),  executeWithPause:    overrides.executeWithPause ??    (() => Effect.succeed({ status: "completed", result: { result: "default" } })),  resume: overrides.resume ?? (() => Effect.succeed(null)),  isExecutionSettled: undefined,  getPausedExecution: () => Effect.succeed(null),  pausedExecutionCount: () => Effect.succeed(0),  hasPausedExecutions: () => Effect.succeed(false),  getDescription: Effect.succeed("test executor"),}); /** * An in-memory stand-in for `executor.artifacts` with the same observable * behavior the real owner-scoped store has: save mints an id (or overwrites in * place), get fails when nothing matches, list is newest-first without code. */const makeArtifactStore = () => {  const rows = new Map<string, Artifact>();  let seq = 0;  const calls: Array<{    title: string;    description: string | null;    code: string;    bindings: ArtifactBindings | null;  }> = [];  return {    calls,    rows,    port: {      list: (): Effect.Effect<readonly ArtifactSummary[]> =>        Effect.sync(() =>          [...rows.values()]            .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())            .map(({ code: _code, bindings: _bindings, ...summary }) => summary),        ),      get: (id: string): Effect.Effect<Artifact, TestArtifactError> =>        Effect.suspend(() => {          const row = rows.get(id);          return row            ? Effect.succeed(row)            : Effect.fail(new TestArtifactError({ message: `no artifact ${id}` }));        }),      save: (input: {        readonly id?: string;        readonly title: string;        readonly description?: string | null;        readonly code: string;        readonly bindings?: ArtifactBindings | null;        readonly preview?: string | null;      }): Effect.Effect<Artifact, TestArtifactError> =>        Effect.suspend(() => {          calls.push({            title: input.title,            description: input.description ?? null,            code: input.code,            bindings: input.bindings ?? null,          });          const existing = input.id === undefined ? undefined : rows.get(input.id);          if (input.id !== undefined && !existing) {            return Effect.fail(new TestArtifactError({ message: `no artifact ${input.id}` }));          }          seq += 1;          const artifact: Artifact = {            id: ArtifactId.make(existing?.id ?? `art_${seq}`),            owner: "user",            title: input.title,            description: input.description ?? null,            // Mirrors the real save: the preview is written on every overwrite,            // including back to null, because it interprets `code`.            preview:              input.preview === undefined || input.preview === null                ? null                : { kind: "layout", markup: input.preview },            code: input.code,            bindings: input.bindings ?? null,            createdAt: existing?.createdAt ?? new Date(seq * 1000),            updatedAt: new Date(seq * 1000),          };          rows.set(artifact.id, artifact);          return Effect.succeed(artifact);        }),    },  };}; const withClient = async <E extends Cause.YieldableError>(  engine: ExecutionEngine<E>,  capabilities: ClientCapabilities,  fn: (client: Client) => Promise<void>,  config?: Partial<ExecutorMcpServerConfig<E>>,) => {  const mcpServer = await Effect.runPromise(    createExecutorMcpServer({      engine,      loadAppShellHtml: () => Promise.resolve(SHELL_HTML),      // Artifacts are on by default and nearly every test in this file      // exercises the artifact surface; spelled out here anyway so the cases      // that assert the OFF surface (`artifactsEnabled: false`) read as the      // deliberate contrast. The default (no flag at all) is proved directly      // in the opt-out describe block.      artifactsEnabled: true,      ...config,    } as ExecutorMcpServerConfig<E>),  );  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 SHELL_HTML = "<!doctype html><html><body><div id='root'></div></body></html>"; // What a client that renders MCP Apps advertises at `initialize`. The SDK's// `ClientCapabilities` has no `extensions` field yet (pending SEP-1724), which// is exactly why ext-apps ships `getUiCapability` to read it.// oxlint-disable-next-line executor/no-double-cast -- boundary: MCP SDK ClientCapabilities predates the ext-apps `extensions` fieldconst APPS_CAPS = {  extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },} as unknown as ClientCapabilities; const NO_APPS_CAPS: ClientCapabilities = {}; const structuredOf = (result: Awaited<ReturnType<Client["callTool"]>>): Record<string, unknown> =>  (result.structuredContent ?? {}) as Record<string, unknown>; const textOf = (result: Awaited<ReturnType<Client["callTool"]>>): string =>  (result.content as Array<{ type: string; text: string }>)[0].text; const toolNames = async (client: Client): Promise<string[]> =>  (await client.listTools()).tools.map((tool) => tool.name); const COUNTER_CODE = "function App() { return <div>hi</div>; }";const UPDATED_CODE = "function App() { return <div>hi again</div>; }"; const makePausedResult = (id: string, message: string): ExecutionResult => ({  status: "paused",  execution: {    id,    elicitationContext: {      request: FormElicitation.make({ message, requestedSchema: {} }),    },  } as ExecutionResult extends { status: "paused"; execution: infer P } ? P : never,}); // ---------------------------------------------------------------------------// Capability-gated visibility// --------------------------------------------------------------------------- describe("MCP host — artifact tool visibility", () => {  it("hides the app-only tools from clients that cannot render MCP Apps", async () => {    const store = makeArtifactStore();    await withClient(      makeStubEngine({}),      NO_APPS_CAPS,      async (client) => {        const names = await toolNames(client);        // The model-facing tools are always advertised — they degrade to a        // deep link rather than disappearing.        expect(names).toContain("create-artifact");        expect(names).toContain("edit-artifact");        expect(names).toContain("list-artifacts");        expect(names).toContain("show-artifact");        // `execute-action` is only callable from inside a rendered app.        expect(names).not.toContain("execute-action");        expect(names).not.toContain("execute-action-resume");      },      { artifacts: store.port },    );  });   it("exposes the app-only tools to clients that advertise the shell mime type", async () => {    const store = makeArtifactStore();    await withClient(      makeStubEngine({}),      APPS_CAPS,      async (client) => {        const names = await toolNames(client);        expect(names).toContain("create-artifact");        expect(names).toContain("execute-action");        expect(names).toContain("execute-action-resume");      },      { artifacts: store.port },    );  });   // A cold restore (deploy, DO eviction) rebuilds the server mid-conversation  // on the same session id, and the client — already past `initialize` — never  // sends another one. Everything the rebuilt server knows about the client's  // capabilities has to come from what the host persisted.  it("keeps the app-only tools visible on a cold restore that replays no initialize", async () => {    const store = makeArtifactStore();    const mcpServer = await Effect.runPromise(      createExecutorMcpServer({        engine: makeStubEngine({}),        artifacts: store.port,        artifactsEnabled: true,        loadAppShellHtml: () => Promise.resolve(SHELL_HTML),        // What the host read back out of DO storage for this session.        restoredAppsEnabled: true,      }),    );    const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();    await mcpServer.connect(serverTransport);    // Deliberately NOT an SDK `Client`: `client.connect()` always sends    // `initialize`, which is the one thing a cold restore never replays. The    // client is mid-conversation and just keeps issuing calls against the same    // mcp-session-id, so this drives raw JSON-RPC over the transport.    const responses = new Map<number, Record<string, unknown>>();    clientTransport.onmessage = (message) => {      const envelope = message as { id?: number; result?: Record<string, unknown> };      if (typeof envelope.id === "number" && envelope.result) {        responses.set(envelope.id, envelope.result);      }    };    await clientTransport.start();    const call = async (id: number, method: string, params: Record<string, unknown>) => {      await clientTransport.send({ jsonrpc: "2.0", id, method, params });      await vi.waitFor(() => expect(responses.has(id)).toBe(true));      return responses.get(id) ?? {};    };     const listed = (await call(1, "tools/list", {})) as {      tools: Array<{ name: string }>;    };    const names = listed.tools.map((tool) => tool.name);    expect(names).toContain("execute-action");    expect(names).toContain("execute-action-resume");     // The render path is the symptom that took this to production: the same    // session rendered inline before the restore and a deep link after.    const created = (await call(2, "tools/call", {      name: "create-artifact",      arguments: { code: COUNTER_CODE, title: "Active users dashboard" },    })) as { structuredContent?: Record<string, unknown> };    expect(created.structuredContent).toEqual({ code: COUNTER_CODE, artifactId: "art_1" });    expect(created.structuredContent).not.toHaveProperty("status", "fallback_url");     await clientTransport.close();    await serverTransport.close();  });   // The restore path the `agents` runtime actually takes, and the one that  // reached production. On cold restore it replays the PERSISTED `initialize`  // REQUEST (`reinitializeServer`), which does re-establish the server's client  // capabilities — but it never replays the `notifications/initialized`  // NOTIFICATION, and `oninitialized` (the only hook that re-runs  // `syncToolAvailability`) fires solely on that notification. So the rebuilt  // server holds full apps capabilities while nothing re-reads them, and the  // replay is dispatched un-awaited, so a tool call can also land ahead of it.  //  // Both orderings are covered here: capabilities present but no hook, and a  // tool call dispatched before the replay arrives at all.  it("renders inline on a restore that replays initialize without the initialized notification", async () => {    const store = makeArtifactStore();    const mcpServer = await Effect.runPromise(      createExecutorMcpServer({        engine: makeStubEngine({}),        artifacts: store.port,        artifactsEnabled: true,        loadAppShellHtml: () => Promise.resolve(SHELL_HTML),        // Nothing persisted: the capability must come from the replay alone, so        // a passing assertion cannot be coming from the seed.        restoredAppsEnabled: false,        artifactUrl: artifactUrlFor("https://executor.test"),      }),    );    const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();    await mcpServer.connect(serverTransport);     const responses = new Map<string | number, Record<string, unknown>>();    clientTransport.onmessage = (message) => {      const envelope = message as { id?: string | number; result?: Record<string, unknown> };      if (envelope.id !== undefined && envelope.result) responses.set(envelope.id, envelope.result);    };    await clientTransport.start();     // Exactly what `agents/mcp` replays: the initialize request under its    // restore sentinel id, and no `notifications/initialized` after it.    await clientTransport.send({      jsonrpc: "2.0",      id: "__worker_transport_restore__",      method: "initialize",      params: {        protocolVersion: "2025-06-18",        capabilities: APPS_CAPS,        clientInfo: { name: "ClaudeAI", version: "1.0.0" },      },    } as never);    await vi.waitFor(() =>      expect(mcpServer.server.getClientCapabilities()).toMatchObject(APPS_CAPS),    );     const call = async (id: number, method: string, params: Record<string, unknown>) => {      await clientTransport.send({ jsonrpc: "2.0", id, method, params });      await vi.waitFor(() => expect(responses.has(id)).toBe(true));      return responses.get(id) ?? {};    };     const created = (await call(1, "tools/call", {      name: "create-artifact",      arguments: { code: COUNTER_CODE, title: "Active users dashboard" },    })) as { structuredContent?: Record<string, unknown> };    expect(created.structuredContent).toEqual({      code: COUNTER_CODE,      artifactId: "art_1",      url: "https://executor.test/artifacts/art_1",    });    expect(created.structuredContent).not.toHaveProperty("status", "fallback_url");     // The widget is useless if it cannot call back in, so visibility has to    // reconcile on the same restore.    const listed = (await call(2, "tools/list", {})) as { tools: Array<{ name: string }> };    const names = listed.tools.map((tool) => tool.name);    expect(names).toContain("execute-action");    expect(names).toContain("execute-action-resume");     await clientTransport.close();    await serverTransport.close();  });   it("still falls back to a deep link when the restored session had no apps support", async () => {    const store = makeArtifactStore();    await withClient(      makeStubEngine({}),      NO_APPS_CAPS,      async (client) => {        const result = await client.callTool({          name: "create-artifact",          arguments: { code: COUNTER_CODE, title: "Active users dashboard" },        });        expect(structuredOf(result)).toEqual({          status: "fallback_url",          url: "https://executor.test/artifacts/art_1",          artifactId: "art_1",        });      },      {        artifacts: store.port,        restoredAppsEnabled: false,        artifactUrl: artifactUrlFor("https://executor.test"),      },    );  });   // The restored value seeds the session; it must never outrank the client in  // front of you. A client that reconnects without apps support gets the  // fallback, restored bit or not.  it("lets a real initialize overrule the restored value in both directions", async () => {    const store = makeArtifactStore();    await withClient(      makeStubEngine({}),      NO_APPS_CAPS,      async (client) => {        const names = await toolNames(client);        expect(names).not.toContain("execute-action");        const result = await client.callTool({          name: "create-artifact",          arguments: { code: COUNTER_CODE, title: "Active users dashboard" },        });        expect(structuredOf(result)).toMatchObject({ status: "fallback_url" });      },      {        artifacts: store.port,        restoredAppsEnabled: true,        artifactUrl: artifactUrlFor("https://executor.test"),      },    );  });   it("reports the negotiated value to the host so the next restore can seed itself", async () => {    const store = makeArtifactStore();    const seen: boolean[] = [];    await withClient(      makeStubEngine({}),      APPS_CAPS,      async (client) => {        await toolNames(client);      },      {        artifacts: store.port,        restoredAppsEnabled: false,        onAppsEnabledChange: (appsEnabled) => Effect.sync(() => void seen.push(appsEnabled)),      },    );    expect(seen).toEqual([true]);  });   it("does not re-persist when initialize confirms the restored value", async () => {    const store = makeArtifactStore();    const seen: boolean[] = [];    await withClient(      makeStubEngine({}),      APPS_CAPS,      async (client) => {        await toolNames(client);      },      {        artifacts: store.port,        restoredAppsEnabled: true,        onAppsEnabledChange: (appsEnabled) => Effect.sync(() => void seen.push(appsEnabled)),      },    );    expect(seen).toEqual([]);  });   it("registers no ui tools at all when no shell loader is configured", async () => {    const store = makeArtifactStore();    const mcpServer = await Effect.runPromise(      createExecutorMcpServer({        engine: makeStubEngine({}),        artifacts: store.port,        // Opted in, so the only thing withholding the surface is the missing        // shell loader — otherwise this would pass on the connection default.        artifactsEnabled: true,      }),    );    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);    const names = (await client.listTools()).tools.map((tool) => tool.name);    expect(names).toContain("execute");    expect(names).not.toContain("create-artifact");    expect(names).not.toContain("list-artifacts");    await clientTransport.close();    await serverTransport.close();  });   // A session that opted out (`?artifacts=false`) gets no artifact surface at  // all. The host here is fully artifact-capable — shell loader, store, the  // apps client capability — so what these assert is the opt-out itself, not  // a host that never had artifacts.  it("registers no artifact tools on an opted-out connection", async () => {    const store = makeArtifactStore();    await withClient(      makeStubEngine({}),      APPS_CAPS,      async (client) => {        const names = await toolNames(client);        expect(names).not.toContain("create-artifact");        expect(names).not.toContain("edit-artifact");        expect(names).not.toContain("list-artifacts");        expect(names).not.toContain("show-artifact");        expect(names).not.toContain("execute-action");        expect(names).not.toContain("execute-action-resume");        // The rest of the surface is untouched.

Lines 1–500 of 2555.