|
| 1 | +// Cross-target: downstream MCP session continuity — the "an agent can use a |
| 2 | +// stateful MCP server" promise. Servers like Render's MCP key state by |
| 3 | +// Mcp-Session-Id (select_workspace, then every later call reads the |
| 4 | +// selection). If executor dials a fresh downstream connection per tool call, |
| 5 | +// every call lands in a brand-new session: select_workspace succeeds, and the |
| 6 | +// very next call reports no workspace selected. This scenario drives that |
| 7 | +// journey against a session-stateful MCP fixture and asserts state set by one |
| 8 | +// tool call is visible to the next call in the same execution. |
| 9 | +import { randomBytes } from "node:crypto"; |
| 10 | + |
| 11 | +import { expect } from "@effect/vitest"; |
| 12 | +import { Effect, Predicate } from "effect"; |
| 13 | +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 14 | +import { composePluginApi } from "@executor-js/api/server"; |
| 15 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 16 | +import { serveMcpServer } from "@executor-js/plugin-mcp/testing"; |
| 17 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; |
| 18 | + |
| 19 | +import { scenario } from "../src/scenario"; |
| 20 | +import { Api, Target } from "../src/services"; |
| 21 | + |
| 22 | +const api = composePluginApi([mcpHttpPlugin()] as const); |
| 23 | + |
| 24 | +const freshSlug = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`; |
| 25 | + |
| 26 | +// A session-stateful MCP server, shaped like Render's workspace selection: |
| 27 | +// `select_workspace` stores the choice in the session (each MCP session gets |
| 28 | +// its own server instance from the factory, exactly like state keyed by |
| 29 | +// Mcp-Session-Id), and `get_workspace` answers from that same session state. |
| 30 | +const makeSessionStatefulMcpServer = () => () => { |
| 31 | + const server = new McpServer( |
| 32 | + { name: "session-stateful-test-server", version: "1.0.0" }, |
| 33 | + { capabilities: {} }, |
| 34 | + ); |
| 35 | + // Per-session: lives in this server instance, created per MCP session. |
| 36 | + let selected: string | null = null; |
| 37 | + |
| 38 | + server.registerTool( |
| 39 | + "select_workspace", |
| 40 | + { |
| 41 | + description: "Selects the workspace for this MCP session", |
| 42 | + inputSchema: {}, |
| 43 | + }, |
| 44 | + async () => { |
| 45 | + selected = "ws-e2e"; |
| 46 | + return { content: [{ type: "text" as const, text: "selected:ws-e2e" }] }; |
| 47 | + }, |
| 48 | + ); |
| 49 | + |
| 50 | + server.registerTool( |
| 51 | + "get_workspace", |
| 52 | + { |
| 53 | + description: "Returns the workspace selected earlier in this MCP session", |
| 54 | + inputSchema: {}, |
| 55 | + }, |
| 56 | + async () => ({ |
| 57 | + content: [ |
| 58 | + { |
| 59 | + type: "text" as const, |
| 60 | + text: selected === null ? "no workspace selected" : `workspace:${selected}`, |
| 61 | + }, |
| 62 | + ], |
| 63 | + }), |
| 64 | + ); |
| 65 | + |
| 66 | + return server; |
| 67 | +}; |
| 68 | + |
| 69 | +// One sandbox execution, two dependent tool calls — the smallest agent journey |
| 70 | +// that relies on downstream session state. |
| 71 | +const selectThenReadCode = (slug: string) => ` |
| 72 | +const selected = await tools.${slug}.org.main.select_workspace({}); |
| 73 | +const read = await tools.${slug}.org.main.get_workspace({}); |
| 74 | +return JSON.stringify({ selected, read }); |
| 75 | +`; |
| 76 | + |
| 77 | +scenario( |
| 78 | + "MCP · session state set by one tool call is visible to the next call", |
| 79 | + {}, |
| 80 | + Effect.scoped( |
| 81 | + Effect.gen(function* () { |
| 82 | + const target = yield* Target; |
| 83 | + const { client: makeApiClient } = yield* Api; |
| 84 | + const identity = yield* target.newIdentity(); |
| 85 | + const client = yield* makeApiClient(api, identity); |
| 86 | + const slug = freshSlug("mcp_state"); |
| 87 | + |
| 88 | + const server = yield* serveMcpServer(makeSessionStatefulMcpServer()); |
| 89 | + |
| 90 | + yield* client.mcp.addServer({ |
| 91 | + payload: { |
| 92 | + transport: "remote", |
| 93 | + name: "Session-stateful MCP", |
| 94 | + endpoint: server.url, |
| 95 | + slug, |
| 96 | + remoteTransport: "streamable-http", |
| 97 | + }, |
| 98 | + }); |
| 99 | + |
| 100 | + yield* Effect.gen(function* () { |
| 101 | + yield* client.connections.create({ |
| 102 | + payload: { |
| 103 | + owner: "org", |
| 104 | + name: ConnectionName.make("main"), |
| 105 | + integration: IntegrationSlug.make(slug), |
| 106 | + template: AuthTemplateSlug.make("none"), |
| 107 | + value: "", |
| 108 | + }, |
| 109 | + }); |
| 110 | + |
| 111 | + // Only observe the execution's own traffic, not discovery's. |
| 112 | + yield* server.clearRequests; |
| 113 | + |
| 114 | + const executed = yield* client.executions.execute({ |
| 115 | + payload: { code: selectThenReadCode(slug), autoApprove: true }, |
| 116 | + }); |
| 117 | + expect(executed.status, "the two-call execution completed").toBe("completed"); |
| 118 | + |
| 119 | + // THE promise: the selection made by the first call is what the second |
| 120 | + // call reads. A per-call connection lands the second call in a fresh |
| 121 | + // downstream session, which answers "no workspace selected". |
| 122 | + expect(executed.text, "the first call selected the workspace").toContain("selected:ws-e2e"); |
| 123 | + expect(executed.text, "the second call sees the same session's selection").toContain( |
| 124 | + "workspace:ws-e2e", |
| 125 | + ); |
| 126 | + |
| 127 | + // Wire-level corroboration from the fixture's request ledger: every |
| 128 | + // session-bound request of this execution carried ONE Mcp-Session-Id. |
| 129 | + const sessionIds = new Set( |
| 130 | + (yield* server.requests) |
| 131 | + .map((request) => request.sessionId) |
| 132 | + .filter(Predicate.isNotUndefined), |
| 133 | + ); |
| 134 | + expect([...sessionIds].length, "both tool calls rode a single downstream MCP session").toBe( |
| 135 | + 1, |
| 136 | + ); |
| 137 | + }).pipe( |
| 138 | + Effect.ensuring( |
| 139 | + Effect.gen(function* () { |
| 140 | + yield* client.connections |
| 141 | + .remove({ |
| 142 | + params: { |
| 143 | + owner: "org", |
| 144 | + integration: IntegrationSlug.make(slug), |
| 145 | + name: ConnectionName.make("main"), |
| 146 | + }, |
| 147 | + }) |
| 148 | + .pipe(Effect.ignore); |
| 149 | + yield* client.mcp |
| 150 | + .removeServer({ params: { slug: IntegrationSlug.make(slug) } }) |
| 151 | + .pipe(Effect.ignore); |
| 152 | + }), |
| 153 | + ), |
| 154 | + ); |
| 155 | + }), |
| 156 | + ), |
| 157 | +); |
0 commit comments