History

Reuse downstream MCP sessions across tool calls (#1435)

Rhys Sullivan committed 3 months ago

Commit af95edb1 parent 44f29bc10 files · +615 −97Browse files

Unified · Split

10 files
  1. .changeset/mcp-session-reuse.md +5 −0
  2. e2e/scenarios/mcp-session-state.test.ts +157 −0
  3. packages/plugins/mcp/src/sdk/connection-pool.test.ts +156 −0
  4. packages/plugins/mcp/src/sdk/connection-pool.ts +136 −0
  5. packages/plugins/mcp/src/sdk/elicitation.test.ts +53 −78
  6. packages/plugins/mcp/src/sdk/errors.ts +4 −0
  7. packages/plugins/mcp/src/sdk/invoke.ts +31 −11
  8. packages/plugins/mcp/src/sdk/multi-placement-auth.test.ts +6 −2
  9. packages/plugins/mcp/src/sdk/plugin.ts +38 −2
  10. packages/plugins/mcp/src/testing/server.ts +29 −4
.changeset/mcp-session-reuse.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"@executor-js/plugin-mcp": patch
3+---
4+
5+Reuse downstream MCP sessions across tool calls. Remote MCP invocations now lease connections from a per-plugin-instance pool (one idle session per resolved credential identity, exclusive per invoke, 5-minute idle TTL) instead of dialing a fresh connection per call, so servers that key state by `Mcp-Session-Id` (workspace selection and similar) see consecutive calls in the same session. A reused session rejected with HTTP 404 is redialed once transparently; stdio transports and endpoint probing remain per-call.
e2e/scenarios/mcp-session-state.test.ts+157−0
@@ -0,0 +1,157 @@
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+);
packages/plugins/mcp/src/sdk/connection-pool.test.ts+156−0
@@ -0,0 +1,156 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+import { ElicitationResponse, type Elicit } from "@executor-js/sdk";
4+
5+import { createMcpConnector } from "./connection";
6+import { createMcpConnectionPool } from "./connection-pool";
7+import { invokeMcpTool } from "./invoke";
8+import { makeEchoMcpServer, serveMcpServer } from "../testing";
9+
10+const acceptAll: Elicit = () =>
11+ Effect.succeed(ElicitationResponse.make({ action: "accept", content: { approved: true } }));
12+
13+const invoke = (input: {
14+ readonly endpoint: string;
15+ readonly pool: ReturnType<typeof createMcpConnectionPool>;
16+ readonly toolName: string;
17+ readonly args: Record<string, unknown>;
18+ readonly elicit?: Elicit;
19+}) =>
20+ invokeMcpTool({
21+ toolId: input.toolName,
22+ toolName: input.toolName,
23+ args: input.args,
24+ transport: "streamable-http",
25+ connector: createMcpConnector({
26+ transport: "remote",
27+ endpoint: input.endpoint,
28+ remoteTransport: "streamable-http",
29+ }),
30+ connectionPool: input.pool,
31+ connectionPoolKey: input.endpoint,
32+ elicit: input.elicit ?? acceptAll,
33+ });
34+
35+describe("MCP connection pool", () => {
36+ it.effect("reuses one session for sequential invokes", () =>
37+ Effect.scoped(
38+ Effect.gen(function* () {
39+ const server = yield* serveMcpServer(() => makeEchoMcpServer());
40+ const pool = createMcpConnectionPool();
41+
42+ const first = yield* invoke({
43+ endpoint: server.endpoint,
44+ pool,
45+ toolName: "echo",
46+ args: { value: "first" },
47+ });
48+ const second = yield* invoke({
49+ endpoint: server.endpoint,
50+ pool,
51+ toolName: "echo",
52+ args: { value: "second" },
53+ });
54+
55+ expect(first).toMatchObject({ content: [{ type: "text", text: "first" }] });
56+ expect(second).toMatchObject({ content: [{ type: "text", text: "second" }] });
57+ expect(server.sessionCount()).toBe(1);
58+ yield* pool.close();
59+ }),
60+ ),
61+ );
62+
63+ it.effect("leases separate sessions to concurrent invokes", () =>
64+ Effect.scoped(
65+ Effect.gen(function* () {
66+ const server = yield* serveMcpServer(() => makeEchoMcpServer());
67+ const pool = createMcpConnectionPool();
68+
69+ const results = yield* Effect.all(
70+ [
71+ invoke({
72+ endpoint: server.endpoint,
73+ pool,
74+ toolName: "echo",
75+ args: { value: "left" },
76+ }),
77+ invoke({
78+ endpoint: server.endpoint,
79+ pool,
80+ toolName: "echo",
81+ args: { value: "right" },
82+ }),
83+ ],
84+ { concurrency: "unbounded" },
85+ );
86+
87+ expect(results).toEqual([
88+ expect.objectContaining({ content: [{ type: "text", text: "left" }] }),
89+ expect.objectContaining({ content: [{ type: "text", text: "right" }] }),
90+ ]);
91+ expect(server.sessionCount()).toBe(2);
92+ yield* pool.close();
93+ }),
94+ ),
95+ );
96+
97+ it.effect("redials once when a reused session has expired", () =>
98+ Effect.scoped(
99+ Effect.gen(function* () {
100+ const server = yield* serveMcpServer(() => makeEchoMcpServer());
101+ const pool = createMcpConnectionPool();
102+
103+ yield* invoke({
104+ endpoint: server.endpoint,
105+ pool,
106+ toolName: "echo",
107+ args: { value: "before" },
108+ });
109+ yield* server.forgetSessions;
110+ const after = yield* invoke({
111+ endpoint: server.endpoint,
112+ pool,
113+ toolName: "echo",
114+ args: { value: "after" },
115+ });
116+
117+ expect(after).toMatchObject({ content: [{ type: "text", text: "after" }] });
118+ expect(server.sessionCount()).toBe(2);
119+ yield* pool.close();
120+ }),
121+ ),
122+ );
123+
124+ it.effect("drops a connection after a transport-level invocation failure", () =>
125+ Effect.scoped(
126+ Effect.gen(function* () {
127+ const server = yield* serveMcpServer(() => makeEchoMcpServer());
128+ const pool = createMcpConnectionPool();
129+
130+ yield* invoke({
131+ endpoint: server.endpoint,
132+ pool,
133+ toolName: "echo",
134+ args: { value: "before" },
135+ });
136+ yield* server.rejectNextSessionRequest(500);
137+ yield* invoke({
138+ endpoint: server.endpoint,
139+ pool,
140+ toolName: "echo",
141+ args: { value: "fails" },
142+ }).pipe(Effect.flip);
143+ const after = yield* invoke({
144+ endpoint: server.endpoint,
145+ pool,
146+ toolName: "echo",
147+ args: { value: "after" },
148+ });
149+
150+ expect(after).toMatchObject({ content: [{ type: "text", text: "after" }] });
151+ expect(server.sessionCount()).toBe(2);
152+ yield* pool.close();
153+ }),
154+ ),
155+ );
156+});
packages/plugins/mcp/src/sdk/connection-pool.ts+136−0
@@ -0,0 +1,136 @@
1+import { Cause, Effect, Exit, Predicate } from "effect";
2+
3+import type { McpConnection, McpConnector } from "./connection";
4+import type { McpInvocationError } from "./errors";
5+
6+const IDLE_TTL_MS = 5 * 60 * 1_000;
7+
8+type IdleConnection = {
9+ readonly connection: McpConnection;
10+ readonly idleSince: number;
11+};
12+
13+type ConnectionLease = {
14+ readonly connection: McpConnection;
15+ readonly reused: boolean;
16+};
17+
18+const closeQuietly = (connection: McpConnection): Effect.Effect<void> =>
19+ Effect.tryPromise(() => connection.close()).pipe(Effect.ignore);
20+
21+const isMcpInvocationError = (error: unknown): error is McpInvocationError =>
22+ Predicate.isTagged(error, "McpInvocationError");
23+
24+const isDeadConnectionFailure = (error: unknown): boolean => {
25+ if (Predicate.isTagged(error, "McpConnectionError")) return true;
26+ if (!isMcpInvocationError(error)) return false;
27+ return (
28+ error.transportFailure === true ||
29+ error.status === 400 ||
30+ error.status === 404 ||
31+ error.status === 408
32+ );
33+};
34+
35+const shouldDropConnection = <A, E>(exit: Exit.Exit<A, E>): boolean => {
36+ if (Exit.isSuccess(exit)) return false;
37+ const failures = exit.cause.reasons.filter(Cause.isFailReason);
38+ if (failures.length === 0) return true;
39+ return failures.some((failure) => isDeadConnectionFailure(failure.error));
40+};
41+
42+/** A per-plugin-instance pool that gives each invocation an exclusive MCP
43+ * connection lease while retaining at most one idle session per identity. */
44+export interface McpConnectionPool {
45+ readonly withConnection: <A, E, R>(
46+ key: string,
47+ connector: McpConnector,
48+ use: (connection: McpConnection) => Effect.Effect<A, E, R>,
49+ ) => Effect.Effect<A, E | Effect.Error<McpConnector>, R>;
50+ /** Closes and removes every currently idle connection. Leased connections
51+ * remain owned by their in-flight invocation and follow normal release. */
52+ readonly close: () => Effect.Effect<void>;
53+}
54+
55+/** Creates an MCP connection pool with lazy five-minute idle eviction and one
56+ * automatic fresh-dial retry for a reused session rejected with HTTP 404. */
57+export const createMcpConnectionPool = (): McpConnectionPool => {
58+ const idle = new Map<string, IdleConnection>();
59+
60+ const acquire = (key: string, connector: McpConnector, forceFresh: boolean) =>
61+ Effect.gen(function* () {
62+ if (forceFresh) {
63+ const connection = yield* connector;
64+ return { connection, reused: false } satisfies ConnectionLease;
65+ }
66+
67+ const taken = yield* Effect.sync(() => {
68+ const entry = idle.get(key);
69+ if (!entry) return { connection: undefined, expired: undefined };
70+ idle.delete(key);
71+ if (Date.now() - entry.idleSince >= IDLE_TTL_MS) {
72+ return { connection: undefined, expired: entry.connection };
73+ }
74+ return { connection: entry.connection, expired: undefined };
75+ });
76+
77+ if (taken.expired) yield* closeQuietly(taken.expired);
78+ if (taken.connection) {
79+ return { connection: taken.connection, reused: true } satisfies ConnectionLease;
80+ }
81+
82+ const connection = yield* connector;
83+ return { connection, reused: false } satisfies ConnectionLease;
84+ });
85+
86+ const release = <A, E>(key: string, lease: ConnectionLease, exit: Exit.Exit<A, E>) =>
87+ Effect.gen(function* () {
88+ if (shouldDropConnection(exit)) {
89+ yield* closeQuietly(lease.connection);
90+ return;
91+ }
92+
93+ const displaced = yield* Effect.sync(() => {
94+ if (idle.has(key)) return lease.connection;
95+ idle.set(key, { connection: lease.connection, idleSince: Date.now() });
96+ return undefined;
97+ });
98+ if (displaced) yield* closeQuietly(displaced);
99+ });
100+
101+ const withConnection: McpConnectionPool["withConnection"] = (key, connector, use) => {
102+ let reused = false;
103+ const run = (forceFresh: boolean) =>
104+ Effect.acquireUseRelease(
105+ acquire(key, connector, forceFresh),
106+ (lease) => {
107+ if (!forceFresh) reused = lease.reused;
108+ return use(lease.connection);
109+ },
110+ (lease, exit) => release(key, lease, exit),
111+ );
112+
113+ return run(false).pipe(
114+ Effect.catch((error) =>
115+ reused && isMcpInvocationError(error) && error.status === 404
116+ ? run(true)
117+ : Effect.fail(error),
118+ ),
119+ );
120+ };
121+
122+ const close = () =>
123+ Effect.gen(function* () {
124+ const connections = yield* Effect.sync(() => {
125+ const connections = [...idle.values()].map((entry) => entry.connection);
126+ idle.clear();
127+ return connections;
128+ });
129+ yield* Effect.forEach(connections, closeQuietly, {
130+ concurrency: "unbounded",
131+ discard: true,
132+ });
133+ });
134+
135+ return { withConnection, close };
136+};
packages/plugins/mcp/src/sdk/elicitation.test.ts+53−78
@@ -1,5 +1,5 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { Effect, Schema } from "effect";
2+import { Effect, Predicate, Schema, Semaphore } from "effect";
33 import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
44 import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types";
55
@@ -26,7 +26,10 @@ import { makeElicitationMcpServer, serveMcpServer } from "../testing"; · 20 unmodified lines
2626
2727 const isFormElicitation = Schema.is(FormElicitation);
2828
29−const serveElicitationTestServer = serveMcpServer(makeElicitationMcpServer);
29+const elicitationServerSemaphore = Semaphore.makeUnsafe(1);
30+const serveElicitationTestServer = Effect.acquireRelease(elicitationServerSemaphore.take(1), () =>
31+ elicitationServerSemaphore.release(1).pipe(Effect.asVoid),
32+).pipe(Effect.flatMap(() => serveMcpServer(makeElicitationMcpServer)));
3033
3134 const schemaValidator = new CfWorkerJsonSchemaValidator({ shortcircuit: false });
3235
@@ -56,8 +59,9 @@ const TEMPLATE = AuthTemplateSlug.make("none"); · 23 unmodified lines
5659 // ---------------------------------------------------------------------------
5760
5861 const makeTestExecutor = (serverUrl: string) =>
59− createExecutor(
60− makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
62+ Effect.acquireRelease(
63+ createExecutor(makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const })),
64+ (executor) => executor.close().pipe(Effect.ignore),
6165 ).pipe(
6266 Effect.tap((executor) =>
6367 Effect.gen(function* () {
@@ -85,7 +89,7 @@ const findTool = (tools: readonly Tool[], name: string): Tool => · 21 unmodified lines
8589 // ---------------------------------------------------------------------------
8690
8791 describe("MCP elicitation (end-to-end)", () => {
88− it.effect("form elicitation accepted → tool returns approved result", () =>
92+ it.effect("reuses the session while installing each invoke's elicitation handler", () =>
8993 Effect.gen(function* () {
9094 const server = yield* serveElicitationTestServer;
9195 const executor = yield* makeTestExecutor(server.url);
@@ -93,12 +97,19 @@ describe("MCP elicitation (end-to-end)", () => { · 1 unmodified line
9397 const tools = yield* executor.tools.list();
9498 const gatedEcho = findTool(tools, "gated_echo");
9599
96− const elicitationMessages: string[] = [];
100+ const acceptedMessages: string[] = [];
101+ const declinedMessages: string[] = [];
102+ let capturedAddress: string | undefined;
103+ let capturedArgs: unknown;
104+ let capturedRequest: unknown;
97105
98106 const options: InvokeOptions = {
99107 onElicitation: (ctx) => {
108+ capturedAddress = String(ctx.address);
109+ capturedArgs = ctx.args;
110+ capturedRequest = ctx.request;
100111 if (isFormElicitation(ctx.request)) {
101− elicitationMessages.push(ctx.request.message);
112+ acceptedMessages.push(ctx.request.message);
102113 }
103114 return Effect.succeed(
104115 ElicitationResponse.make({
@@ -109,36 +120,50 @@ describe("MCP elicitation (end-to-end)", () => { · 4 unmodified lines
109120 },
110121 };
111122
112− const result = yield* executor.execute(gatedEcho.address, { value: "hello" }, options);
113−
114− expect(result).toMatchObject({
115− ok: true,
116− data: { content: [{ type: "text", text: "approved:hello" }] },
117− });
118− expect(elicitationMessages.length).toBeGreaterThanOrEqual(1);
119− expect(elicitationMessages.some((m) => m.includes('Approve echo for "hello"?'))).toBe(true);
120− }),
121− );
122−
123− it.effect("form elicitation declined → tool returns denied result", () =>
124− Effect.gen(function* () {
125− const server = yield* serveElicitationTestServer;
126− const executor = yield* makeTestExecutor(server.url);
127− const tools = yield* executor.tools.list();
128− const gatedEcho = findTool(tools, "gated_echo");
129−
130− const result = yield* executor.execute(
123+ yield* server.clearRequests;
124+ const accepted = yield* executor.execute(gatedEcho.address, { value: "hello" }, options);
125+ const declined = yield* executor.execute(
131126 gatedEcho.address,
132127 { value: "nope" },
133128 {
134− onElicitation: () => Effect.succeed(ElicitationResponse.make({ action: "decline" })),
129+ onElicitation: (ctx) => {
130+ if (isFormElicitation(ctx.request)) {
131+ declinedMessages.push(ctx.request.message);
132+ }
133+ return Effect.succeed(ElicitationResponse.make({ action: "decline" }));
134+ },
135135 },
136136 );
137+ yield* executor.close();
137138
138− expect(result).toMatchObject({
139+ expect(accepted).toMatchObject({
140+ ok: true,
141+ data: { content: [{ type: "text", text: "approved:hello" }] },
142+ });
143+ expect(declined).toMatchObject({
139144 ok: true,
140145 data: { content: [{ type: "text", text: "denied:nope" }] },
141146 });
147+ expect(acceptedMessages).toEqual(['Approve echo for "hello"?']);
148+ expect(declinedMessages).toEqual(['Approve echo for "nope"?']);
149+ expect(capturedAddress).toBe(String(gatedEcho.address));
150+ expect(capturedArgs).toEqual({ value: "hello" });
151+ expect(isFormElicitation(capturedRequest)).toBe(true);
152+ const form = capturedRequest as FormElicitation;
153+ expect(form.message).toContain('Approve echo for "hello"?');
154+ expect(form.requestedSchema).toEqual({
155+ type: "object",
156+ properties: {
157+ approved: { type: "boolean", title: "Approve" },
158+ },
159+ required: ["approved"],
160+ });
161+ const sessionIds = new Set(
162+ (yield* server.requests)
163+ .map((request) => request.sessionId)
164+ .filter(Predicate.isNotUndefined),
165+ );
166+ expect(sessionIds.size).toBe(1);
142167 }),
143168 );
144169
@@ -316,54 +341,4 @@ describe("MCP elicitation (end-to-end)", () => { · 171 unmodified lines
316341 expect(integration?.description).toBe("Gmail");
317342 }),
318343 );
319−
320− it.effect("handler receives correct address, args, and FormElicitation schema", () =>
321− Effect.gen(function* () {
322− const server = yield* serveElicitationTestServer;
323− const executor = yield* makeTestExecutor(server.url);
324− const tools = yield* executor.tools.list();
325− const gatedEcho = findTool(tools, "gated_echo");
326−
327− let capturedAddress: string | undefined;
328− let capturedArgs: unknown;
329− let capturedRequest: unknown;
330−
331− yield* executor.execute(
332− gatedEcho.address,
333− { value: "ctx-test" },
334− {
335− onElicitation: (ctx) => {
336− capturedAddress = String(ctx.address);
337− capturedArgs = ctx.args;
338− capturedRequest = ctx.request;
339− return Effect.succeed(
340− ElicitationResponse.make({
341− action: "accept",
342− content: { approved: true },
343− }),
344− );
345− },
346− },
347− );
348−
349− expect(capturedAddress).toBe(String(gatedEcho.address));
350− expect(capturedArgs).toEqual({ value: "ctx-test" });
351− expect(isFormElicitation(capturedRequest)).toBe(true);
352−
353− const form = capturedRequest as FormElicitation;
354− expect(form.message).toContain('Approve echo for "ctx-test"?');
355− expect(form.requestedSchema).toEqual({
356− type: "object",
357− properties: {
358− approved: { type: "boolean", title: "Approve" },
359− },
360− required: ["approved"],
361− });
362− }),
363− );
364−
365− // removed: "connection is reused across multiple tool calls" — v2 does not
366− // cache MCP client connections across invocations (Hyperdrive request-scoped
367− // rule; each invoke dials and closes its own connection). Session reuse is no
368− // longer a property of the plugin, so the assertion no longer applies.
369344 });
packages/plugins/mcp/src/sdk/errors.ts+4−0
@@ -38,6 +38,10 @@ export class McpInvocationError extends Data.TaggedError("McpInvocationError")<{ · 37 unmodified lines
3838 readonly toolName: string;
3939 readonly message: string;
4040 readonly status?: number;
41+ /** The SDK failure came from the HTTP/SSE transport rather than a JSON-RPC
42+ * protocol response. Used only to decide whether a leased session is safe
43+ * to return to the idle pool. */
44+ readonly transportFailure?: boolean;
4145 /** The server rejected the call as an unknown tool (protocol error), which
4246 * means the persisted catalog has drifted from the server's live tool set. */
4347 readonly unknownTool?: boolean;
packages/plugins/mcp/src/sdk/invoke.ts+31−11
@@ -2,9 +2,12 @@ · 1 unmodified line
22 // MCP tool invocation — shared helper called from plugin.invokeTool.
33 //
44 // Responsible for:
5−// 1. Dialing a fresh MCP client connection for the call (no DB-connection
6−// caching — request-scoped per the Hyperdrive rule; each invoke acquires
7−// and releases its own connection).
5+// 1. Leasing remote connections from a per-plugin-instance pool when one is
6+// provided. MCP streamable-http is a series of HTTP requests keyed by the
7+// application-level `Mcp-Session-Id`, not a raw pooled socket, and servers
8+// legitimately retain state in that session. The pool keeps one idle
9+// connection per resolved identity and leases it exclusively per invoke;
10+// stdio and callers without a pool remain strictly per-call.
811 // 2. Installing a per-invocation `ElicitRequestSchema` handler that bridges
912 // MCP's elicit capability into the host's elicit function threaded via
1013 // `InvokeToolInput.elicit`.
@@ -30,6 +33,7 @@ import { · 19 unmodified lines
3033
3134 import { McpConnectionError, McpInvocationError, McpOAuthReauthorizationRequired } from "./errors";
3235 import type { McpConnection, McpConnector } from "./connection";
36+import type { McpConnectionPool } from "./connection-pool";
3337 import { httpStatusFromCause, insufficientScopeFromCause } from "./http-status";
3438
3539 // ---------------------------------------------------------------------------
@@ -181,13 +185,17 @@ const useConnection = ( · 145 unmodified lines
181185 message: `MCP tool call failed for ${toolName}`,
182186 status: 403,
183187 insufficientScope: true,
188+ transportFailure: true,
184189 });
185190 }
186191 const status = httpStatusFromCause(cause);
192+ // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK protocol failures are its McpError subclass; transport failures use other error shapes
193+ const protocolFailure = cause instanceof McpError;
187194 return new McpInvocationError({
188195 toolName,
189196 message: `MCP tool call failed for ${toolName}`,
190197 ...(status === undefined ? {} : { status }),
198+ ...(!protocolFailure ? { transportFailure: true } : {}),
191199 ...(isUnknownToolCause(cause, toolName) ? { unknownTool: true } : {}),
192200 ...(status === 403 && insufficientScopeFromCause(cause)
193201 ? { insufficientScope: true }
@@ -211,8 +219,12 @@ export interface InvokeMcpToolInput { · 17 unmodified lines
211219 readonly toolName: string;
212220 readonly args: unknown;
213221 readonly transport: string;
214− /** Dials a fresh connection. The connection is closed after the call. */
222+ /** Dials a fresh connection when no reusable remote session is available. */
215223 readonly connector: McpConnector;
224+ /** Optional per-plugin-instance pool and resolved remote identity key. Both
225+ * must be present to enable reuse; otherwise the connection is per-call. */
226+ readonly connectionPool?: McpConnectionPool;
227+ readonly connectionPoolKey?: string;
216228 readonly elicit: Elicit;
217229 /** Fired when the server sends `notifications/tools/list_changed` during
218230 * the call window. Synchronous and non-throwing by contract; the caller
@@ -228,6 +240,20 @@ export const invokeMcpTool = ( · 9 unmodified lines
228240 > =>
229241 Effect.gen(function* () {
230242 const args = argsRecord(input.args);
243+ const use = (connection: McpConnection) =>
244+ useConnection(connection, input.toolName, args, input.elicit, input.onToolListChanged);
245+
246+ if (input.connectionPool && input.connectionPoolKey) {
247+ return yield* input.connectionPool.withConnection(
248+ input.connectionPoolKey,
249+ input.connector.pipe(
250+ Effect.withSpan("plugin.mcp.connection.acquire", {
251+ attributes: { "plugin.mcp.transport": input.transport },
252+ }),
253+ ),
254+ use,
255+ );
256+ }
231257
232258 const connection = yield* Effect.acquireRelease(
233259 input.connector.pipe(
@@ -248,13 +274,7 @@ export const invokeMcpTool = ( · 14 unmodified lines
248274 ),
249275 );
250276
251− return yield* useConnection(
252− connection,
253− input.toolName,
254− args,
255− input.elicit,
256− input.onToolListChanged,
257− );
277+ return yield* use(connection);
258278 }).pipe(
259279 Effect.scoped,
260280 Effect.withSpan("plugin.mcp.invoke", {
packages/plugins/mcp/src/sdk/multi-placement-auth.test.ts+6−2
@@ -169,7 +169,9 @@ describe("MCP multi-placement auth", () => { · 168 unmodified lines
169169 { marker: "h" },
170170 { onElicitation: "accept-all" },
171171 );
172− const headerRequests = (yield* server.requests).slice(beforeHeader);
172+ const headerRequests = (yield* server.requests)
173+ .slice(beforeHeader)
174+ .filter((request) => request.method === "POST");
173175 expect(headerRequests.every((r) => r.authorization === "Bearer header-secret")).toBe(true);
174176 expect(headerRequests.every((r) => !r.url.includes("auth_token="))).toBe(true);
175177
@@ -179,7 +181,9 @@ describe("MCP multi-placement auth", () => { · 3 unmodified lines
179181 { marker: "q" },
180182 { onElicitation: "accept-all" },
181183 );
182− const queryRequests = (yield* server.requests).slice(beforeQuery);
184+ const queryRequests = (yield* server.requests)
185+ .slice(beforeQuery)
186+ .filter((request) => request.method === "POST");
183187 expect(queryRequests.every((r) => r.url.includes("auth_token=query-secret"))).toBe(true);
184188 expect(queryRequests.every((r) => r.authorization === undefined)).toBe(true);
185189 }),
packages/plugins/mcp/src/sdk/plugin.ts+38−2
@@ -41,6 +41,7 @@ import { · 40 unmodified lines
4141 } from "@executor-js/sdk/http-auth";
4242
4343 import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection";
44+import { createMcpConnectionPool } from "./connection-pool";
4445 import { discoverTools } from "./discover";
4546 import {
4647 McpConnectionError,
@@ -615,6 +616,33 @@ const buildConnectorInput = ( · 568 unmodified lines
615616 });
616617 };
617618
619+// Record fields are key-sorted before stringifying: the key only guards
620+// same-identity reuse (a mismatch merely costs a fresh dial), but insertion
621+// order must not make two equal identities look distinct.
622+const sortedRecord = (
623+ record: Record<string, string | null | undefined> | undefined,
624+): Record<string, string | null> =>
625+ Object.fromEntries(
626+ Object.entries(record ?? {})
627+ .filter((entry): entry is [string, string | null] => entry[1] !== undefined)
628+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
629+ );
630+
631+const connectionPoolKey = (
632+ input: Extract<ConnectorInput, { readonly transport: "remote" }>,
633+ template: string,
634+ values: Record<string, string | null>,
635+): string =>
636+ JSON.stringify({
637+ endpoint: input.endpoint,
638+ transport: input.transport,
639+ remoteTransport: input.remoteTransport,
640+ headers: sortedRecord(input.headers),
641+ queryParams: sortedRecord(input.queryParams),
642+ template,
643+ values: sortedRecord(values),
644+ });
645+
618646 // ---------------------------------------------------------------------------
619647 // Declared auth methods — project the stored MCP config into the catalog's
620648 // plugin-agnostic `AuthMethodDescriptor[]`, one per declared method. Pure and
@@ -699,6 +727,7 @@ export interface McpPluginOptions { · 78 unmodified lines
699727
700728 export const mcpPlugin = definePlugin((options?: McpPluginOptions) => {
701729 const allowStdio = options?.dangerouslyAllowStdioMCP ?? false;
730+ const connectionPool = createMcpConnectionPool();
702731
703732 const presetEntries = (
704733 allowStdio
@@ -731,6 +760,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 26 unmodified lines
731760 // factory reads `allowStdio` and gates the stdio tab + presets.
732761 clientConfig: { allowStdio },
733762 storage: () => ({}),
763+ close: () => connectionPool.close(),
734764
735765 extension: (ctx: PluginCtx) => {
736766 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
@@ -1266,13 +1296,18 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 529 unmodified lines
12661296 }
12671297 }
12681298
1269− const connector: McpConnector = yield* buildConnectorInput(
1299+ const connectorInput = yield* buildConnectorInput(
12701300 parsed,
12711301 credential.values,
12721302 String(credential.template),
12731303 allowStdio,
12741304 options?.httpClientLayer ?? ctx.httpClientLayer,
1275− ).pipe(Effect.map((ci) => createMcpConnector(ci)));
1305+ );
1306+ const connector: McpConnector = createMcpConnector(connectorInput);
1307+ const poolKey =
1308+ connectorInput.transport === "remote"
1309+ ? connectionPoolKey(connectorInput, String(credential.template), credential.values)
1310+ : undefined;
12761311
12771312 const connectionRef = {
12781313 owner: credential.owner,
@@ -1293,6 +1328,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 14 unmodified lines
12931328 args,
12941329 transport,
12951330 connector,
1331+ ...(poolKey === undefined ? {} : { connectionPool, connectionPoolKey: poolKey }),
12961332 elicit,
12971333 onToolListChanged: () => {
12981334 toolListChanged = true;
packages/plugins/mcp/src/testing/server.ts+29−4
@@ -12,6 +12,10 @@ export type McpTestServer = { · 11 unmodified lines
1212 readonly sessionCount: () => number;
1313 readonly requests: Effect.Effect<readonly McpTestRequest[]>;
1414 readonly clearRequests: Effect.Effect<void>;
15+ /** Drops all server-side session registrations without notifying clients. */
16+ readonly forgetSessions: Effect.Effect<void>;
17+ /** Rejects the next request carrying an MCP session id with this status. */
18+ readonly rejectNextSessionRequest: (status: number) => Effect.Effect<void>;
1519 };
1620
1721 export type McpTestRequest = {
@@ -64,9 +68,11 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 46 unmodified lines
6468 Effect.acquireRelease(
6569 Effect.gen(function* () {
6670 const transports = new Map<string, StreamableHTTPServerTransport>();
71+ const allTransports = new Set<StreamableHTTPServerTransport>();
6772 const requests = yield* Ref.make<readonly McpTestRequest[]>([]);
6873 const path = options.path ?? "/";
6974 let sessions = 0;
75+ let nextSessionRequestStatus: number | undefined;
7076
7177 const handleMcpRequest = (
7278 request: http.IncomingMessage,
@@ -130,6 +136,13 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 57 unmodified lines
130136 }
131137 }
132138
139+ if (sessionId && request.method === "POST" && nextSessionRequestStatus !== undefined) {
140+ const status = nextSessionRequestStatus;
141+ nextSessionRequestStatus = undefined;
142+ writeText(response, status, `Forced HTTP ${status}`);
143+ return;
144+ }
145+
133146 const existingTransport = sessionId ? transports.get(sessionId) : undefined;
134147 if (sessionId && !existingTransport) {
135148 writeText(response, 404, "Session not found");
@@ -150,6 +163,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 14 unmodified lines
150163 transports.set(sid, transport);
151164 },
152165 });
166+ allTransports.add(transport);
153167 sessions += 1;
154168
155169 const mcpServer = factory();
@@ -202,21 +216,32 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO · 46 unmodified lines
202216 sessionCount: () => sessions,
203217 requests: Ref.get(requests),
204218 clearRequests: Ref.set(requests, []),
219+ forgetSessions: Effect.sync(() => transports.clear()),
220+ rejectNextSessionRequest: (status: number) =>
221+ Effect.sync(() => {
222+ nextSessionRequestStatus = status;
223+ }),
205224 close: Effect.gen(function* () {
206− for (const transport of transports.values()) {
225+ for (const transport of allTransports) {
207226 yield* Effect.tryPromise({
208227 try: () => transport.close(),
209228 catch: (cause) => new McpTestServerError({ cause }),
210229 }).pipe(Effect.ignore);
211230 }
212− yield* Effect.sync(() => {
213− nodeServer.close();
231+ yield* Effect.callback<void>((resume) => {
232+ nodeServer.close(() => resume(Effect.void));
214233 nodeServer.closeAllConnections?.();
215234 });
235+ // closeAllConnections destroys sockets out from under in-flight SDK
236+ // reads; give those rejections a beat to settle before the scope
237+ // ends so they surface inside the test, not as unhandled rejections.
238+ // A raw timer, not Effect.sleep: this runs inside it.effect tests
239+ // whose TestClock never advances wall-clock sleeps.
240+ yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 25)));
216241 }),
217242 };
218243 }),
219− (server) => server.close,
244+ (server) => server.close.pipe(Effect.ignore),
220245 ).pipe(Effect.map(({ close: _close, ...server }) => server));
221246
222247 export const serveMcpServerWithOAuth = (