@harness-fe/mcp-server 3.0.1

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/dist/auth.d.ts +53 -0
  4. package/dist/auth.js +212 -0
  5. package/dist/bridge.d.ts +302 -0
  6. package/dist/bridge.js +1580 -0
  7. package/dist/cli.d.ts +18 -0
  8. package/dist/cli.js +277 -0
  9. package/dist/daemon.d.ts +98 -0
  10. package/dist/daemon.js +80 -0
  11. package/dist/dashboardApi.d.ts +40 -0
  12. package/dist/dashboardApi.js +142 -0
  13. package/dist/dashboardSpa.d.ts +18 -0
  14. package/dist/dashboardSpa.js +180 -0
  15. package/dist/dashboardUrl.d.ts +13 -0
  16. package/dist/dashboardUrl.js +18 -0
  17. package/dist/eventsHandler.d.ts +24 -0
  18. package/dist/eventsHandler.js +114 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +6 -0
  21. package/dist/mcp.d.ts +15 -0
  22. package/dist/mcp.js +923 -0
  23. package/dist/mcpHttp.d.ts +39 -0
  24. package/dist/mcpHttp.js +49 -0
  25. package/dist/openBrowser.d.ts +33 -0
  26. package/dist/openBrowser.js +63 -0
  27. package/dist/remoteBridge.d.ts +61 -0
  28. package/dist/remoteBridge.js +307 -0
  29. package/dist/replayCreate.d.ts +36 -0
  30. package/dist/replayCreate.js +156 -0
  31. package/dist/replayViewer.d.ts +20 -0
  32. package/dist/replayViewer.js +168 -0
  33. package/dist/sessionRouter.d.ts +42 -0
  34. package/dist/sessionRouter.js +88 -0
  35. package/dist/store/JsonMemoryStore.d.ts +52 -0
  36. package/dist/store/JsonMemoryStore.js +119 -0
  37. package/dist/store/JsonTaskStore.d.ts +21 -0
  38. package/dist/store/JsonTaskStore.js +53 -0
  39. package/dist/store/JsonlStore.d.ts +128 -0
  40. package/dist/store/JsonlStore.js +1168 -0
  41. package/dist/store/MemoryEventStore.d.ts +47 -0
  42. package/dist/store/MemoryEventStore.js +111 -0
  43. package/dist/store/WriteQueue.d.ts +51 -0
  44. package/dist/store/WriteQueue.js +142 -0
  45. package/dist/store/index.d.ts +6 -0
  46. package/dist/store/index.js +5 -0
  47. package/dist/store/types.d.ts +416 -0
  48. package/dist/store/types.js +19 -0
  49. package/package.json +63 -0
  50. package/src/auth.test.ts +90 -0
  51. package/src/auth.ts +248 -0
  52. package/src/bridge-auth.test.ts +196 -0
  53. package/src/bridge.test.ts +1708 -0
  54. package/src/bridge.ts +1804 -0
  55. package/src/cli.ts +315 -0
  56. package/src/daemon.test.ts +123 -0
  57. package/src/daemon.ts +161 -0
  58. package/src/dashboardApi.test.ts +235 -0
  59. package/src/dashboardApi.ts +184 -0
  60. package/src/dashboardSpa.test.ts +239 -0
  61. package/src/dashboardSpa.ts +195 -0
  62. package/src/dashboardUrl.test.ts +46 -0
  63. package/src/dashboardUrl.ts +28 -0
  64. package/src/eventsHandler.test.ts +247 -0
  65. package/src/eventsHandler.ts +136 -0
  66. package/src/index.ts +26 -0
  67. package/src/mcp.ts +1407 -0
  68. package/src/mcpHttp.test.ts +101 -0
  69. package/src/mcpHttp.ts +88 -0
  70. package/src/openBrowser.test.ts +103 -0
  71. package/src/openBrowser.ts +81 -0
  72. package/src/remoteBridge.test.ts +119 -0
  73. package/src/remoteBridge.ts +404 -0
  74. package/src/replay.test.ts +271 -0
  75. package/src/replayCreate.ts +194 -0
  76. package/src/replayViewer.ts +173 -0
  77. package/src/sessionRouter.ts +116 -0
  78. package/src/store/JsonMemoryStore.test.ts +175 -0
  79. package/src/store/JsonMemoryStore.ts +128 -0
  80. package/src/store/JsonTaskStore.test.ts +212 -0
  81. package/src/store/JsonTaskStore.ts +59 -0
  82. package/src/store/JsonlStore.test.ts +1538 -0
  83. package/src/store/JsonlStore.ts +1321 -0
  84. package/src/store/MemoryEventStore.test.ts +119 -0
  85. package/src/store/MemoryEventStore.ts +151 -0
  86. package/src/store/WriteQueue.ts +165 -0
  87. package/src/store/index.ts +29 -0
  88. package/src/store/types.ts +517 -0
package/src/cli.ts ADDED
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry — boots WS bridge + MCP server (stdio or HTTP).
4
+ *
5
+ * Usage:
6
+ * npx @harness-fe/mcp-server # 127.0.0.1, stdio MCP
7
+ * npx @harness-fe/mcp-server --host 0.0.0.0 --token auto
8
+ * npx @harness-fe/mcp-server --host 0.0.0.0 --token auto --mcp-transport http
9
+ *
10
+ * Leader / follower:
11
+ * - first process bound to the WS port = leader (in-process Bridge)
12
+ * - subsequent processes (EADDRINUSE) become followers that attach to
13
+ * the leader via the `mcp.call` control channel using `RemoteBridge`.
14
+ *
15
+ * This lets multiple Claude Code windows share a single dev-bridge daemon
16
+ * (and thus the same browser / vite-plugin connections).
17
+ */
18
+
19
+ import { randomBytes } from 'node:crypto';
20
+ import {
21
+ DEFAULT_HOST,
22
+ DEFAULT_WS_PORT,
23
+ buildHttpUrl,
24
+ isLoopbackHost,
25
+ parseWsUrl,
26
+ } from '@harness-fe/protocol';
27
+ import { Bridge, defaultDataDir, type IBridge } from './bridge.js';
28
+ import { RemoteBridge } from './remoteBridge.js';
29
+ import { startMcpStdioServer } from './mcp.js';
30
+ import { startMcpHttpServer } from './mcpHttp.js';
31
+
32
+ type McpTransport = 'stdio' | 'http';
33
+
34
+ interface CliConfig {
35
+ host: string;
36
+ port: number;
37
+ token: string | undefined;
38
+ mcpTransport: McpTransport;
39
+ mcpPath: string;
40
+ publicHost: string | undefined;
41
+ /** Friendly daemon name; surfaces in banner / dashboard. Cosmetic only. */
42
+ label: string | undefined;
43
+ /** Resolved data directory. Defaults to defaultDataDir(port). */
44
+ dataDir: string;
45
+ }
46
+
47
+ function printHelpAndExit(): never {
48
+ const help = `harness-fe — frontend harness MCP daemon
49
+
50
+ Usage:
51
+ harness-fe [options]
52
+
53
+ Options:
54
+ --host <addr> Bind address. Default 127.0.0.1.
55
+ Use 0.0.0.0 to accept LAN connections (requires --token).
56
+ --port <number> TCP port. Default ${DEFAULT_WS_PORT}.
57
+ --token <value|auto> Token required for HTTP/WS auth. Pass "auto" to generate one.
58
+ Required when --host is not loopback.
59
+ --mcp-transport <kind> stdio (default) or http. http mounts /mcp on the bridge.
60
+ --mcp-path <path> URL path for the MCP HTTP endpoint. Default /mcp.
61
+ --public-host <addr> Override the host printed in outbound URLs. Useful when
62
+ binding 0.0.0.0 and the auto-detected LAN IP is wrong.
63
+ -h, --help Show this help.
64
+
65
+ Environment:
66
+ HARNESS_FE_HOST Same as --host
67
+ HARNESS_FE_TOKEN Same as --token (use "auto" to generate)
68
+ HARNESS_FE_MCP_TRANSPORT Same as --mcp-transport
69
+ HARNESS_FE_MCP_PATH Same as --mcp-path
70
+ HARNESS_FE_URL Full ws:// URL (legacy; --host/--port override it)
71
+ `;
72
+ process.stderr.write(help);
73
+ process.exit(0);
74
+ }
75
+
76
+ function parseArgs(argv: string[]): CliConfig {
77
+ const args = argv.slice(2);
78
+
79
+ let host: string | undefined;
80
+ let port: number | undefined;
81
+ let token: string | undefined;
82
+ let mcpTransport: McpTransport | undefined;
83
+ let mcpPath: string | undefined;
84
+ let publicHost: string | undefined;
85
+
86
+ for (let i = 0; i < args.length; i++) {
87
+ const a = args[i];
88
+ const next = () => {
89
+ const v = args[++i];
90
+ if (v == null) {
91
+ process.stderr.write(`harness-fe: missing value for ${a}\n`);
92
+ process.exit(2);
93
+ }
94
+ return v;
95
+ };
96
+ switch (a) {
97
+ case '-h':
98
+ case '--help':
99
+ printHelpAndExit();
100
+ break;
101
+ case '--host':
102
+ host = next();
103
+ break;
104
+ case '--port':
105
+ port = Number(next());
106
+ if (!Number.isFinite(port) || port <= 0) {
107
+ process.stderr.write(`harness-fe: invalid --port\n`);
108
+ process.exit(2);
109
+ }
110
+ break;
111
+ case '--token':
112
+ token = next();
113
+ break;
114
+ case '--mcp-transport': {
115
+ const v = next();
116
+ if (v !== 'stdio' && v !== 'http') {
117
+ process.stderr.write(`harness-fe: invalid --mcp-transport (stdio|http)\n`);
118
+ process.exit(2);
119
+ }
120
+ mcpTransport = v;
121
+ break;
122
+ }
123
+ case '--mcp-path':
124
+ mcpPath = next();
125
+ break;
126
+ case '--public-host':
127
+ publicHost = next();
128
+ break;
129
+ default:
130
+ process.stderr.write(`harness-fe: unknown argument ${a}\n`);
131
+ process.exit(2);
132
+ }
133
+ }
134
+
135
+ // Apply env fallbacks, then URL fallback for host/port.
136
+ const envUrl = process.env.HARNESS_FE_URL;
137
+ let envHost: string | undefined;
138
+ let envPort: number | undefined;
139
+ if (envUrl) {
140
+ try {
141
+ const parsed = parseWsUrl(envUrl);
142
+ envHost = parsed.host;
143
+ envPort = parsed.port;
144
+ } catch {
145
+ // ignore — fall back to defaults below
146
+ }
147
+ }
148
+
149
+ const finalHost = host ?? process.env.HARNESS_FE_HOST ?? envHost ?? DEFAULT_HOST;
150
+ const finalPort = port ?? envPort ?? DEFAULT_WS_PORT;
151
+
152
+ let finalToken = token ?? process.env.HARNESS_FE_TOKEN;
153
+ if (finalToken === 'auto') {
154
+ finalToken = randomBytes(24).toString('base64url');
155
+ }
156
+ if (finalToken === '') finalToken = undefined;
157
+
158
+ const finalTransport: McpTransport =
159
+ (mcpTransport ?? (process.env.HARNESS_FE_MCP_TRANSPORT as McpTransport | undefined)) ??
160
+ 'stdio';
161
+ if (finalTransport !== 'stdio' && finalTransport !== 'http') {
162
+ process.stderr.write(`harness-fe: invalid mcp transport "${finalTransport}"\n`);
163
+ process.exit(2);
164
+ }
165
+ const finalMcpPath = mcpPath ?? process.env.HARNESS_FE_MCP_PATH ?? '/mcp';
166
+
167
+ // Data dir defaults to port-keyed path. Explicit env override wins.
168
+ const finalDataDir = process.env.HARNESS_FE_DATA_DIR ?? defaultDataDir(finalPort);
169
+
170
+ const finalLabel = process.env.HARNESS_FE_LABEL || undefined;
171
+
172
+ return {
173
+ host: finalHost,
174
+ port: finalPort,
175
+ token: finalToken,
176
+ mcpTransport: finalTransport,
177
+ mcpPath: finalMcpPath,
178
+ publicHost,
179
+ label: finalLabel,
180
+ dataDir: finalDataDir,
181
+ };
182
+ }
183
+
184
+ function validate(_cfg: CliConfig): void {
185
+ // Token requirement is left entirely to the operator. We don't refuse
186
+ // a non-loopback bind without a token — that's their call, not ours.
187
+ // Warnings are emitted from the banner so the operator sees them; CI /
188
+ // automation that pipes stderr can suppress as needed.
189
+ }
190
+
191
+ function printBanner(cfg: CliConfig, role: 'leader' | 'follower', viewerUrl: string | undefined): void {
192
+ const lines: string[] = [];
193
+ const labelSuffix = cfg.label ? ` (${cfg.label})` : '';
194
+ lines.push(`[harness-fe] ${role}: WS bridge listening on ws://${cfg.host}:${cfg.port}${labelSuffix}`);
195
+ if (role === 'leader') {
196
+ // Surface the data dir so the user can see exactly where this
197
+ // daemon's sessions / recordings / projects are landing.
198
+ lines.push(`[harness-fe] data: ${cfg.dataDir}`);
199
+ }
200
+ const isLan = !isLoopbackHost(cfg.host);
201
+ if (isLan) {
202
+ lines.push(`[harness-fe] WARNING: bound to non-loopback host ${cfg.host}.`);
203
+ if (cfg.token) {
204
+ lines.push(`[harness-fe] anyone reaching this host:port with the token can read console / network / recordings.`);
205
+ } else {
206
+ lines.push(`[harness-fe] no token set — anyone on this network can read console / network / recordings.`);
207
+ lines.push(`[harness-fe] add --token auto (or HARNESS_FE_TOKEN=…) to enable auth.`);
208
+ }
209
+ }
210
+ // Always print the dashboard URL. The token (when present) is folded
211
+ // into the query so the first hit hands it off to a cookie; without a
212
+ // token, auth is disabled and the URL works on its own.
213
+ const host = cfg.publicHost ?? viewerHost(viewerUrl) ?? cfg.host;
214
+ const dashboard = buildHttpUrl({ host, port: cfg.port, token: cfg.token });
215
+ lines.push(`[harness-fe] dashboard: ${dashboard}`);
216
+ if (cfg.mcpTransport === 'http') {
217
+ const mcp = buildHttpUrl({ host, port: cfg.port, token: cfg.token, path: cfg.mcpPath });
218
+ lines.push(`[harness-fe] mcp http: ${mcp}`);
219
+ if (cfg.token) {
220
+ const mcpNoTok = buildHttpUrl({ host, port: cfg.port, path: cfg.mcpPath });
221
+ lines.push(
222
+ `[harness-fe] agent config: { "url": "${mcpNoTok}", "headers": { "Authorization": "Bearer ${cfg.token}" } }`,
223
+ );
224
+ } else {
225
+ lines.push(`[harness-fe] agent config: { "url": "${mcp}" } (no auth — token unset)`);
226
+ }
227
+ }
228
+ if (cfg.token) {
229
+ lines.push(`[harness-fe] token: ${cfg.token}`);
230
+ }
231
+ process.stderr.write(lines.join('\n') + '\n');
232
+ }
233
+
234
+ function viewerHost(viewerUrl: string | undefined): string | undefined {
235
+ if (!viewerUrl) return undefined;
236
+ try {
237
+ return new URL(viewerUrl).hostname;
238
+ } catch {
239
+ return undefined;
240
+ }
241
+ }
242
+
243
+ async function main() {
244
+ const cfg = parseArgs(process.argv);
245
+ validate(cfg);
246
+
247
+ const { active, shutdown, role } = await startBridgeOrAttach(cfg);
248
+ printBanner(cfg, role, active.getViewerBaseUrl());
249
+
250
+ let mcpShutdown: (() => Promise<void>) | undefined;
251
+ if (cfg.mcpTransport === 'stdio') {
252
+ await startMcpStdioServer(active);
253
+ process.stderr.write('[harness-fe] MCP stdio server connected\n');
254
+ } else {
255
+ if (role === 'follower') {
256
+ process.stderr.write(
257
+ '[harness-fe] --mcp-transport=http is only supported on the leader. ' +
258
+ 'Another daemon already holds the port; stop it first.\n',
259
+ );
260
+ await shutdown();
261
+ process.exit(2);
262
+ }
263
+ const handle = await startMcpHttpServer(active, { path: cfg.mcpPath });
264
+ process.stderr.write(`[harness-fe] MCP http server mounted at ${handle.path}\n`);
265
+ mcpShutdown = () => handle.close();
266
+ }
267
+
268
+ const onSignal = async () => {
269
+ process.stderr.write('[harness-fe] shutting down\n');
270
+ if (mcpShutdown) {
271
+ try { await mcpShutdown(); } catch { /* swallow */ }
272
+ }
273
+ await shutdown();
274
+ process.exit(0);
275
+ };
276
+ process.on('SIGINT', onSignal);
277
+ process.on('SIGTERM', onSignal);
278
+ }
279
+
280
+ async function startBridgeOrAttach(
281
+ cfg: CliConfig,
282
+ ): Promise<{ active: IBridge; shutdown: () => Promise<void>; role: 'leader' | 'follower' }> {
283
+ const bridge = new Bridge({
284
+ port: cfg.port,
285
+ host: cfg.host,
286
+ dataDir: cfg.dataDir,
287
+ label: cfg.label,
288
+ auth: cfg.token ? { token: cfg.token } : undefined,
289
+ publicHost: cfg.publicHost,
290
+ });
291
+ try {
292
+ await bridge.start();
293
+ return {
294
+ active: bridge,
295
+ shutdown: () => bridge.stop(),
296
+ role: 'leader',
297
+ };
298
+ } catch (err) {
299
+ if ((err as NodeJS.ErrnoException)?.code !== 'EADDRINUSE') throw err;
300
+ }
301
+
302
+ // Port already taken — attach as follower.
303
+ const remote = new RemoteBridge({ port: cfg.port, host: cfg.host, token: cfg.token });
304
+ await remote.connect();
305
+ return {
306
+ active: remote,
307
+ shutdown: () => remote.stop(),
308
+ role: 'follower',
309
+ };
310
+ }
311
+
312
+ main().catch((err) => {
313
+ process.stderr.write(`[harness-fe] fatal: ${err?.stack ?? err}\n`);
314
+ process.exit(1);
315
+ });
@@ -0,0 +1,123 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { IncomingMessage } from 'node:http';
6
+ import { createDaemon } from './daemon.js';
7
+
8
+ const cleanups: Array<() => Promise<void> | void> = [];
9
+
10
+ afterEach(async () => {
11
+ while (cleanups.length) {
12
+ const fn = cleanups.shift();
13
+ if (fn) await fn();
14
+ }
15
+ });
16
+
17
+ function freshDataDir(): string {
18
+ const dir = mkdtempSync(join(tmpdir(), 'harness-daemon-test-'));
19
+ cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
20
+ return dir;
21
+ }
22
+
23
+ describe('createDaemon', () => {
24
+ it('boots a bridge on an ephemeral port and serves /mcp', async () => {
25
+ const daemon = createDaemon({
26
+ port: 0,
27
+ host: '127.0.0.1',
28
+ dataDir: freshDataDir(),
29
+ });
30
+ cleanups.push(() => daemon.stop());
31
+
32
+ await daemon.start();
33
+ const port = daemon.getBoundPort();
34
+ expect(port).toBeGreaterThan(0);
35
+ expect(daemon.mcpPath).toBe('/mcp');
36
+
37
+ // Anything not /mcp falls through to the bridge's 404.
38
+ const res = await fetch(`http://127.0.0.1:${port}/nothing-here`);
39
+ expect(res.status).toBe(404);
40
+
41
+ // /mcp is mounted (returns something other than the bridge 404 body).
42
+ const mcp = await fetch(`http://127.0.0.1:${port}/mcp`);
43
+ const body = await mcp.text();
44
+ expect(body).not.toBe('Not Found');
45
+ });
46
+
47
+ it('start is idempotent and stop is idempotent', async () => {
48
+ const daemon = createDaemon({
49
+ port: 0,
50
+ host: '127.0.0.1',
51
+ dataDir: freshDataDir(),
52
+ });
53
+ cleanups.push(() => daemon.stop());
54
+
55
+ await daemon.start();
56
+ await daemon.start(); // second call no-ops
57
+ const port = daemon.getBoundPort();
58
+ expect(port).toBeGreaterThan(0);
59
+
60
+ await daemon.stop();
61
+ await daemon.stop(); // second call no-ops
62
+ });
63
+
64
+ it('invokes a custom authorize on every request and rejects on false', async () => {
65
+ const authorize = vi.fn<(req: IncomingMessage) => boolean>(
66
+ (req) => req.headers.authorization === 'Bearer good',
67
+ );
68
+
69
+ const daemon = createDaemon({
70
+ port: 0,
71
+ host: '127.0.0.1',
72
+ dataDir: freshDataDir(),
73
+ authorize,
74
+ });
75
+ cleanups.push(() => daemon.stop());
76
+ await daemon.start();
77
+
78
+ const port = daemon.getBoundPort()!;
79
+ const denied = await fetch(`http://127.0.0.1:${port}/mcp`);
80
+ expect(denied.status).toBe(401);
81
+
82
+ const allowed = await fetch(`http://127.0.0.1:${port}/mcp`, {
83
+ headers: { authorization: 'Bearer good' },
84
+ });
85
+ expect(allowed.status).not.toBe(401);
86
+
87
+ // Auth was consulted at least twice (denied + allowed). Other internal
88
+ // routes may also have hit it during startup; we don't assert an
89
+ // exact count — only that the predicate is on the hot path.
90
+ expect(authorize.mock.calls.length).toBeGreaterThanOrEqual(2);
91
+ });
92
+
93
+ it('exposes the underlying Bridge as a tested escape hatch', async () => {
94
+ const daemon = createDaemon({
95
+ port: 0,
96
+ host: '127.0.0.1',
97
+ dataDir: freshDataDir(),
98
+ });
99
+ cleanups.push(() => daemon.stop());
100
+ await daemon.start();
101
+
102
+ expect(daemon.bridge).toBeDefined();
103
+ expect(daemon.bridge.getBoundPort()).toBe(daemon.getBoundPort());
104
+ });
105
+
106
+ it('honours a custom mcpPath', async () => {
107
+ const daemon = createDaemon({
108
+ port: 0,
109
+ host: '127.0.0.1',
110
+ dataDir: freshDataDir(),
111
+ mcpPath: '/custom-mcp',
112
+ });
113
+ cleanups.push(() => daemon.stop());
114
+ await daemon.start();
115
+ expect(daemon.mcpPath).toBe('/custom-mcp');
116
+
117
+ const port = daemon.getBoundPort()!;
118
+ const res = await fetch(`http://127.0.0.1:${port}/custom-mcp`);
119
+ // 404 would mean the path wasn't mounted (bridge default). Anything
120
+ // else means the MCP transport responded.
121
+ expect(await res.text()).not.toBe('Not Found');
122
+ });
123
+ });
package/src/daemon.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `createDaemon` — programmatic entry point for embedding the harness-fe
3
+ * daemon inside another Node.js process. Wraps `Bridge` construction +
4
+ * `startMcpHttpServer` so a host application can boot the daemon with
5
+ * `import { createDaemon } from '@harness-fe/mcp-server'` instead of
6
+ * spawning the CLI as a sidecar.
7
+ *
8
+ * Scope (v1):
9
+ * - Factory mode only — the daemon owns its own HTTP listener on a
10
+ * caller-chosen port. Attaching to a host's existing `http.Server`
11
+ * (middleware / handle modes) is a follow-up that requires Bridge
12
+ * surgery; tracked separately.
13
+ * - Resumable SSE — pass through `eventStore` (defaults to
14
+ * `MemoryEventStore`; `null` disables resumability).
15
+ * - Custom auth — pass `authorize: (req) => boolean` to replace the
16
+ * built-in token check. The CLI translates `--token` into one of
17
+ * these so the daemon itself has exactly one auth pipeline.
18
+ *
19
+ * Standalone CLI use is unchanged: `cli.ts` is a thin caller of this
20
+ * factory. Anything the CLI does that's specific to a developer's
21
+ * machine (port-keyed `defaultDataDir`, `HARNESS_FE_*` env, leader /
22
+ * follower attachment, banner, signal handlers) stays in `cli.ts` —
23
+ * not pushed into the factory.
24
+ */
25
+
26
+ import type { IncomingMessage } from 'node:http';
27
+
28
+ import { Bridge } from './bridge.js';
29
+ import { startMcpHttpServer } from './mcpHttp.js';
30
+ import type { EventStore, IStore } from './store/types.js';
31
+ import type { ITaskStore, IMemoryStore } from './store/types.js';
32
+
33
+ export interface DaemonOptions {
34
+ /** TCP port to listen on. Default `DEFAULT_WS_PORT` (see protocol). */
35
+ port?: number;
36
+ /** Bind address. Default `127.0.0.1` (loopback only). */
37
+ host?: string;
38
+ /**
39
+ * Override the host used when building outbound URLs (dashboard, replay
40
+ * viewer). Useful when binding `0.0.0.0` and the auto-detected LAN IP is
41
+ * wrong, or when the host application sits behind a reverse proxy.
42
+ */
43
+ publicHost?: string;
44
+ /**
45
+ * Custom request authorizer applied to every HTTP request and WS upgrade.
46
+ * Return `false` to reject. When supplied, the built-in token check is
47
+ * skipped — there is exactly one auth pipeline. Synchronous because the
48
+ * WS upgrade handshake completes inline; async auth should be cached in
49
+ * a cookie by the host's own middleware and read back here.
50
+ */
51
+ authorize?: (req: IncomingMessage) => boolean;
52
+ /**
53
+ * IStore implementation. Omit for the default JSONL store at `dataDir`.
54
+ * Pass `null` to disable session/event persistence entirely.
55
+ */
56
+ store?: IStore | null;
57
+ /** Task store. Omit for default JsonTaskStore. `null` disables. */
58
+ taskStore?: ITaskStore | null;
59
+ /** Memory store. Omit for default JsonMemoryStore. `null` disables. */
60
+ memoryStore?: IMemoryStore | null;
61
+ /**
62
+ * EventStore backing resumable SSE streams. Omit for the in-memory
63
+ * default (1000 events / 5 minutes / 50 MiB). `null` disables
64
+ * resumability — reconnects after a drop start at the live tail.
65
+ */
66
+ eventStore?: EventStore | null;
67
+ /**
68
+ * Root data directory for the default stores. Omit to let `Bridge`
69
+ * compute a port-keyed default; pass explicitly when the host wants
70
+ * everything at a known location.
71
+ */
72
+ dataDir?: string;
73
+ /** Cosmetic friendly name; surfaces in the dashboard banner. */
74
+ label?: string;
75
+ /** URL path the MCP HTTP transport mounts on. Default `/mcp`. */
76
+ mcpPath?: string;
77
+ /**
78
+ * Whether to use stateful MCP sessions (sessionId in headers) or
79
+ * stateless one-shot requests. Default `true` (stateful — matches what
80
+ * Claude Code, Cursor, and the MCP spec default expect).
81
+ */
82
+ mcpStateful?: boolean;
83
+ }
84
+
85
+ export interface DaemonHandle {
86
+ /**
87
+ * Start the bridge and mount the MCP HTTP transport. Throws if the port
88
+ * is already in use — in embedded mode there's no leader/follower
89
+ * fallback; the host decides how to handle the conflict.
90
+ */
91
+ start(): Promise<void>;
92
+ /** Stop the MCP transport and bridge. Safe to call multiple times. */
93
+ stop(): Promise<void>;
94
+ /** Bound TCP port (only meaningful after `start`). */
95
+ getBoundPort(): number | undefined;
96
+ /** Outbound base URL for dashboard / replay viewer links. */
97
+ getViewerBaseUrl(): string | undefined;
98
+ /** Path the MCP HTTP transport is mounted on. */
99
+ readonly mcpPath: string;
100
+ /** Underlying bridge — escape hatch for tests and advanced wiring. */
101
+ readonly bridge: Bridge;
102
+ }
103
+
104
+ export function createDaemon(opts: DaemonOptions = {}): DaemonHandle {
105
+ const mcpPath = opts.mcpPath ?? '/mcp';
106
+
107
+ const bridge = new Bridge({
108
+ port: opts.port,
109
+ host: opts.host,
110
+ publicHost: opts.publicHost,
111
+ store: opts.store,
112
+ taskStore: opts.taskStore,
113
+ memoryStore: opts.memoryStore,
114
+ dataDir: opts.dataDir,
115
+ label: opts.label,
116
+ auth: opts.authorize ? { authorize: opts.authorize } : undefined,
117
+ });
118
+
119
+ let mcpHandle: Awaited<ReturnType<typeof startMcpHttpServer>> | undefined;
120
+ let started = false;
121
+ let stopped = false;
122
+
123
+ return {
124
+ mcpPath,
125
+ bridge,
126
+
127
+ async start() {
128
+ if (started) return;
129
+ started = true;
130
+ await bridge.start();
131
+ // Forward eventStore as-is so `null` opts out of resumability and
132
+ // `undefined` falls through to the default MemoryEventStore.
133
+ mcpHandle = await startMcpHttpServer(bridge, {
134
+ path: mcpPath,
135
+ stateful: opts.mcpStateful,
136
+ eventStore: opts.eventStore,
137
+ });
138
+ },
139
+
140
+ async stop() {
141
+ if (stopped) return;
142
+ stopped = true;
143
+ if (mcpHandle) {
144
+ try {
145
+ await mcpHandle.close();
146
+ } catch {
147
+ /* swallow — bridge.stop will be called anyway */
148
+ }
149
+ }
150
+ await bridge.stop();
151
+ },
152
+
153
+ getBoundPort() {
154
+ return bridge.getBoundPort();
155
+ },
156
+
157
+ getViewerBaseUrl() {
158
+ return bridge.getViewerBaseUrl();
159
+ },
160
+ };
161
+ }