@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,416 @@
1
+ /**
2
+ * Store types — the public interface for the JSONL-based persistence layer.
3
+ *
4
+ * v0.4.0 layout (new, flat):
5
+ * {dataDir}/projects/{projectId}/meta.json
6
+ * {dataDir}/projects/{projectId}/tasks.json
7
+ * {dataDir}/projects/{projectId}/memory.json
8
+ * {dataDir}/projects/{projectId}/notes.jsonl
9
+ * {dataDir}/projects/{projectId}/builds/{buildId}/meta.json
10
+ * {dataDir}/tabs/{tabId}/meta.json
11
+ * {dataDir}/sessions/{sessionId}/meta.json ← one per pageload
12
+ * {dataDir}/sessions/{sessionId}/timeline.jsonl ← mixed parent+child events
13
+ * {dataDir}/sessions/{sessionId}/recording.jsonl ← rrweb chunks
14
+ * {dataDir}/visitors/{visitorId}/meta.json ← per-browser identity (0.5+)
15
+ *
16
+ * Legacy layout (v0.3.x, read-only fallback — daemon warns on startup):
17
+ * {dataDir}/{projectId}/sessions/{buildId}/tabs/{tabId}/...
18
+ */
19
+ import type { Task, VisitorEnv } from '@harness-fe/protocol';
20
+ export type { EventId, EventStore, StreamId, } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
21
+ /** Short type codes used in JSONL lines to keep files compact. */
22
+ export type EventType = 'log' | 'err' | 'req' | 'res' | 'cmd' | 'resp' | 'hmr' | 'task' | 'task:claim' | 'task:resolve' | 'rrweb' | 'node:log' | 'node:err' | 'note' | 'load' | 'storage' | 'server-log' | 'server-err' | 'server-action' | 'app-log' | string;
23
+ /** A single event line in a JSONL file. Carries row-level projectId/buildId tags. */
24
+ export interface StoreEvent {
25
+ /**
26
+ * Server-assigned monotonic integer per session (assigned at enqueue time).
27
+ * Optional on input — the store layer assigns this.
28
+ * Always present on events returned by `tail` and `search`.
29
+ */
30
+ seq?: number;
31
+ /** Unix timestamp in milliseconds. */
32
+ ts: number;
33
+ /** Short event type code. */
34
+ t: EventType;
35
+ /** Tab ID — present for tab-scoped events. */
36
+ tab?: string;
37
+ /**
38
+ * Load/session ID on tab-scoped events. Kept for backward compat with
39
+ * v0.3.x event lines and bridge code that still stamps event.load.
40
+ */
41
+ load?: string;
42
+ /**
43
+ * Row-level project ID. Stamped by the bridge before calling appendEvent().
44
+ */
45
+ projectId?: string;
46
+ /**
47
+ * Row-level build ID. Stamped by the bridge.
48
+ */
49
+ buildId?: string;
50
+ /**
51
+ * Row-level visitor ID. Stamped by the bridge from the registered peer
52
+ * or the frame's own `visitorId`. Lets agents filter timeline by
53
+ * "everything from this user" without join lookups.
54
+ */
55
+ visitorId?: string;
56
+ /** Event payload — structure depends on `t`. */
57
+ d?: unknown;
58
+ }
59
+ export interface ProjectMeta {
60
+ id: string;
61
+ createdAt: number;
62
+ lastActiveAt: number;
63
+ parentProjectId?: string;
64
+ displayName?: string;
65
+ tags?: string[];
66
+ metadata?: Record<string, unknown>;
67
+ }
68
+ /**
69
+ * Per-build metadata. Lives at projects/{projectId}/builds/{buildId}/meta.json.
70
+ */
71
+ export interface BuildMeta {
72
+ id: string;
73
+ projectId: string;
74
+ builtAt: number;
75
+ gitSha?: string;
76
+ gitDirty?: boolean;
77
+ sourceDigest?: string;
78
+ nodeVersion?: string;
79
+ /** 'vite' | 'webpack' | 'esbuild' | 'rspack' | … */
80
+ bundler?: string;
81
+ bundlerVersion?: string;
82
+ /** Timestamp when this build's dev server was closed. */
83
+ endedAt?: number;
84
+ metadata?: Record<string, unknown>;
85
+ }
86
+ /**
87
+ * Node in a project tree returned by `getProjectTree`.
88
+ */
89
+ export interface ProjectTreeNode {
90
+ id: string;
91
+ displayName?: string;
92
+ tags?: string[];
93
+ children: ProjectTreeNode[];
94
+ }
95
+ /**
96
+ * Per-tab metadata. Lives at tabs/{tabId}/meta.json.
97
+ * A tab spans multiple sessions and may host multiple projects.
98
+ */
99
+ export interface TabMeta {
100
+ id: string;
101
+ userAgent?: string;
102
+ connectedAt: number;
103
+ disconnectedAt?: number;
104
+ metadata?: Record<string, unknown>;
105
+ }
106
+ /**
107
+ * Per-session (pageload) metadata. Lives at sessions/{sessionId}/meta.json.
108
+ * A session = one pageload. Multiple projects/iframes may participate
109
+ * (they share sessionId via tryInheritFromParent).
110
+ */
111
+ export interface SessionMeta {
112
+ /** sessionId generated by the runtime (shared across same-origin iframes). */
113
+ id: string;
114
+ tabId: string;
115
+ startedAt: number;
116
+ endedAt?: number;
117
+ url?: string;
118
+ title?: string;
119
+ referrer?: string;
120
+ userAgent?: string;
121
+ /**
122
+ * Every (projectId, buildId) pair that participated in this pageload.
123
+ * Merge semantics: new participants are appended on each upsertSession call.
124
+ */
125
+ participants: Array<{
126
+ projectId: string;
127
+ buildId?: string;
128
+ joinedAt: number;
129
+ }>;
130
+ initial?: {
131
+ viewport?: {
132
+ w: number;
133
+ h: number;
134
+ dpr: number;
135
+ };
136
+ storageKeys?: {
137
+ local?: number;
138
+ session?: number;
139
+ cookie?: number;
140
+ };
141
+ storageTruncated?: boolean;
142
+ };
143
+ metadata?: Record<string, unknown>;
144
+ }
145
+ /**
146
+ * Per-visitor identity. Lives at visitors/{visitorId}/meta.json. Stitches a
147
+ * user's activity across pageloads / refreshes / tabs.
148
+ */
149
+ export interface VisitorMeta {
150
+ /** visitorId — anonymous UUID persisted in browser localStorage. */
151
+ id: string;
152
+ /** App-supplied identifier (latest non-empty value wins). */
153
+ userId?: string;
154
+ firstSeenAt: number;
155
+ lastSeenAt: number;
156
+ /** Distinct sessions (pageloads) attributed to this visitor. */
157
+ sessionCount: number;
158
+ /** LRU-capped list of distinct tabIds seen (max 50). */
159
+ tabIds: string[];
160
+ /** Distinct projects this visitor has touched (max 50). */
161
+ projectIds: string[];
162
+ /** Last-seen environment snapshot. */
163
+ lastEnv?: VisitorEnv;
164
+ }
165
+ export interface TailOptions {
166
+ /** Number of lines to return from the end. Default 50. */
167
+ n?: number;
168
+ /** Filter by event type(s). */
169
+ type?: EventType | EventType[];
170
+ /** Only return events after this timestamp. */
171
+ since?: number;
172
+ /** Only return events before this timestamp. */
173
+ until?: number;
174
+ /** Filter by projectId (useful for multi-project session timelines). */
175
+ projectId?: string;
176
+ }
177
+ export interface SearchOptions {
178
+ /** Filter by event type(s). */
179
+ type?: EventType | EventType[];
180
+ /** Max results. Default 50. */
181
+ limit?: number;
182
+ }
183
+ export interface RecordingChunkSummary {
184
+ chunkId: string;
185
+ tabId: string;
186
+ startTs: number;
187
+ endTs: number;
188
+ eventCount: number;
189
+ }
190
+ export interface RecordingChunk extends RecordingChunkSummary {
191
+ events: unknown[];
192
+ }
193
+ /**
194
+ * Metadata for a saved replay export.
195
+ */
196
+ export interface ReplayExportMeta {
197
+ exportId: string;
198
+ projectId: string;
199
+ sessionId: string;
200
+ tabId?: string;
201
+ label?: string;
202
+ since: number;
203
+ until: number;
204
+ startTs: number;
205
+ endTs: number;
206
+ chunkCount: number;
207
+ eventCount: number;
208
+ bytes: number;
209
+ createdAt: number;
210
+ }
211
+ export interface SessionSummary {
212
+ session: SessionMeta;
213
+ counts: Partial<Record<EventType, number>>;
214
+ lastError?: StoreEvent;
215
+ lastActivity?: number;
216
+ tabs: string[];
217
+ }
218
+ export interface RetentionPolicy {
219
+ /** Delete sessions older than this many days. Default 7. */
220
+ maxAgeDays?: number;
221
+ /** Keep at most this many sessions globally. Default 200. */
222
+ maxSessions?: number;
223
+ /** Delete recording.jsonl files older than this many days. Default 3. */
224
+ recordingRetentionDays?: number;
225
+ /** Keep at most this many recording chunks per session. */
226
+ maxRecordingChunksPerSession?: number;
227
+ /** Keep at most this many bytes of recording data per session. */
228
+ maxRecordingBytesPerSession?: number;
229
+ /** Prefer keeping chunks that overlap rrweb markers when trimming. */
230
+ preserveMarkedChunks?: boolean;
231
+ /** Keep at most this many replay exports per project. Default 50. */
232
+ maxExportsPerProject?: number;
233
+ /** Keep at most this many bytes of replay exports per project. Default 200MB. */
234
+ maxExportBytesPerProject?: number;
235
+ /** Keep at most this many BuildMeta records per project. Default 100. */
236
+ maxBuildsPerProject?: number;
237
+ /** @deprecated Use maxSessions. */
238
+ maxSessionsPerProject?: number;
239
+ /** @deprecated Use maxRecordingChunksPerSession. */
240
+ maxRecordingChunksPerTab?: number;
241
+ /** @deprecated Use maxRecordingBytesPerSession. */
242
+ maxRecordingBytesPerTab?: number;
243
+ }
244
+ export interface PurgeResult {
245
+ sessionsDeleted: number;
246
+ recordingsDeleted: number;
247
+ exportsDeleted: number;
248
+ buildsDeleted?: number;
249
+ bytesFreed: number;
250
+ }
251
+ export interface ITaskStore {
252
+ loadTasks(projectId: string): Task[];
253
+ saveTasks(projectId: string, tasks: Task[]): void;
254
+ }
255
+ export interface MemoryEntry {
256
+ key: string;
257
+ value: string;
258
+ updatedAt: number;
259
+ }
260
+ export interface IMemoryStore {
261
+ get(projectId: string, key: string): MemoryEntry | undefined;
262
+ set(projectId: string, key: string, value: string): MemoryEntry;
263
+ delete(projectId: string, key: string): boolean;
264
+ list(projectId: string): MemoryEntry[];
265
+ }
266
+ export interface IStore {
267
+ /**
268
+ * Open a new build (dev server start / prod build). Returns buildId.
269
+ * Writes projects/{projectId}/builds/{buildId}/meta.json.
270
+ * (Replaces openSession() from v0.3.x)
271
+ */
272
+ openBuild(projectId: string, patch?: Partial<Omit<BuildMeta, 'id' | 'projectId' | 'builtAt'>>): string;
273
+ /**
274
+ * Mark a build as ended.
275
+ * (Replaces closeSession() for build-plugin connections from v0.3.x)
276
+ */
277
+ closeBuild(buildId: string, closedAt?: number): void;
278
+ /**
279
+ * Write or update tab metadata at tabs/{tabId}/meta.json.
280
+ * Merge semantics: caller-provided fields overwrite, others preserved.
281
+ */
282
+ upsertTab(tabId: string, patch: Partial<Omit<TabMeta, 'id'>>): TabMeta;
283
+ /** Get tab metadata. */
284
+ getTab(tabId: string): TabMeta | undefined;
285
+ /**
286
+ * Mark a tab as disconnected.
287
+ * New signature: (tabId, disconnectedAt?) — no sessionId param.
288
+ */
289
+ closeTab(tabId: string, disconnectedAt?: number): void;
290
+ /**
291
+ * Open or update a session (one pageload). Writes sessions/{sessionId}/meta.json.
292
+ * participants list is extended (not replaced) on each call.
293
+ * (Replaces openLoad() from v0.3.x)
294
+ */
295
+ upsertSession(sessionId: string, meta: Partial<Omit<SessionMeta, 'id'>> & {
296
+ tabId: string;
297
+ startedAt: number;
298
+ }): SessionMeta;
299
+ /**
300
+ * Mark a session as ended.
301
+ * (Replaces closeLatestLoad() from v0.3.x)
302
+ */
303
+ closeSession(sessionId: string, endedAt?: number): void;
304
+ /** Get session metadata. */
305
+ getSession(sessionId: string): SessionMeta | undefined;
306
+ /**
307
+ * List sessions by recency.
308
+ * New signature: opts object with optional tabId / projectId / buildId / limit.
309
+ * (Replaces listSessions(projectId, limit?) from v0.3.x)
310
+ */
311
+ listSessions(opts?: {
312
+ tabId?: string;
313
+ projectId?: string;
314
+ buildId?: string;
315
+ limit?: number;
316
+ }): SessionMeta[];
317
+ /**
318
+ * Append a single event to sessions/{sessionId}/timeline.jsonl.
319
+ * event.projectId and event.buildId should be pre-stamped by the bridge.
320
+ * (Replaces append(sessionId=buildId, event, tabId?) from v0.3.x)
321
+ */
322
+ appendEvent(sessionId: string, event: StoreEvent): void;
323
+ /**
324
+ * Append a batch of events.
325
+ * (Replaces appendBatch() from v0.3.x)
326
+ */
327
+ appendEventBatch(sessionId: string, events: StoreEvent[]): void;
328
+ /**
329
+ * Append an rrweb recording chunk to sessions/{sessionId}/recording.jsonl.
330
+ * (Replaces appendRecording(sessionId, tabId, chunk, loadId?) from v0.3.x)
331
+ */
332
+ appendRecording(sessionId: string, chunk: unknown): void;
333
+ /** Write a project-level note. */
334
+ writeNote(projectId: string, key: string, value: string): void;
335
+ /**
336
+ * Upsert project metadata. `id` and `createdAt` are never overwritten.
337
+ * Throws if `patch.parentProjectId` would create a cycle.
338
+ */
339
+ upsertProject(projectId: string, patch: Partial<Omit<ProjectMeta, 'id' | 'createdAt'>>): ProjectMeta;
340
+ /** Read a single project's metadata. */
341
+ getProject(projectId: string): ProjectMeta | undefined;
342
+ /** List all known projects. */
343
+ listProjects(): ProjectMeta[];
344
+ /** Upsert build metadata. Creates the project dir if missing. */
345
+ upsertBuild(projectId: string, buildId: string, patch: Partial<Omit<BuildMeta, 'id' | 'projectId'>>): BuildMeta;
346
+ /** Read a single build's metadata. */
347
+ getBuild(projectId: string, buildId: string): BuildMeta | undefined;
348
+ /** List builds for a project, newest first. */
349
+ listBuilds(projectId: string, limit?: number): BuildMeta[];
350
+ /** Get a forest (or sub-tree from `rootId`) from parentProjectId links. */
351
+ getProjectTree(rootId?: string): ProjectTreeNode[];
352
+ /**
353
+ * Upsert visitor metadata. Merges with existing meta:
354
+ * - `firstSeenAt` preserved; `lastSeenAt` advances
355
+ * - `sessionCount` increments when caller passes `incrementSession: true`
356
+ * - `tabIds` / `projectIds` deduped and LRU-capped at 50
357
+ * - `userId` overwritten if patch carries a non-empty value
358
+ * - `lastEnv` overwritten if patch carries one
359
+ */
360
+ upsertVisitor(visitorId: string, patch: {
361
+ userId?: string;
362
+ seenAt?: number;
363
+ incrementSession?: boolean;
364
+ addTabId?: string;
365
+ addProjectId?: string;
366
+ lastEnv?: VisitorEnv;
367
+ }): VisitorMeta;
368
+ /** Read a single visitor's metadata. */
369
+ getVisitor(visitorId: string): VisitorMeta | undefined;
370
+ /** List known visitors, newest lastSeenAt first. */
371
+ listVisitors(opts?: {
372
+ projectId?: string;
373
+ limit?: number;
374
+ }): VisitorMeta[];
375
+ /**
376
+ * Read the last N events from a session timeline.
377
+ * New signature: no tabId param (tab is implicit per session).
378
+ */
379
+ tail(sessionId: string, opts?: TailOptions): StoreEvent[];
380
+ /** Search events in a session timeline by substring match. */
381
+ search(sessionId: string, query: string, opts?: SearchOptions): StoreEvent[];
382
+ /** List recording chunks for a session. */
383
+ listRecordings(sessionId: string): RecordingChunkSummary[];
384
+ /** Return recording chunks overlapping the requested time window. */
385
+ sliceRecordings(sessionId: string, since: number, until: number): RecordingChunk[];
386
+ /** Persist a replay export. */
387
+ writeExport(input: {
388
+ sessionId: string;
389
+ tabId?: string;
390
+ since: number;
391
+ until: number;
392
+ label?: string;
393
+ events: unknown[];
394
+ startTs: number;
395
+ endTs: number;
396
+ chunkCount: number;
397
+ }): ReplayExportMeta;
398
+ /** Read export metadata by id. */
399
+ getExport(exportId: string): ReplayExportMeta | undefined;
400
+ /** Read the raw events array for an export. */
401
+ readExportEvents(exportId: string): unknown[] | undefined;
402
+ /** List exports for a project, newest first. */
403
+ listExports(projectId: string, limit?: number): ReplayExportMeta[];
404
+ /** Get a summary of a session (counts, last error, etc.). */
405
+ summary(sessionId: string): SessionSummary;
406
+ /** Read project notes. */
407
+ listNotes(projectId: string): Array<{
408
+ key: string;
409
+ value: string;
410
+ ts: number;
411
+ }>;
412
+ /** Delete old sessions and recordings according to retention policy. */
413
+ purge(policy?: RetentionPolicy): PurgeResult;
414
+ /** Close any open file handles. */
415
+ close(): void | Promise<void>;
416
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Store types — the public interface for the JSONL-based persistence layer.
3
+ *
4
+ * v0.4.0 layout (new, flat):
5
+ * {dataDir}/projects/{projectId}/meta.json
6
+ * {dataDir}/projects/{projectId}/tasks.json
7
+ * {dataDir}/projects/{projectId}/memory.json
8
+ * {dataDir}/projects/{projectId}/notes.jsonl
9
+ * {dataDir}/projects/{projectId}/builds/{buildId}/meta.json
10
+ * {dataDir}/tabs/{tabId}/meta.json
11
+ * {dataDir}/sessions/{sessionId}/meta.json ← one per pageload
12
+ * {dataDir}/sessions/{sessionId}/timeline.jsonl ← mixed parent+child events
13
+ * {dataDir}/sessions/{sessionId}/recording.jsonl ← rrweb chunks
14
+ * {dataDir}/visitors/{visitorId}/meta.json ← per-browser identity (0.5+)
15
+ *
16
+ * Legacy layout (v0.3.x, read-only fallback — daemon warns on startup):
17
+ * {dataDir}/{projectId}/sessions/{buildId}/tabs/{tabId}/...
18
+ */
19
+ export {};
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@harness-fe/mcp-server",
3
+ "version": "3.0.1",
4
+ "description": "Unified MCP daemon: stdio MCP for AI agents + WS bridge for Vite plugin and runtime client.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/Morphicai/harness-fe.git",
10
+ "directory": "packages/mcp-server"
11
+ },
12
+ "homepage": "https://github.com/Morphicai/harness-fe#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/Morphicai/harness-fe/issues"
15
+ },
16
+ "main": "dist/index.js",
17
+ "types": "dist/index.d.ts",
18
+ "bin": {
19
+ "harness-fe": "./dist/cli.js"
20
+ },
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "src",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "dependencies": {
37
+ "@modelcontextprotocol/sdk": "^1.26.0",
38
+ "rrweb-player": "1.0.0-alpha.4",
39
+ "ws": "^8.18.0",
40
+ "zod": "^4.4.3",
41
+ "@harness-fe/protocol": "3.0.0",
42
+ "@harness-fe/dashboard-ui": "0.2.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/ws": "^8.5.10",
46
+ "fast-check": "^3.22.0",
47
+ "tsx": "^4.22.3",
48
+ "typescript": "^5.6.0",
49
+ "vitest": "^2.1.9"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "scripts": {
55
+ "build": "tsc",
56
+ "postbuild": "node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"",
57
+ "dev": "tsc --watch --preserveWatchOutput",
58
+ "watch": "tsc --watch --preserveWatchOutput",
59
+ "start": "tsx src/cli.ts",
60
+ "typecheck": "tsc --noEmit",
61
+ "test": "vitest run"
62
+ }
63
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { IncomingMessage } from 'node:http';
3
+ import {
4
+ extractToken,
5
+ isAuthEnabled,
6
+ isAuthorized,
7
+ verifyToken,
8
+ } from './auth.js';
9
+
10
+ function fakeReq(init: { headers?: Record<string, string>; url?: string }): IncomingMessage {
11
+ return {
12
+ headers: init.headers ?? {},
13
+ url: init.url ?? '/',
14
+ } as unknown as IncomingMessage;
15
+ }
16
+
17
+ describe('auth: isAuthEnabled', () => {
18
+ it('disabled when no token', () => {
19
+ expect(isAuthEnabled({})).toBe(false);
20
+ expect(isAuthEnabled({ token: '' })).toBe(false);
21
+ });
22
+ it('enabled with token', () => {
23
+ expect(isAuthEnabled({ token: 'x' })).toBe(true);
24
+ });
25
+ });
26
+
27
+ describe('auth: extractToken', () => {
28
+ it('reads Authorization: Bearer …', () => {
29
+ const req = fakeReq({ headers: { authorization: 'Bearer my-token' } });
30
+ expect(extractToken(req)).toBe('my-token');
31
+ });
32
+ it('reads cookie harness_fe_token=…', () => {
33
+ const req = fakeReq({ headers: { cookie: 'other=1; harness_fe_token=cookie-tok; bar=2' } });
34
+ expect(extractToken(req)).toBe('cookie-tok');
35
+ });
36
+ it('reads ?token=… query string', () => {
37
+ const req = fakeReq({ url: '/dashboard?foo=1&token=qs-tok&bar=2' });
38
+ expect(extractToken(req)).toBe('qs-tok');
39
+ });
40
+ it('reads WS subprotocol harness-fe.token.…', () => {
41
+ const req = fakeReq({
42
+ headers: { 'sec-websocket-protocol': 'json, harness-fe.token.ws-tok' },
43
+ });
44
+ expect(extractToken(req)).toBe('ws-tok');
45
+ });
46
+ it('header beats cookie beats query', () => {
47
+ const req = fakeReq({
48
+ headers: { authorization: 'Bearer hdr', cookie: 'harness_fe_token=ck' },
49
+ url: '/?token=qs',
50
+ });
51
+ expect(extractToken(req)).toBe('hdr');
52
+ });
53
+ it('returns undefined when none provided', () => {
54
+ expect(extractToken(fakeReq({}))).toBeUndefined();
55
+ });
56
+ });
57
+
58
+ describe('auth: verifyToken (timing-safe)', () => {
59
+ it('matches identical tokens', () => {
60
+ expect(verifyToken('abc', 'abc')).toBe(true);
61
+ });
62
+ it('rejects mismatched tokens', () => {
63
+ expect(verifyToken('abc', 'abd')).toBe(false);
64
+ });
65
+ it('rejects empty/undefined', () => {
66
+ expect(verifyToken(undefined, 'abc')).toBe(false);
67
+ expect(verifyToken('', 'abc')).toBe(false);
68
+ expect(verifyToken('abc', '')).toBe(false);
69
+ });
70
+ it('handles different length safely (no throw)', () => {
71
+ expect(verifyToken('a', 'abcdefg')).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe('auth: isAuthorized', () => {
76
+ it('passes everything when auth disabled', () => {
77
+ expect(isAuthorized(fakeReq({}), {})).toBe(true);
78
+ });
79
+ it('passes valid token via header', () => {
80
+ const req = fakeReq({ headers: { authorization: 'Bearer s3cret' } });
81
+ expect(isAuthorized(req, { token: 's3cret' })).toBe(true);
82
+ });
83
+ it('rejects missing token when auth enabled', () => {
84
+ expect(isAuthorized(fakeReq({}), { token: 's3cret' })).toBe(false);
85
+ });
86
+ it('rejects wrong token', () => {
87
+ const req = fakeReq({ headers: { authorization: 'Bearer nope' } });
88
+ expect(isAuthorized(req, { token: 's3cret' })).toBe(false);
89
+ });
90
+ });