Skip to content

Commit af95edb

Browse files
authored
Reuse downstream MCP sessions across tool calls (#1435)
1 parent 44f29bc commit af95edb

10 files changed

Lines changed: 615 additions & 97 deletions

File tree

.changeset/mcp-session-reuse.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
);
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

0 commit comments

Comments
 (0)