@harness-fe/mcp-server 4.0.0-next.2 → 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.
Files changed (100) hide show
  1. package/dist/bin.d.ts +2 -0
  2. package/dist/bin.js +15 -0
  3. package/dist/daemon.d.ts +3 -3
  4. package/dist/daemon.js +1 -1
  5. package/dist/index.d.ts +4 -4
  6. package/dist/index.js +3 -3
  7. package/dist/mcp.d.ts +2 -2
  8. package/dist/mcp.js +65 -19
  9. package/dist/mcpHttp.d.ts +2 -2
  10. package/dist/mcpHttp.js +88 -18
  11. package/package.json +5 -7
  12. package/src/bin.ts +19 -0
  13. package/src/daemon.ts +3 -3
  14. package/src/experimental.test.ts +2 -2
  15. package/src/index.ts +4 -4
  16. package/src/mcp.ts +67 -23
  17. package/src/mcpHttp.test.ts +52 -3
  18. package/src/mcpHttp.ts +102 -23
  19. package/src/mcpLayer.e2e.test.ts +2 -2
  20. package/src/newCapabilities.e2e.test.ts +3 -3
  21. package/dist/auth.d.ts +0 -53
  22. package/dist/auth.js +0 -212
  23. package/dist/bridge.d.ts +0 -323
  24. package/dist/bridge.js +0 -1618
  25. package/dist/cli.d.ts +0 -18
  26. package/dist/cli.js +0 -293
  27. package/dist/dashboardApi.d.ts +0 -40
  28. package/dist/dashboardApi.js +0 -142
  29. package/dist/dashboardSpa.d.ts +0 -18
  30. package/dist/dashboardSpa.js +0 -180
  31. package/dist/dashboardUrl.d.ts +0 -13
  32. package/dist/dashboardUrl.js +0 -18
  33. package/dist/eventsHandler.d.ts +0 -24
  34. package/dist/eventsHandler.js +0 -114
  35. package/dist/identity.d.ts +0 -90
  36. package/dist/identity.js +0 -123
  37. package/dist/openBrowser.d.ts +0 -33
  38. package/dist/openBrowser.js +0 -63
  39. package/dist/remoteBridge.d.ts +0 -61
  40. package/dist/remoteBridge.js +0 -307
  41. package/dist/replayCreate.d.ts +0 -36
  42. package/dist/replayCreate.js +0 -156
  43. package/dist/replayViewer.d.ts +0 -20
  44. package/dist/replayViewer.js +0 -168
  45. package/dist/sessionRouter.d.ts +0 -45
  46. package/dist/sessionRouter.js +0 -88
  47. package/dist/store/JsonMemoryStore.d.ts +0 -52
  48. package/dist/store/JsonMemoryStore.js +0 -119
  49. package/dist/store/JsonTaskStore.d.ts +0 -21
  50. package/dist/store/JsonTaskStore.js +0 -53
  51. package/dist/store/JsonlStore.d.ts +0 -128
  52. package/dist/store/JsonlStore.js +0 -1172
  53. package/dist/store/MemoryEventStore.d.ts +0 -47
  54. package/dist/store/MemoryEventStore.js +0 -111
  55. package/dist/store/WriteQueue.d.ts +0 -51
  56. package/dist/store/WriteQueue.js +0 -142
  57. package/dist/store/index.d.ts +0 -6
  58. package/dist/store/index.js +0 -5
  59. package/dist/store/types.d.ts +0 -427
  60. package/dist/store/types.js +0 -19
  61. package/dist/visitorTimeline.d.ts +0 -24
  62. package/dist/visitorTimeline.js +0 -68
  63. package/src/auth.test.ts +0 -90
  64. package/src/auth.ts +0 -248
  65. package/src/bridge-auth.test.ts +0 -196
  66. package/src/bridge.test.ts +0 -1708
  67. package/src/bridge.ts +0 -1854
  68. package/src/cli.ts +0 -338
  69. package/src/dashboardApi.test.ts +0 -235
  70. package/src/dashboardApi.ts +0 -184
  71. package/src/dashboardSpa.test.ts +0 -239
  72. package/src/dashboardSpa.ts +0 -195
  73. package/src/dashboardUrl.test.ts +0 -46
  74. package/src/dashboardUrl.ts +0 -28
  75. package/src/eventsHandler.test.ts +0 -247
  76. package/src/eventsHandler.ts +0 -136
  77. package/src/identity.test.ts +0 -109
  78. package/src/identity.ts +0 -137
  79. package/src/openBrowser.test.ts +0 -103
  80. package/src/openBrowser.ts +0 -81
  81. package/src/remoteBridge.test.ts +0 -119
  82. package/src/remoteBridge.ts +0 -404
  83. package/src/replay.test.ts +0 -271
  84. package/src/replayCreate.ts +0 -194
  85. package/src/replayViewer.ts +0 -173
  86. package/src/sessionRouter.ts +0 -119
  87. package/src/store/JsonMemoryStore.test.ts +0 -175
  88. package/src/store/JsonMemoryStore.ts +0 -128
  89. package/src/store/JsonTaskStore.test.ts +0 -212
  90. package/src/store/JsonTaskStore.ts +0 -59
  91. package/src/store/JsonlStore.test.ts +0 -1538
  92. package/src/store/JsonlStore.ts +0 -1325
  93. package/src/store/MemoryEventStore.test.ts +0 -119
  94. package/src/store/MemoryEventStore.ts +0 -151
  95. package/src/store/WriteQueue.ts +0 -165
  96. package/src/store/identityTagging.test.ts +0 -67
  97. package/src/store/index.ts +0 -29
  98. package/src/store/types.ts +0 -532
  99. package/src/visitorTimeline.test.ts +0 -197
  100. package/src/visitorTimeline.ts +0 -89
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Compat shim: the `harness-fe` CLI moved to @harness-fe/dev-cli when the
4
+ * monolith was split (daemon / mcp-server / dev-cli). We keep this bin so the
5
+ * old `npx @harness-fe/mcp-server` keeps working, but forward to dev-cli via
6
+ * npx at runtime — a static dependency would create an mcp-server ↔ dev-cli
7
+ * cycle (dev-cli already depends on mcp-server).
8
+ */
9
+ import { spawnSync } from 'node:child_process';
10
+ process.stderr.write('[harness-fe] The CLI moved to @harness-fe/dev-cli; forwarding. ' +
11
+ 'Run `npx @harness-fe/dev-cli` directly to skip this hop.\n');
12
+ const result = spawnSync('npx', ['-y', '@harness-fe/dev-cli', ...process.argv.slice(2)], {
13
+ stdio: 'inherit',
14
+ });
15
+ process.exit(result.status ?? 0);
package/dist/daemon.d.ts CHANGED
@@ -24,9 +24,9 @@
24
24
  */
25
25
  import type { IncomingMessage } from 'node:http';
26
26
  import type { ConsentPolicy } from '@harness-fe/protocol';
27
- import { Bridge } from './bridge.js';
28
- import type { EventStore, IStore } from './store/types.js';
29
- import type { ITaskStore, IMemoryStore } from './store/types.js';
27
+ import { Bridge } from '@harness-fe/daemon';
28
+ import type { EventStore, IStore } from '@harness-fe/daemon';
29
+ import type { ITaskStore, IMemoryStore } from '@harness-fe/daemon';
30
30
  export interface DaemonOptions {
31
31
  /** TCP port to listen on. Default `DEFAULT_WS_PORT` (see protocol). */
32
32
  port?: number;
package/dist/daemon.js CHANGED
@@ -22,7 +22,7 @@
22
22
  * follower attachment, banner, signal handlers) stays in `cli.ts` —
23
23
  * not pushed into the factory.
24
24
  */
25
- import { Bridge } from './bridge.js';
25
+ import { Bridge } from '@harness-fe/daemon';
26
26
  import { startMcpHttpServer } from './mcpHttp.js';
27
27
  export function createDaemon(opts = {}) {
28
28
  const mcpPath = opts.mcpPath ?? '/mcp';
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- export { Bridge, defaultDataDir, type BridgeOptions } from './bridge.js';
1
+ export { Bridge, defaultDataDir, type BridgeOptions } from '@harness-fe/daemon';
2
2
  export { createDaemon, type DaemonOptions, type DaemonHandle } from './daemon.js';
3
- export { SessionRouter, type PeerSession } from './sessionRouter.js';
3
+ export { SessionRouter, type PeerSession } from '@harness-fe/daemon';
4
4
  export { startMcpStdioServer, createMcpServer, experimentalEnabled, type McpServerOptions, } from './mcp.js';
5
5
  export { startMcpHttpServer, type McpHttpOptions, type McpHttpHandle } from './mcpHttp.js';
6
- export { JsonlStore, JsonTaskStore, JsonMemoryStore, MemoryEventStore, sanitizeId, type MemoryEventStoreOptions, } from './store/index.js';
7
- export type { IStore, ITaskStore, IMemoryStore, EventStore, EventId, StreamId, ProjectMeta, ProjectTreeNode, BuildMeta, SessionMeta, TabMeta, } from './store/index.js';
6
+ export { JsonlStore, JsonTaskStore, JsonMemoryStore, MemoryEventStore, sanitizeId, type MemoryEventStoreOptions, } from '@harness-fe/daemon';
7
+ export type { IStore, ITaskStore, IMemoryStore, EventStore, EventId, StreamId, ProjectMeta, ProjectTreeNode, BuildMeta, SessionMeta, TabMeta, } from '@harness-fe/daemon';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- export { Bridge, defaultDataDir } from './bridge.js';
1
+ export { Bridge, defaultDataDir } from '@harness-fe/daemon';
2
2
  export { createDaemon } from './daemon.js';
3
- export { SessionRouter } from './sessionRouter.js';
3
+ export { SessionRouter } from '@harness-fe/daemon';
4
4
  export { startMcpStdioServer, createMcpServer, experimentalEnabled, } from './mcp.js';
5
5
  export { startMcpHttpServer } from './mcpHttp.js';
6
- export { JsonlStore, JsonTaskStore, JsonMemoryStore, MemoryEventStore, sanitizeId, } from './store/index.js';
6
+ export { JsonlStore, JsonTaskStore, JsonMemoryStore, MemoryEventStore, sanitizeId, } from '@harness-fe/daemon';
package/dist/mcp.d.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  * to the active runtime-client via the bridge.
7
7
  */
8
8
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
- import type { IBridge } from './bridge.js';
10
- import type { AuthOptions } from './auth.js';
9
+ import type { IBridge } from '@harness-fe/daemon';
10
+ import type { AuthOptions } from '@harness-fe/daemon';
11
11
  export interface McpServerOptions {
12
12
  /**
13
13
  * Name of the environment variable that gates experimental tools.
package/dist/mcp.js CHANGED
@@ -9,12 +9,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
9
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
10
  import { z } from 'zod';
11
11
  import { COMMAND, PROTOCOL_VERSION, clickArgsSchema, evaluateArgsSchema, navigateArgsSchema, reloadArgsSchema, screenshotArgsSchema, scrollArgsSchema, setHtmlArgsSchema, setStyleArgsSchema, selectorSchema, typeArgsSchema, waitForArgsSchema, } from '@harness-fe/protocol';
12
- import { canSee, identifyPrincipal } from './identity.js';
13
- import { RemoteBridge } from './remoteBridge.js';
14
- import { buildVisitorTimeline } from './visitorTimeline.js';
15
- import { createReplayExport } from './replayCreate.js';
16
- import { openBrowser } from './openBrowser.js';
17
- import { buildDashboardUrl } from './dashboardUrl.js';
12
+ import { canSee, canSeeProject, identifyPrincipal } from '@harness-fe/daemon';
13
+ import { RemoteBridge } from '@harness-fe/daemon';
14
+ import { buildVisitorTimeline } from '@harness-fe/daemon';
15
+ import { createReplayExport } from '@harness-fe/daemon';
16
+ import { openBrowser } from '@harness-fe/daemon';
17
+ import { buildDashboardUrl } from '@harness-fe/daemon';
18
18
  const SERVER_NAME = 'harness-fe';
19
19
  /**
20
20
  * Experimental-feature gate.
@@ -88,6 +88,26 @@ function err(message) {
88
88
  isError: true,
89
89
  };
90
90
  }
91
+ /**
92
+ * Owner chain of a project for tenant isolation (4.0 · A — binding/tagging):
93
+ * the project's own `createdBy` followed by its ancestors' (walked via
94
+ * `parentProjectId`, self → root, cycle-safe). Feed to `canSeeProject` so a
95
+ * host agent sees its sub-apps' data. Empty when the project is unknown.
96
+ */
97
+ function ownerChainOf(projectId, store) {
98
+ const chain = [];
99
+ const seen = new Set();
100
+ let id = projectId;
101
+ while (id && !seen.has(id)) {
102
+ seen.add(id);
103
+ const p = store.getProject(id);
104
+ if (!p)
105
+ break;
106
+ chain.push(p.createdBy);
107
+ id = p.parentProjectId;
108
+ }
109
+ return chain;
110
+ }
91
111
  function registerTools(server, bridge, auth) {
92
112
  server.registerTool(COMMAND.PAGE_CLICK, {
93
113
  description: 'Click on a DOM element resolved by the selector.',
@@ -462,8 +482,14 @@ function registerTools(server, bridge, auth) {
462
482
  },
463
483
  }, async ({ status, limit }, extra) => {
464
484
  const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
465
- const tasks = (await bridge.listTasks({ status: status ?? 'pending', limit }))
466
- .filter((t) => canSee(principal, t.createdBy));
485
+ // Tenant isolation by project ownership (4.0 · A): a task is visible
486
+ // when the caller owns its project (or a host ancestor). Falls back
487
+ // to the submitter tag when no store is configured (in-memory mode).
488
+ const store = bridge.store;
489
+ const all = await bridge.listTasks({ status: status ?? 'pending', limit });
490
+ const tasks = store
491
+ ? all.filter((t) => canSeeProject(principal, t.projectId, ownerChainOf(t.projectId, store)))
492
+ : all.filter((t) => canSee(principal, t.createdBy));
467
493
  const summary = tasks.map((t) => ({
468
494
  id: t.id,
469
495
  status: t.status,
@@ -492,14 +518,34 @@ function registerTools(server, bridge, auth) {
492
518
  return ok(task);
493
519
  });
494
520
  server.registerTool(COMMAND.TASKS_RESOLVE, {
495
- 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.',
496
525
  inputSchema: {
497
526
  taskId: z.string(),
498
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(),
499
545
  },
500
- }, async ({ taskId, note }, extra) => {
546
+ }, async ({ taskId, note, resolution }, extra) => {
501
547
  const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
502
- const task = await bridge.resolveTask(taskId, note, principal);
548
+ const task = await bridge.resolveTask(taskId, note, resolution, principal);
503
549
  if (!task) {
504
550
  throw new Error(`tasks.resolve: no task with id "${taskId}"`);
505
551
  }
@@ -558,10 +604,10 @@ function registerStoreTools(server, store, memoryStore, bridge, auth) {
558
604
  },
559
605
  }, async ({ projectId, limit }, extra) => {
560
606
  const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
561
- const sessions = store
562
- .listSessions({ projectId, limit: limit ?? 10 })
563
- .filter((s) => canSee(principal, s.createdBy));
564
- return ok(sessions);
607
+ // Owning a project (or a host ancestor) grants its whole session set.
608
+ if (!canSeeProject(principal, projectId, ownerChainOf(projectId, store)))
609
+ return ok([]);
610
+ return ok(store.listSessions({ projectId, limit: limit ?? 10 }));
565
611
  });
566
612
  server.registerTool('session.summary', {
567
613
  description: 'Get a summary of a session: event counts, last error, active tabs.',
@@ -631,12 +677,12 @@ function registerStoreTools(server, store, memoryStore, bridge, auth) {
631
677
  inputSchema: {},
632
678
  }, async (_args, extra) => {
633
679
  const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
634
- const projects = store.listProjects().filter((p) => canSee(principal, p.createdBy));
680
+ const projects = store
681
+ .listProjects()
682
+ .filter((p) => canSeeProject(principal, p.id, ownerChainOf(p.id, store)));
635
683
  const result = projects.map((p) => ({
636
684
  ...p,
637
- recentSessions: store
638
- .listSessions({ projectId: p.id, limit: 3 })
639
- .filter((s) => canSee(principal, s.createdBy)),
685
+ recentSessions: store.listSessions({ projectId: p.id, limit: 3 }),
640
686
  }));
641
687
  return ok(result);
642
688
  });
package/dist/mcpHttp.d.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  * `http://<host>:<port>/mcp` and authenticate via `Authorization: Bearer
7
7
  * <token>` like any other client.
8
8
  */
9
- import type { IBridge } from './bridge.js';
10
- import type { EventStore } from './store/types.js';
9
+ import type { IBridge } from '@harness-fe/daemon';
10
+ import type { EventStore } from '@harness-fe/daemon';
11
11
  export interface McpHttpOptions {
12
12
  /** URL path the transport listens on. Default `/mcp`. */
13
13
  path?: string;
package/dist/mcpHttp.js CHANGED
@@ -8,8 +8,11 @@
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
- import { MemoryEventStore } from './store/MemoryEventStore.js';
13
+ import { identifyPrincipal } from '@harness-fe/daemon';
14
+ import { runWithCaller } from '@harness-fe/daemon';
15
+ import { MemoryEventStore } from '@harness-fe/daemon';
13
16
  /**
14
17
  * Mount the MCP HTTP transport on the bridge's HTTP server. Bridge must
15
18
  * already have been started; calls `prependHttpHandler` so it runs before
@@ -18,38 +21,105 @@ import { MemoryEventStore } from './store/MemoryEventStore.js';
18
21
  export async function startMcpHttpServer(bridge, opts = {}) {
19
22
  const path = opts.path ?? '/mcp';
20
23
  const stateful = opts.stateful !== false;
21
- const eventStore = opts.eventStore === null
22
- ? undefined
23
- : opts.eventStore ?? new MemoryEventStore();
24
- // Pass the daemon's auth so MCP tools can identify the per-call principal
25
- // from request headers (4.0 · P4). stdio (startMcpStdioServer) omits this,
26
- // so stdio calls resolve to the local principal.
27
- const server = createMcpServer(bridge, {
28
- experimentalEnvVar: opts.experimentalEnvVar,
29
- auth: bridge.getAuthOptions(),
30
- });
31
- const transport = new StreamableHTTPServerTransport({
32
- sessionIdGenerator: stateful ? () => randomUUID() : undefined,
33
- eventStore,
34
- });
35
- await server.connect(transport);
36
24
  const b = bridge;
37
25
  if (typeof b.prependHttpHandler !== 'function') {
38
26
  throw new Error('mcpHttp: bridge does not support prependHttpHandler (need a Bridge instance with HTTP server)');
39
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.
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
+ }
40
51
  b.prependHttpHandler(async (req, res) => {
41
52
  const url = req.url ?? '';
42
53
  const qi = url.indexOf('?');
43
54
  const reqPath = qi < 0 ? url : url.slice(0, qi);
44
55
  if (reqPath !== path)
45
56
  return false;
46
- await transport.handleRequest(req, res);
57
+ // Per-call caller for command-target scoping (4.0 · A): every sendCommand
58
+ // within this request reads it via currentCaller().
59
+ const principal = identifyPrincipal(req.headers, auth);
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');
47
95
  return true;
48
96
  });
49
97
  return {
50
98
  path,
51
99
  async close() {
52
- await server.close();
100
+ for (const { server } of sessions.values()) {
101
+ await server.close().catch(() => undefined);
102
+ }
103
+ sessions.clear();
53
104
  },
54
105
  };
55
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.2",
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",
@@ -16,7 +16,7 @@
16
16
  "main": "dist/index.js",
17
17
  "types": "dist/index.d.ts",
18
18
  "bin": {
19
- "harness-fe": "./dist/cli.js"
19
+ "harness-fe": "./dist/bin.js"
20
20
  },
21
21
  "exports": {
22
22
  ".": {
@@ -35,11 +35,10 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@modelcontextprotocol/sdk": "^1.26.0",
38
- "rrweb-player": "1.0.0-alpha.4",
39
38
  "ws": "^8.18.0",
40
39
  "zod": "^4.4.3",
41
- "@harness-fe/dashboard-ui": "0.2.0",
42
- "@harness-fe/protocol": "4.0.0-next.0"
40
+ "@harness-fe/daemon": "4.0.0-next.4",
41
+ "@harness-fe/protocol": "4.0.0-next.4"
43
42
  },
44
43
  "devDependencies": {
45
44
  "@types/ws": "^8.5.10",
@@ -53,10 +52,9 @@
53
52
  },
54
53
  "scripts": {
55
54
  "build": "tsc",
56
- "postbuild": "node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"",
55
+ "postbuild": "node -e \"require('fs').chmodSync('dist/bin.js', 0o755)\"",
57
56
  "dev": "tsc --watch --preserveWatchOutput",
58
57
  "watch": "tsc --watch --preserveWatchOutput",
59
- "start": "tsx src/cli.ts",
60
58
  "typecheck": "tsc --noEmit",
61
59
  "test": "vitest run"
62
60
  }
package/src/bin.ts ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Compat shim: the `harness-fe` CLI moved to @harness-fe/dev-cli when the
4
+ * monolith was split (daemon / mcp-server / dev-cli). We keep this bin so the
5
+ * old `npx @harness-fe/mcp-server` keeps working, but forward to dev-cli via
6
+ * npx at runtime — a static dependency would create an mcp-server ↔ dev-cli
7
+ * cycle (dev-cli already depends on mcp-server).
8
+ */
9
+ import { spawnSync } from 'node:child_process';
10
+
11
+ process.stderr.write(
12
+ '[harness-fe] The CLI moved to @harness-fe/dev-cli; forwarding. ' +
13
+ 'Run `npx @harness-fe/dev-cli` directly to skip this hop.\n',
14
+ );
15
+
16
+ const result = spawnSync('npx', ['-y', '@harness-fe/dev-cli', ...process.argv.slice(2)], {
17
+ stdio: 'inherit',
18
+ });
19
+ process.exit(result.status ?? 0);
package/src/daemon.ts CHANGED
@@ -27,10 +27,10 @@ import type { IncomingMessage } from 'node:http';
27
27
 
28
28
  import type { ConsentPolicy } from '@harness-fe/protocol';
29
29
 
30
- import { Bridge } from './bridge.js';
30
+ import { Bridge } from '@harness-fe/daemon';
31
31
  import { startMcpHttpServer } from './mcpHttp.js';
32
- import type { EventStore, IStore } from './store/types.js';
33
- import type { ITaskStore, IMemoryStore } from './store/types.js';
32
+ import type { EventStore, IStore } from '@harness-fe/daemon';
33
+ import type { ITaskStore, IMemoryStore } from '@harness-fe/daemon';
34
34
 
35
35
  export interface DaemonOptions {
36
36
  /** TCP port to listen on. Default `DEFAULT_WS_PORT` (see protocol). */
@@ -17,8 +17,8 @@ import { join } from 'node:path';
17
17
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
18
18
  import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
19
19
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
20
- import { Bridge } from './bridge.js';
21
- import { JsonlStore } from './store/index.js';
20
+ import { Bridge } from '@harness-fe/daemon';
21
+ import { JsonlStore } from '@harness-fe/daemon';
22
22
  import { createMcpServer, experimentalEnabled } from './mcp.js';
23
23
  import { createDaemon } from './daemon.js';
24
24
 
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
- export { Bridge, defaultDataDir, type BridgeOptions } from './bridge.js';
1
+ export { Bridge, defaultDataDir, type BridgeOptions } from '@harness-fe/daemon';
2
2
  export { createDaemon, type DaemonOptions, type DaemonHandle } from './daemon.js';
3
- export { SessionRouter, type PeerSession } from './sessionRouter.js';
3
+ export { SessionRouter, type PeerSession } from '@harness-fe/daemon';
4
4
  export {
5
5
  startMcpStdioServer,
6
6
  createMcpServer,
@@ -15,7 +15,7 @@ export {
15
15
  MemoryEventStore,
16
16
  sanitizeId,
17
17
  type MemoryEventStoreOptions,
18
- } from './store/index.js';
18
+ } from '@harness-fe/daemon';
19
19
  export type {
20
20
  IStore,
21
21
  ITaskStore,
@@ -28,4 +28,4 @@ export type {
28
28
  BuildMeta,
29
29
  SessionMeta,
30
30
  TabMeta,
31
- } from './store/index.js';
31
+ } from '@harness-fe/daemon';
package/src/mcp.ts CHANGED
@@ -24,16 +24,16 @@ import {
24
24
  typeArgsSchema,
25
25
  waitForArgsSchema,
26
26
  } from '@harness-fe/protocol';
27
- import type { IBridge } from './bridge.js';
28
- import type { Bridge } from './bridge.js';
29
- import type { AuthOptions } from './auth.js';
30
- import { canSee, identifyPrincipal } from './identity.js';
31
- import { RemoteBridge } from './remoteBridge.js';
32
- import type { IStore, IMemoryStore } from './store/index.js';
33
- import { buildVisitorTimeline } from './visitorTimeline.js';
34
- import { createReplayExport } from './replayCreate.js';
35
- import { openBrowser } from './openBrowser.js';
36
- import { buildDashboardUrl } from './dashboardUrl.js';
27
+ import type { IBridge } from '@harness-fe/daemon';
28
+ import type { Bridge } from '@harness-fe/daemon';
29
+ import type { AuthOptions } from '@harness-fe/daemon';
30
+ import { canSee, canSeeProject, identifyPrincipal } from '@harness-fe/daemon';
31
+ import { RemoteBridge } from '@harness-fe/daemon';
32
+ import type { IStore, IMemoryStore } from '@harness-fe/daemon';
33
+ import { buildVisitorTimeline } from '@harness-fe/daemon';
34
+ import { createReplayExport } from '@harness-fe/daemon';
35
+ import { openBrowser } from '@harness-fe/daemon';
36
+ import { buildDashboardUrl } from '@harness-fe/daemon';
37
37
 
38
38
  const SERVER_NAME = 'harness-fe';
39
39
 
@@ -141,6 +141,25 @@ function err(message: string): {
141
141
  };
142
142
  }
143
143
 
144
+ /**
145
+ * Owner chain of a project for tenant isolation (4.0 · A — binding/tagging):
146
+ * the project's own `createdBy` followed by its ancestors' (walked via
147
+ * `parentProjectId`, self → root, cycle-safe). Feed to `canSeeProject` so a
148
+ * host agent sees its sub-apps' data. Empty when the project is unknown.
149
+ */
150
+ function ownerChainOf(projectId: string, store: IStore): Array<string | undefined> {
151
+ const chain: Array<string | undefined> = [];
152
+ const seen = new Set<string>();
153
+ let id: string | undefined = projectId;
154
+ while (id && !seen.has(id)) {
155
+ seen.add(id);
156
+ const p = store.getProject(id);
157
+ if (!p) break;
158
+ chain.push(p.createdBy);
159
+ id = p.parentProjectId;
160
+ }
161
+ return chain;
162
+ }
144
163
 
145
164
  function registerTools(server: McpServer, bridge: IBridge, auth?: AuthOptions): void {
146
165
  server.registerTool(
@@ -752,8 +771,14 @@ function registerTools(server: McpServer, bridge: IBridge, auth?: AuthOptions):
752
771
  },
753
772
  async ({ status, limit }, extra) => {
754
773
  const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
755
- const tasks = (await bridge.listTasks({ status: status ?? 'pending', limit }))
756
- .filter((t) => canSee(principal, t.createdBy));
774
+ // Tenant isolation by project ownership (4.0 · A): a task is visible
775
+ // when the caller owns its project (or a host ancestor). Falls back
776
+ // to the submitter tag when no store is configured (in-memory mode).
777
+ const store = (bridge as Bridge).store;
778
+ const all = await bridge.listTasks({ status: status ?? 'pending', limit });
779
+ const tasks = store
780
+ ? all.filter((t) => canSeeProject(principal, t.projectId, ownerChainOf(t.projectId, store)))
781
+ : all.filter((t) => canSee(principal, t.createdBy));
757
782
  const summary = tasks.map((t) => ({
758
783
  id: t.id,
759
784
  status: t.status,
@@ -793,15 +818,35 @@ function registerTools(server: McpServer, bridge: IBridge, auth?: AuthOptions):
793
818
  COMMAND.TASKS_RESOLVE,
794
819
  {
795
820
  description:
796
- '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.',
797
825
  inputSchema: {
798
826
  taskId: z.string(),
799
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(),
800
845
  },
801
846
  },
802
- async ({ taskId, note }, extra) => {
847
+ async ({ taskId, note, resolution }, extra) => {
803
848
  const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
804
- const task = await bridge.resolveTask(taskId, note, principal);
849
+ const task = await bridge.resolveTask(taskId, note, resolution, principal);
805
850
  if (!task) {
806
851
  throw new Error(`tasks.resolve: no task with id "${taskId}"`);
807
852
  }
@@ -879,10 +924,9 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
879
924
  },
880
925
  async ({ projectId, limit }, extra) => {
881
926
  const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
882
- const sessions = store
883
- .listSessions({ projectId, limit: limit ?? 10 })
884
- .filter((s) => canSee(principal, s.createdBy));
885
- return ok(sessions);
927
+ // Owning a project (or a host ancestor) grants its whole session set.
928
+ if (!canSeeProject(principal, projectId, ownerChainOf(projectId, store))) return ok([]);
929
+ return ok(store.listSessions({ projectId, limit: limit ?? 10 }));
886
930
  },
887
931
  );
888
932
 
@@ -970,12 +1014,12 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
970
1014
  },
971
1015
  async (_args, extra) => {
972
1016
  const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
973
- const projects = store.listProjects().filter((p) => canSee(principal, p.createdBy));
1017
+ const projects = store
1018
+ .listProjects()
1019
+ .filter((p) => canSeeProject(principal, p.id, ownerChainOf(p.id, store)));
974
1020
  const result = projects.map((p) => ({
975
1021
  ...p,
976
- recentSessions: store
977
- .listSessions({ projectId: p.id, limit: 3 })
978
- .filter((s) => canSee(principal, s.createdBy)),
1022
+ recentSessions: store.listSessions({ projectId: p.id, limit: 3 }),
979
1023
  }));
980
1024
  return ok(result);
981
1025
  },