@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
@@ -0,0 +1,302 @@
1
+ /**
2
+ * WS bridge — accepts connections from vite-plugin and runtime-client.
3
+ *
4
+ * Protocol: see @harness-fe/protocol.
5
+ *
6
+ * Responsibilities:
7
+ * - Handshake: `hello` frame → register peer in SessionRouter, reply `hello.ack`
8
+ * - sendCommand(): forward a CommandFrame to the target tab, return a
9
+ * Promise that resolves when the matching ResponseFrame arrives
10
+ * - onEvent(): broadcast event frames to subscribers (mcp tools / future
11
+ * recorder)
12
+ */
13
+ import { type IncomingMessage, type ServerResponse } from 'node:http';
14
+ import { type AuthOptions } from './auth.js';
15
+ import { type EventFrame, type HttpBatch, type TabInfo, type Task, type TaskStatus } from '@harness-fe/protocol';
16
+ import { SessionRouter, type PeerSession } from './sessionRouter.js';
17
+ import { type IStore, type ITaskStore, type IMemoryStore, type RetentionPolicy } from './store/index.js';
18
+ /**
19
+ * Surface used by the stdio MCP layer. Same shape whether the underlying
20
+ * implementation is an in-process `Bridge` (leader) or a `RemoteBridge`
21
+ * proxying over WS to another daemon (follower).
22
+ *
23
+ * All methods are async so the same call site works in both modes.
24
+ */
25
+ export interface IBridge {
26
+ sendCommand(command: string, args: unknown, opts?: SendCommandOptions): Promise<unknown>;
27
+ listTabs(): Promise<TabInfo[]>;
28
+ listTasks(filter?: {
29
+ status?: TaskStatus | 'all';
30
+ limit?: number;
31
+ }): Promise<Task[]>;
32
+ claimTask(id: string): Promise<Task | undefined>;
33
+ resolveTask(id: string, note?: string): Promise<Task | undefined>;
34
+ getMemoryStore(): IMemoryStore;
35
+ /**
36
+ * Base URL (e.g. http://127.0.0.1:47729) where the replay viewer is reachable.
37
+ * Returns undefined when the bridge does not serve HTTP (e.g. follower mode).
38
+ */
39
+ getViewerBaseUrl(): string | undefined;
40
+ /**
41
+ * The configured auth token, or undefined if auth is disabled. Used to
42
+ * compose URLs that the user (or agent) can hit without an extra
43
+ * authentication step.
44
+ */
45
+ getAuthToken(): string | undefined;
46
+ /**
47
+ * Read an attachment PNG for a task. Returns base64-encoded PNG or null.
48
+ * The task must exist in the in-memory map so we can look up its projectId.
49
+ */
50
+ getTaskAttachmentData(taskId: string, attachmentId: string): Promise<string | null>;
51
+ }
52
+ export interface SendCommandOptions {
53
+ tabId?: string;
54
+ timeoutMs?: number;
55
+ target?: 'runtime-client' | 'vite-plugin';
56
+ projectId?: string;
57
+ }
58
+ /**
59
+ * Default data directory for all persistence stores, keyed by the port the
60
+ * daemon listens on. Identity of a daemon = its listening address; same
61
+ * port → same on-disk store; different ports → independent stores.
62
+ *
63
+ * This lets users opt into isolation simply by configuring a different
64
+ * `--port` in their `mcp.json`, and lets multiple IDEs targeting the same
65
+ * port automatically share state through the existing leader/follower
66
+ * mechanism in `cli.ts`. No cwd / project-root detection involved.
67
+ */
68
+ export declare function defaultDataDir(port: number): string;
69
+ export interface BridgeOptions {
70
+ port?: number;
71
+ /** Bind address. Default 127.0.0.1 (no remote exposure). */
72
+ host?: string;
73
+ /**
74
+ * Token-based auth applied to every HTTP route and WS upgrade. Empty
75
+ * token disables auth (only valid when bound to a loopback host).
76
+ */
77
+ auth?: AuthOptions;
78
+ /**
79
+ * Override the host used when building outbound URLs (dashboard links,
80
+ * replay viewer URLs). When omitted and `host` is `0.0.0.0` / `::`, the
81
+ * first non-internal IPv4 address from the OS network interfaces is
82
+ * used so other LAN devices can follow the links. For loopback binds the
83
+ * literal `host` is reused.
84
+ */
85
+ publicHost?: string;
86
+ /**
87
+ * Store instance for JSONL persistence. If omitted, a default JsonlStore
88
+ * is created at `dataDir` (or `defaultDataDir(port)` when `dataDir` is
89
+ * also omitted). Pass null to disable persistence.
90
+ */
91
+ store?: IStore | null;
92
+ /**
93
+ * Task store instance for JSON task persistence. If omitted, a default
94
+ * JsonTaskStore is created at `dataDir`. Pass null to disable task
95
+ * persistence (useful in tests).
96
+ */
97
+ taskStore?: ITaskStore | null;
98
+ /**
99
+ * Memory store instance for agent memory persistence. If omitted, a default
100
+ * JsonMemoryStore is created at `dataDir`. Pass null to disable memory
101
+ * persistence (useful in tests).
102
+ */
103
+ memoryStore?: IMemoryStore | null;
104
+ /**
105
+ * Root data directory for task attachment binaries. Defaults to the same
106
+ * `~/.harness/data` directory used by the stores. Override in tests.
107
+ */
108
+ attachmentsDataDir?: string;
109
+ /**
110
+ * Root data directory for the default stores (when `store` / `taskStore`
111
+ * / `memoryStore` are not supplied). When omitted, computed from `port`
112
+ * via `defaultDataDir(port)`. Set explicitly to point all stores at a
113
+ * non-default location (useful for tests or migration).
114
+ */
115
+ dataDir?: string;
116
+ /**
117
+ * Optional friendly label surfaced in the startup banner and (later) the
118
+ * dashboard title. Purely cosmetic — has no effect on data isolation,
119
+ * routing, or auth. Identity is always the listening port.
120
+ */
121
+ label?: string;
122
+ /**
123
+ * Automatic retention policy enforcement.
124
+ *
125
+ * Without this, manual `session.purge` MCP calls are the only thing that
126
+ * trims the on-disk store — so a long-running daemon will eventually fill
127
+ * the user's disk. Default: run `store.purge()` once shortly after start
128
+ * and every hour thereafter. Set `enabled: false` for tests / one-shot runs.
129
+ */
130
+ autoPurge?: {
131
+ enabled?: boolean;
132
+ /** Period between purges in ms. Default 1 hour. */
133
+ intervalMs?: number;
134
+ /** Override the retention policy. Default uses store's built-in defaults. */
135
+ policy?: RetentionPolicy;
136
+ /** Skip the startup purge (still runs the periodic timer). Default false. */
137
+ skipInitial?: boolean;
138
+ };
139
+ }
140
+ export type EventListener = (event: EventFrame, session: PeerSession) => void;
141
+ export declare class Bridge implements IBridge {
142
+ readonly router: SessionRouter;
143
+ readonly store: IStore | null;
144
+ readonly taskStore: ITaskStore | null;
145
+ readonly memoryStore: IMemoryStore;
146
+ private wss?;
147
+ private httpServer?;
148
+ /**
149
+ * Optional HTTP handler invoked for non-WebSocket requests. Set via
150
+ * `setHttpHandler()`. Allows higher layers (e.g. replay viewer) to serve
151
+ * routes on the same port as the WS bridge without coupling Bridge to them.
152
+ */
153
+ private httpHandler?;
154
+ private sockets;
155
+ private pending;
156
+ private eventListeners;
157
+ private tasks;
158
+ private opts;
159
+ private auth;
160
+ private publicHostOverride;
161
+ private readonly attachDataDir;
162
+ private autoPurgeOpts;
163
+ /** Set by start() when auto-purge is enabled; cleared by stop(). */
164
+ private autoPurgeTimer?;
165
+ /**
166
+ * Map from connectionId → buildId (for build-plugin connections)
167
+ * or sessionId (for runtime-client connections).
168
+ */
169
+ private connToStoreId;
170
+ /** Connections that already logged a "no store session" warning. */
171
+ private warnedNoSession;
172
+ /**
173
+ * Grace period timers: projectId → timer handle.
174
+ * When a build plugin disconnects, a 30-second timer is started.
175
+ * If the same project reconnects within that window, the timer is cancelled.
176
+ */
177
+ private graceTimers;
178
+ /**
179
+ * Pending build end info: projectId → { buildId, closedAt }.
180
+ * Tracks builds waiting for the grace period to expire.
181
+ */
182
+ private pendingEndBuild;
183
+ /**
184
+ * Dashboard SPA subscribers — connections that sent `hello` with
185
+ * role: 'dashboard-client'. Receive `dashboard.update` frames whenever
186
+ * session state changes; never receive commands and never send events.
187
+ */
188
+ private dashboardSubscribers;
189
+ /** Debounce per-session 'session.update' broadcasts so chatty rrweb chunks don't spam subscribers. */
190
+ private dashboardDebounceTimers;
191
+ /** Optional friendly label (HARNESS_FE_LABEL). Cosmetic only. */
192
+ readonly label: string | undefined;
193
+ constructor(opts?: BridgeOptions);
194
+ /**
195
+ * Returns the memory store instance for use by mcp.ts and other callers.
196
+ */
197
+ getMemoryStore(): IMemoryStore;
198
+ private loadTasks;
199
+ private persistTasks;
200
+ /**
201
+ * Load tasks for a specific project from the task store into the in-memory map.
202
+ * Called when a project connects so its tasks are available immediately.
203
+ */
204
+ private loadTasksForProject;
205
+ private taskDedupKey;
206
+ /**
207
+ * Register an HTTP request handler that runs for non-WebSocket requests on
208
+ * the same port. Only one handler is supported; later calls replace prior
209
+ * ones. WS upgrades bypass this handler.
210
+ */
211
+ setHttpHandler(handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>): void;
212
+ /**
213
+ * Insert a handler that runs *before* the main HTTP handler. Return `true`
214
+ * if the request was consumed (no further processing); return `false` to
215
+ * fall through to the existing handler. Allows mcpHttp.ts to mount on
216
+ * `/mcp` without owning the whole HTTP surface.
217
+ */
218
+ prependHttpHandler(handler: (req: IncomingMessage, res: ServerResponse) => Promise<boolean> | boolean): void;
219
+ start(): Promise<void>;
220
+ stop(): Promise<void>;
221
+ /**
222
+ * Run `store.purge()` defensively. Errors are logged but never bubble out
223
+ * — the daemon must continue serving even if disk is full or files are
224
+ * locked.
225
+ */
226
+ private runAutoPurge;
227
+ /** Expose the bound port (useful when port:0 was passed for tests). */
228
+ getBoundPort(): number | undefined;
229
+ getViewerBaseUrl(): string | undefined;
230
+ getAuthToken(): string | undefined;
231
+ /**
232
+ * Broadcast a `dashboard.update` frame to every subscribed dashboard SPA.
233
+ *
234
+ * `kind: 'session.update'` is debounced per-sessionId (200ms) so chatty
235
+ * rrweb chunk appends don't spam every subscriber. Other kinds fire
236
+ * immediately because they represent rare state transitions (new
237
+ * session, session closed, export created).
238
+ */
239
+ notifyDashboard(payload: {
240
+ kind: 'session.new' | 'session.update' | 'session.closed' | 'project.update' | 'export.new';
241
+ sessionId?: string;
242
+ projectId?: string;
243
+ }): void;
244
+ private flushDashboardUpdate;
245
+ /**
246
+ * Host string used when handing out URLs that other machines need to
247
+ * reach. Loopback binds keep the literal address; wildcard binds
248
+ * (0.0.0.0 / ::) prefer the first non-internal IPv4. Explicit
249
+ * `publicHost` always wins.
250
+ */
251
+ private getPublicHost;
252
+ onEvent(listener: EventListener): () => void;
253
+ /**
254
+ * Handle an HTTP-batch POST /events request (Edge Runtime path).
255
+ *
256
+ * Stateless: each call is a self-contained hello+events sequence.
257
+ * The hello is used to register the peer (or look up the existing session)
258
+ * and the events are persisted to the session timeline — same paths as the
259
+ * WS handler.
260
+ */
261
+ handleHttpBatch(hello: HttpBatch['hello'], events: HttpBatch['events']): void;
262
+ listTabs(): Promise<TabInfo[]>;
263
+ listTasks(filter?: {
264
+ status?: TaskStatus | 'all';
265
+ limit?: number;
266
+ }): Promise<Task[]>;
267
+ claimTask(id: string): Promise<Task | undefined>;
268
+ getTaskAttachmentData(taskId: string, attachmentId: string): Promise<string | null>;
269
+ resolveTask(id: string, note?: string): Promise<Task | undefined>;
270
+ private persistTaskEvent;
271
+ private recordTask;
272
+ /**
273
+ * Write attachment data to disk and return persisted pointer objects.
274
+ * Drops attachments if the total decoded size exceeds 4 MB.
275
+ */
276
+ private writeTaskAttachments;
277
+ /**
278
+ * Read an attachment from disk for a given task.
279
+ * Returns the base64 data if found, null otherwise.
280
+ */
281
+ readTaskAttachment(projectId: string, taskId: string, attachmentId: string): string | null;
282
+ /**
283
+ * Send a command to a specific tab and await its response.
284
+ * `tabId` falls back to the most-recent active tab if omitted.
285
+ */
286
+ sendCommand(command: string, args: unknown, opts?: SendCommandOptions): Promise<unknown>;
287
+ /**
288
+ * Returns true if there is an active build for the given projectId.
289
+ * Checks both in-memory grace period builds and the store.
290
+ */
291
+ private hasActiveBuild;
292
+ private onConnection;
293
+ private handleFrame;
294
+ /**
295
+ * Runtime → daemon query dispatcher (0.5+). Whitelisted methods only.
296
+ * Owner check: tasks.update / tasks.get / tasks.delete refuse to touch
297
+ * tasks whose `visitorId` doesn't match the caller's `peer.visitorId`.
298
+ */
299
+ private handleQuery;
300
+ private handleMcpCall;
301
+ private invokeMcpMethod;
302
+ }