@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 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. Use after addressing the user request.',
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
- // Establish the per-call caller for command-target scoping (4.0 · A):
50
- // every sendCommand within this request reads it via currentCaller().
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
- await runWithCaller(principal, () => transport.handleRequest(req, res));
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
- await server.close();
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",
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/protocol": "4.0.0-next.0",
41
- "@harness-fe/daemon": "4.0.0-next.3"
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. Use after addressing the user request.',
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 }),
@@ -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
- // Establish the per-call caller for command-target scoping (4.0 · A):
93
- // every sendCommand within this request reads it via currentCaller().
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
- await runWithCaller(principal, () => transport.handleRequest(req, res));
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
- await server.close();
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
+ }