@harness-fe/mcp-server 4.0.0-next.3 → 4.0.0-next.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mcp.js +26 -6
- package/dist/mcpHttp.js +83 -19
- package/package.json +3 -3
- package/src/mcp.ts +26 -6
- package/src/mcpHttp.test.ts +49 -0
- package/src/mcpHttp.ts +96 -23
package/dist/mcp.js
CHANGED
|
@@ -488,7 +488,7 @@ function registerTools(server, bridge, auth) {
|
|
|
488
488
|
const store = bridge.store;
|
|
489
489
|
const all = await bridge.listTasks({ status: status ?? 'pending', limit });
|
|
490
490
|
const tasks = store
|
|
491
|
-
? all.filter((t) => canSeeProject(principal, ownerChainOf(t.projectId, store)))
|
|
491
|
+
? all.filter((t) => canSeeProject(principal, t.projectId, ownerChainOf(t.projectId, store)))
|
|
492
492
|
: all.filter((t) => canSee(principal, t.createdBy));
|
|
493
493
|
const summary = tasks.map((t) => ({
|
|
494
494
|
id: t.id,
|
|
@@ -518,14 +518,34 @@ function registerTools(server, bridge, auth) {
|
|
|
518
518
|
return ok(task);
|
|
519
519
|
});
|
|
520
520
|
server.registerTool(COMMAND.TASKS_RESOLVE, {
|
|
521
|
-
description: 'Mark a task as resolved with an optional note
|
|
521
|
+
description: 'Mark a task as resolved with an optional note and structured resolution. ' +
|
|
522
|
+
'Use after addressing the user request. Pass `resolution` to close the ' +
|
|
523
|
+
'feedback loop: how it was fixed (type), the fix commit/PR, and the ' +
|
|
524
|
+
'verificationSessionId of the post-fix re-test that proved the fix.',
|
|
522
525
|
inputSchema: {
|
|
523
526
|
taskId: z.string(),
|
|
524
527
|
note: z.string().optional(),
|
|
528
|
+
resolution: z
|
|
529
|
+
.object({
|
|
530
|
+
type: z
|
|
531
|
+
.enum(['code-fix', 'config', 'wontfix', 'duplicate', 'cannot-reproduce'])
|
|
532
|
+
.optional(),
|
|
533
|
+
commit: z.string().optional().describe('Git commit SHA of the fix.'),
|
|
534
|
+
prUrl: z.string().optional().describe('Pull-request URL for the fix.'),
|
|
535
|
+
verificationSessionId: z
|
|
536
|
+
.string()
|
|
537
|
+
.optional()
|
|
538
|
+
.describe('Session id of the post-fix re-test that verified the fix.'),
|
|
539
|
+
verifiedAt: z
|
|
540
|
+
.number()
|
|
541
|
+
.optional()
|
|
542
|
+
.describe('Epoch ms; defaulted when verificationSessionId is given.'),
|
|
543
|
+
})
|
|
544
|
+
.optional(),
|
|
525
545
|
},
|
|
526
|
-
}, async ({ taskId, note }, extra) => {
|
|
546
|
+
}, async ({ taskId, note, resolution }, extra) => {
|
|
527
547
|
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
528
|
-
const task = await bridge.resolveTask(taskId, note, principal);
|
|
548
|
+
const task = await bridge.resolveTask(taskId, note, resolution, principal);
|
|
529
549
|
if (!task) {
|
|
530
550
|
throw new Error(`tasks.resolve: no task with id "${taskId}"`);
|
|
531
551
|
}
|
|
@@ -585,7 +605,7 @@ function registerStoreTools(server, store, memoryStore, bridge, auth) {
|
|
|
585
605
|
}, async ({ projectId, limit }, extra) => {
|
|
586
606
|
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
587
607
|
// Owning a project (or a host ancestor) grants its whole session set.
|
|
588
|
-
if (!canSeeProject(principal, ownerChainOf(projectId, store)))
|
|
608
|
+
if (!canSeeProject(principal, projectId, ownerChainOf(projectId, store)))
|
|
589
609
|
return ok([]);
|
|
590
610
|
return ok(store.listSessions({ projectId, limit: limit ?? 10 }));
|
|
591
611
|
});
|
|
@@ -659,7 +679,7 @@ function registerStoreTools(server, store, memoryStore, bridge, auth) {
|
|
|
659
679
|
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
660
680
|
const projects = store
|
|
661
681
|
.listProjects()
|
|
662
|
-
.filter((p) => canSeeProject(principal, ownerChainOf(p.id, store)));
|
|
682
|
+
.filter((p) => canSeeProject(principal, p.id, ownerChainOf(p.id, store)));
|
|
663
683
|
const result = projects.map((p) => ({
|
|
664
684
|
...p,
|
|
665
685
|
recentSessions: store.listSessions({ projectId: p.id, limit: 3 }),
|
package/dist/mcpHttp.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
10
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
11
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
11
12
|
import { createMcpServer } from './mcp.js';
|
|
12
13
|
import { identifyPrincipal } from '@harness-fe/daemon';
|
|
13
14
|
import { runWithCaller } from '@harness-fe/daemon';
|
|
@@ -20,42 +21,105 @@ import { MemoryEventStore } from '@harness-fe/daemon';
|
|
|
20
21
|
export async function startMcpHttpServer(bridge, opts = {}) {
|
|
21
22
|
const path = opts.path ?? '/mcp';
|
|
22
23
|
const stateful = opts.stateful !== false;
|
|
23
|
-
const eventStore = opts.eventStore === null
|
|
24
|
-
? undefined
|
|
25
|
-
: opts.eventStore ?? new MemoryEventStore();
|
|
26
|
-
// Pass the daemon's auth so MCP tools can identify the per-call principal
|
|
27
|
-
// from request headers (4.0 · P4). stdio (startMcpStdioServer) omits this,
|
|
28
|
-
// so stdio calls resolve to the local principal.
|
|
29
|
-
const server = createMcpServer(bridge, {
|
|
30
|
-
experimentalEnvVar: opts.experimentalEnvVar,
|
|
31
|
-
auth: bridge.getAuthOptions(),
|
|
32
|
-
});
|
|
33
|
-
const transport = new StreamableHTTPServerTransport({
|
|
34
|
-
sessionIdGenerator: stateful ? () => randomUUID() : undefined,
|
|
35
|
-
eventStore,
|
|
36
|
-
});
|
|
37
|
-
await server.connect(transport);
|
|
38
24
|
const b = bridge;
|
|
39
25
|
if (typeof b.prependHttpHandler !== 'function') {
|
|
40
26
|
throw new Error('mcpHttp: bridge does not support prependHttpHandler (need a Bridge instance with HTTP server)');
|
|
41
27
|
}
|
|
28
|
+
// Pass the daemon's auth so MCP tools can identify the per-call principal
|
|
29
|
+
// from request headers (4.0 · P4). stdio (startMcpStdioServer) omits this,
|
|
30
|
+
// so stdio calls resolve to the local principal.
|
|
42
31
|
const auth = b.getAuthOptions();
|
|
32
|
+
const sessions = new Map();
|
|
33
|
+
function newSession() {
|
|
34
|
+
const server = createMcpServer(bridge, { experimentalEnvVar: opts.experimentalEnvVar, auth });
|
|
35
|
+
const transport = new StreamableHTTPServerTransport({
|
|
36
|
+
sessionIdGenerator: stateful ? () => randomUUID() : undefined,
|
|
37
|
+
// null → resumability off; an explicit store → use it; default → a
|
|
38
|
+
// fresh per-session MemoryEventStore.
|
|
39
|
+
eventStore: opts.eventStore === null ? undefined : (opts.eventStore ?? new MemoryEventStore()),
|
|
40
|
+
onsessioninitialized: (sid) => {
|
|
41
|
+
sessions.set(sid, { transport, server });
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
transport.onclose = () => {
|
|
45
|
+
const sid = transport.sessionId;
|
|
46
|
+
if (sid)
|
|
47
|
+
sessions.delete(sid);
|
|
48
|
+
};
|
|
49
|
+
return { transport, server };
|
|
50
|
+
}
|
|
43
51
|
b.prependHttpHandler(async (req, res) => {
|
|
44
52
|
const url = req.url ?? '';
|
|
45
53
|
const qi = url.indexOf('?');
|
|
46
54
|
const reqPath = qi < 0 ? url : url.slice(0, qi);
|
|
47
55
|
if (reqPath !== path)
|
|
48
56
|
return false;
|
|
49
|
-
//
|
|
50
|
-
//
|
|
57
|
+
// Per-call caller for command-target scoping (4.0 · A): every sendCommand
|
|
58
|
+
// within this request reads it via currentCaller().
|
|
51
59
|
const principal = identifyPrincipal(req.headers, auth);
|
|
52
|
-
|
|
60
|
+
const sid = req.headers['mcp-session-id'];
|
|
61
|
+
// Established session — route by id (POST follow-ups, GET SSE, DELETE).
|
|
62
|
+
if (typeof sid === 'string' && sessions.has(sid)) {
|
|
63
|
+
const { transport } = sessions.get(sid);
|
|
64
|
+
await runWithCaller(principal, () => transport.handleRequest(req, res));
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
// No (known) session id. A POST `initialize` opens one; anything else is invalid.
|
|
68
|
+
if (req.method === 'POST') {
|
|
69
|
+
let body;
|
|
70
|
+
try {
|
|
71
|
+
body = await readJsonBody(req);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
sendMcpError(res, 400, -32700, 'Parse error');
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (stateful && !isInitializeRequest(body)) {
|
|
78
|
+
sendMcpError(res, 400, -32600, 'Bad Request: no valid mcp-session-id (initialize first)');
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
const { server, transport } = newSession();
|
|
82
|
+
await server.connect(transport);
|
|
83
|
+
if (!stateful) {
|
|
84
|
+
// Stateless one-shot: tear down when the response ends.
|
|
85
|
+
res.on('close', () => {
|
|
86
|
+
void transport.close();
|
|
87
|
+
void server.close();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
await runWithCaller(principal, () => transport.handleRequest(req, res, body));
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
// GET/DELETE without a known session — nothing to attach to.
|
|
94
|
+
sendMcpError(res, 400, -32600, 'Bad Request: unknown or missing mcp-session-id');
|
|
53
95
|
return true;
|
|
54
96
|
});
|
|
55
97
|
return {
|
|
56
98
|
path,
|
|
57
99
|
async close() {
|
|
58
|
-
|
|
100
|
+
for (const { server } of sessions.values()) {
|
|
101
|
+
await server.close().catch(() => undefined);
|
|
102
|
+
}
|
|
103
|
+
sessions.clear();
|
|
59
104
|
},
|
|
60
105
|
};
|
|
61
106
|
}
|
|
107
|
+
async function readJsonBody(req) {
|
|
108
|
+
const chunks = [];
|
|
109
|
+
let total = 0;
|
|
110
|
+
const MAX = 4 * 1024 * 1024; // 4 MiB cap — MCP requests are small
|
|
111
|
+
for await (const c of req) {
|
|
112
|
+
const buf = c;
|
|
113
|
+
total += buf.length;
|
|
114
|
+
if (total > MAX)
|
|
115
|
+
throw new Error('mcp body too large');
|
|
116
|
+
chunks.push(buf);
|
|
117
|
+
}
|
|
118
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
119
|
+
return raw ? JSON.parse(raw) : undefined;
|
|
120
|
+
}
|
|
121
|
+
function sendMcpError(res, status, code, message) {
|
|
122
|
+
res.statusCode = status;
|
|
123
|
+
res.setHeader('content-type', 'application/json');
|
|
124
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id: null }));
|
|
125
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harness-fe/mcp-server",
|
|
3
|
-
"version": "4.0.0-next.
|
|
3
|
+
"version": "4.0.0-next.4",
|
|
4
4
|
"description": "Unified MCP daemon: stdio MCP for AI agents + WS bridge for Vite plugin and runtime client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
38
38
|
"ws": "^8.18.0",
|
|
39
39
|
"zod": "^4.4.3",
|
|
40
|
-
"@harness-fe/
|
|
41
|
-
"@harness-fe/
|
|
40
|
+
"@harness-fe/daemon": "4.0.0-next.4",
|
|
41
|
+
"@harness-fe/protocol": "4.0.0-next.4"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/ws": "^8.5.10",
|
package/src/mcp.ts
CHANGED
|
@@ -777,7 +777,7 @@ function registerTools(server: McpServer, bridge: IBridge, auth?: AuthOptions):
|
|
|
777
777
|
const store = (bridge as Bridge).store;
|
|
778
778
|
const all = await bridge.listTasks({ status: status ?? 'pending', limit });
|
|
779
779
|
const tasks = store
|
|
780
|
-
? all.filter((t) => canSeeProject(principal, ownerChainOf(t.projectId, store)))
|
|
780
|
+
? all.filter((t) => canSeeProject(principal, t.projectId, ownerChainOf(t.projectId, store)))
|
|
781
781
|
: all.filter((t) => canSee(principal, t.createdBy));
|
|
782
782
|
const summary = tasks.map((t) => ({
|
|
783
783
|
id: t.id,
|
|
@@ -818,15 +818,35 @@ function registerTools(server: McpServer, bridge: IBridge, auth?: AuthOptions):
|
|
|
818
818
|
COMMAND.TASKS_RESOLVE,
|
|
819
819
|
{
|
|
820
820
|
description:
|
|
821
|
-
'Mark a task as resolved with an optional note
|
|
821
|
+
'Mark a task as resolved with an optional note and structured resolution. ' +
|
|
822
|
+
'Use after addressing the user request. Pass `resolution` to close the ' +
|
|
823
|
+
'feedback loop: how it was fixed (type), the fix commit/PR, and the ' +
|
|
824
|
+
'verificationSessionId of the post-fix re-test that proved the fix.',
|
|
822
825
|
inputSchema: {
|
|
823
826
|
taskId: z.string(),
|
|
824
827
|
note: z.string().optional(),
|
|
828
|
+
resolution: z
|
|
829
|
+
.object({
|
|
830
|
+
type: z
|
|
831
|
+
.enum(['code-fix', 'config', 'wontfix', 'duplicate', 'cannot-reproduce'])
|
|
832
|
+
.optional(),
|
|
833
|
+
commit: z.string().optional().describe('Git commit SHA of the fix.'),
|
|
834
|
+
prUrl: z.string().optional().describe('Pull-request URL for the fix.'),
|
|
835
|
+
verificationSessionId: z
|
|
836
|
+
.string()
|
|
837
|
+
.optional()
|
|
838
|
+
.describe('Session id of the post-fix re-test that verified the fix.'),
|
|
839
|
+
verifiedAt: z
|
|
840
|
+
.number()
|
|
841
|
+
.optional()
|
|
842
|
+
.describe('Epoch ms; defaulted when verificationSessionId is given.'),
|
|
843
|
+
})
|
|
844
|
+
.optional(),
|
|
825
845
|
},
|
|
826
846
|
},
|
|
827
|
-
async ({ taskId, note }, extra) => {
|
|
847
|
+
async ({ taskId, note, resolution }, extra) => {
|
|
828
848
|
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
829
|
-
const task = await bridge.resolveTask(taskId, note, principal);
|
|
849
|
+
const task = await bridge.resolveTask(taskId, note, resolution, principal);
|
|
830
850
|
if (!task) {
|
|
831
851
|
throw new Error(`tasks.resolve: no task with id "${taskId}"`);
|
|
832
852
|
}
|
|
@@ -905,7 +925,7 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
|
|
|
905
925
|
async ({ projectId, limit }, extra) => {
|
|
906
926
|
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
907
927
|
// Owning a project (or a host ancestor) grants its whole session set.
|
|
908
|
-
if (!canSeeProject(principal, ownerChainOf(projectId, store))) return ok([]);
|
|
928
|
+
if (!canSeeProject(principal, projectId, ownerChainOf(projectId, store))) return ok([]);
|
|
909
929
|
return ok(store.listSessions({ projectId, limit: limit ?? 10 }));
|
|
910
930
|
},
|
|
911
931
|
);
|
|
@@ -996,7 +1016,7 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
|
|
|
996
1016
|
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
997
1017
|
const projects = store
|
|
998
1018
|
.listProjects()
|
|
999
|
-
.filter((p) => canSeeProject(principal, ownerChainOf(p.id, store)));
|
|
1019
|
+
.filter((p) => canSeeProject(principal, p.id, ownerChainOf(p.id, store)));
|
|
1000
1020
|
const result = projects.map((p) => ({
|
|
1001
1021
|
...p,
|
|
1002
1022
|
recentSessions: store.listSessions({ projectId: p.id, limit: 3 }),
|
package/src/mcpHttp.test.ts
CHANGED
|
@@ -99,6 +99,55 @@ describe('mcpHttp', () => {
|
|
|
99
99
|
expect(withAuth.status).not.toBe(401);
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
it('supports multiple concurrent clients (per-session transports)', async () => {
|
|
103
|
+
// Regression: the old single shared transport threw "Server already
|
|
104
|
+
// initialized" on the 2nd initialize, blocking multi-agent (gateway) use
|
|
105
|
+
// and any reconnect. Per-session transports must let each client init.
|
|
106
|
+
const bridge = await startBridge();
|
|
107
|
+
const handle = await startMcpHttpServer(bridge, { path: '/mcp' });
|
|
108
|
+
cleanups.push(() => handle.close());
|
|
109
|
+
const port = bridge.getBoundPort()!;
|
|
110
|
+
|
|
111
|
+
const headers = {
|
|
112
|
+
'content-type': 'application/json',
|
|
113
|
+
accept: 'application/json, text/event-stream',
|
|
114
|
+
};
|
|
115
|
+
const initBody = (name: string) =>
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
jsonrpc: '2.0',
|
|
118
|
+
method: 'initialize',
|
|
119
|
+
params: {
|
|
120
|
+
protocolVersion: '2025-06-18',
|
|
121
|
+
capabilities: {},
|
|
122
|
+
clientInfo: { name, version: '1' },
|
|
123
|
+
},
|
|
124
|
+
id: 1,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const r1 = await fetch(`http://127.0.0.1:${port}/mcp`, { method: 'POST', headers, body: initBody('c1') });
|
|
128
|
+
await r1.text();
|
|
129
|
+
const sid1 = r1.headers.get('mcp-session-id');
|
|
130
|
+
expect(r1.status).toBe(200);
|
|
131
|
+
expect(sid1).toBeTruthy();
|
|
132
|
+
|
|
133
|
+
const r2 = await fetch(`http://127.0.0.1:${port}/mcp`, { method: 'POST', headers, body: initBody('c2') });
|
|
134
|
+
await r2.text();
|
|
135
|
+
const sid2 = r2.headers.get('mcp-session-id');
|
|
136
|
+
expect(r2.status).toBe(200);
|
|
137
|
+
expect(sid2).toBeTruthy();
|
|
138
|
+
// Distinct sessions — the whole point of per-session transports.
|
|
139
|
+
expect(sid2).not.toBe(sid1);
|
|
140
|
+
|
|
141
|
+
// A request carrying an unknown session id is rejected (not silently
|
|
142
|
+
// attached to some shared transport).
|
|
143
|
+
const bad = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { ...headers, 'mcp-session-id': 'does-not-exist' },
|
|
146
|
+
body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', id: 2 }),
|
|
147
|
+
});
|
|
148
|
+
expect(bad.status).toBe(400);
|
|
149
|
+
});
|
|
150
|
+
|
|
102
151
|
// SSE Last-Event-ID resumption — end-to-end wiring proof.
|
|
103
152
|
//
|
|
104
153
|
// What this asserts:
|
package/src/mcpHttp.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { randomUUID } from 'node:crypto';
|
|
11
11
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
12
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
12
13
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
13
14
|
import type { Bridge, IBridge } from '@harness-fe/daemon';
|
|
14
15
|
import { createMcpServer } from './mcp.js';
|
|
@@ -58,48 +59,120 @@ export async function startMcpHttpServer(
|
|
|
58
59
|
): Promise<McpHttpHandle> {
|
|
59
60
|
const path = opts.path ?? '/mcp';
|
|
60
61
|
const stateful = opts.stateful !== false;
|
|
61
|
-
const eventStore =
|
|
62
|
-
opts.eventStore === null
|
|
63
|
-
? undefined
|
|
64
|
-
: opts.eventStore ?? new MemoryEventStore();
|
|
65
|
-
|
|
66
|
-
// Pass the daemon's auth so MCP tools can identify the per-call principal
|
|
67
|
-
// from request headers (4.0 · P4). stdio (startMcpStdioServer) omits this,
|
|
68
|
-
// so stdio calls resolve to the local principal.
|
|
69
|
-
const server = createMcpServer(bridge, {
|
|
70
|
-
experimentalEnvVar: opts.experimentalEnvVar,
|
|
71
|
-
auth: (bridge as Bridge).getAuthOptions(),
|
|
72
|
-
});
|
|
73
|
-
const transport = new StreamableHTTPServerTransport({
|
|
74
|
-
sessionIdGenerator: stateful ? () => randomUUID() : undefined,
|
|
75
|
-
eventStore,
|
|
76
|
-
});
|
|
77
|
-
await server.connect(transport);
|
|
78
|
-
|
|
79
62
|
const b = bridge as Bridge;
|
|
80
63
|
if (typeof b.prependHttpHandler !== 'function') {
|
|
81
64
|
throw new Error(
|
|
82
65
|
'mcpHttp: bridge does not support prependHttpHandler (need a Bridge instance with HTTP server)',
|
|
83
66
|
);
|
|
84
67
|
}
|
|
85
|
-
|
|
68
|
+
// Pass the daemon's auth so MCP tools can identify the per-call principal
|
|
69
|
+
// from request headers (4.0 · P4). stdio (startMcpStdioServer) omits this,
|
|
70
|
+
// so stdio calls resolve to the local principal.
|
|
86
71
|
const auth = b.getAuthOptions();
|
|
72
|
+
|
|
73
|
+
// Per-session transports (4.0) — the MCP HTTP spec's stateful model: each
|
|
74
|
+
// client gets its own transport + server keyed by `mcp-session-id`, created
|
|
75
|
+
// on `initialize`. The old shape shared ONE transport for the whole daemon,
|
|
76
|
+
// which locked after the first initialize — a second agent (or any reconnect)
|
|
77
|
+
// hit "Server already initialized". Per-session is what lets multiple agents
|
|
78
|
+
// share one daemon through the gateway, and lets a client reconnect.
|
|
79
|
+
type Session = { transport: StreamableHTTPServerTransport; server: ReturnType<typeof createMcpServer> };
|
|
80
|
+
const sessions = new Map<string, Session>();
|
|
81
|
+
|
|
82
|
+
function newSession(): Session {
|
|
83
|
+
const server = createMcpServer(bridge, { experimentalEnvVar: opts.experimentalEnvVar, auth });
|
|
84
|
+
const transport = new StreamableHTTPServerTransport({
|
|
85
|
+
sessionIdGenerator: stateful ? () => randomUUID() : undefined,
|
|
86
|
+
// null → resumability off; an explicit store → use it; default → a
|
|
87
|
+
// fresh per-session MemoryEventStore.
|
|
88
|
+
eventStore: opts.eventStore === null ? undefined : (opts.eventStore ?? new MemoryEventStore()),
|
|
89
|
+
onsessioninitialized: (sid: string) => {
|
|
90
|
+
sessions.set(sid, { transport, server });
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
transport.onclose = () => {
|
|
94
|
+
const sid = transport.sessionId;
|
|
95
|
+
if (sid) sessions.delete(sid);
|
|
96
|
+
};
|
|
97
|
+
return { transport, server };
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
b.prependHttpHandler(async (req: IncomingMessage, res: ServerResponse) => {
|
|
88
101
|
const url = req.url ?? '';
|
|
89
102
|
const qi = url.indexOf('?');
|
|
90
103
|
const reqPath = qi < 0 ? url : url.slice(0, qi);
|
|
91
104
|
if (reqPath !== path) return false;
|
|
92
|
-
|
|
93
|
-
//
|
|
105
|
+
|
|
106
|
+
// Per-call caller for command-target scoping (4.0 · A): every sendCommand
|
|
107
|
+
// within this request reads it via currentCaller().
|
|
94
108
|
const principal = identifyPrincipal(req.headers, auth);
|
|
95
|
-
|
|
109
|
+
const sid = req.headers['mcp-session-id'];
|
|
110
|
+
|
|
111
|
+
// Established session — route by id (POST follow-ups, GET SSE, DELETE).
|
|
112
|
+
if (typeof sid === 'string' && sessions.has(sid)) {
|
|
113
|
+
const { transport } = sessions.get(sid)!;
|
|
114
|
+
await runWithCaller(principal, () => transport.handleRequest(req, res));
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// No (known) session id. A POST `initialize` opens one; anything else is invalid.
|
|
119
|
+
if (req.method === 'POST') {
|
|
120
|
+
let body: unknown;
|
|
121
|
+
try {
|
|
122
|
+
body = await readJsonBody(req);
|
|
123
|
+
} catch {
|
|
124
|
+
sendMcpError(res, 400, -32700, 'Parse error');
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
if (stateful && !isInitializeRequest(body)) {
|
|
128
|
+
sendMcpError(res, 400, -32600, 'Bad Request: no valid mcp-session-id (initialize first)');
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
const { server, transport } = newSession();
|
|
132
|
+
await server.connect(transport);
|
|
133
|
+
if (!stateful) {
|
|
134
|
+
// Stateless one-shot: tear down when the response ends.
|
|
135
|
+
res.on('close', () => {
|
|
136
|
+
void transport.close();
|
|
137
|
+
void server.close();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
await runWithCaller(principal, () => transport.handleRequest(req, res, body));
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// GET/DELETE without a known session — nothing to attach to.
|
|
145
|
+
sendMcpError(res, 400, -32600, 'Bad Request: unknown or missing mcp-session-id');
|
|
96
146
|
return true;
|
|
97
147
|
});
|
|
98
148
|
|
|
99
149
|
return {
|
|
100
150
|
path,
|
|
101
151
|
async close() {
|
|
102
|
-
|
|
152
|
+
for (const { server } of sessions.values()) {
|
|
153
|
+
await server.close().catch(() => undefined);
|
|
154
|
+
}
|
|
155
|
+
sessions.clear();
|
|
103
156
|
},
|
|
104
157
|
};
|
|
105
158
|
}
|
|
159
|
+
|
|
160
|
+
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
161
|
+
const chunks: Buffer[] = [];
|
|
162
|
+
let total = 0;
|
|
163
|
+
const MAX = 4 * 1024 * 1024; // 4 MiB cap — MCP requests are small
|
|
164
|
+
for await (const c of req) {
|
|
165
|
+
const buf = c as Buffer;
|
|
166
|
+
total += buf.length;
|
|
167
|
+
if (total > MAX) throw new Error('mcp body too large');
|
|
168
|
+
chunks.push(buf);
|
|
169
|
+
}
|
|
170
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
171
|
+
return raw ? (JSON.parse(raw) as unknown) : undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function sendMcpError(res: ServerResponse, status: number, code: number, message: string): void {
|
|
175
|
+
res.statusCode = status;
|
|
176
|
+
res.setHeader('content-type', 'application/json');
|
|
177
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id: null }));
|
|
178
|
+
}
|