@harness-fe/mcp-server 4.0.0-next.1 → 4.0.0-next.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +15 -0
- package/dist/daemon.d.ts +3 -3
- package/dist/daemon.js +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +49 -15
- package/dist/mcpHttp.d.ts +2 -2
- package/dist/mcpHttp.js +8 -2
- package/package.json +5 -7
- package/src/bin.ts +19 -0
- package/src/daemon.ts +3 -3
- package/src/experimental.test.ts +2 -2
- package/src/index.ts +4 -4
- package/src/mcp.ts +51 -19
- package/src/mcpHttp.test.ts +3 -3
- package/src/mcpHttp.ts +10 -4
- package/src/mcpLayer.e2e.test.ts +2 -2
- package/src/newCapabilities.e2e.test.ts +3 -3
- package/dist/auth.d.ts +0 -53
- package/dist/auth.js +0 -212
- package/dist/bridge.d.ts +0 -323
- package/dist/bridge.js +0 -1618
- package/dist/cli.d.ts +0 -18
- package/dist/cli.js +0 -293
- package/dist/dashboardApi.d.ts +0 -40
- package/dist/dashboardApi.js +0 -142
- package/dist/dashboardSpa.d.ts +0 -18
- package/dist/dashboardSpa.js +0 -180
- package/dist/dashboardUrl.d.ts +0 -13
- package/dist/dashboardUrl.js +0 -18
- package/dist/eventsHandler.d.ts +0 -24
- package/dist/eventsHandler.js +0 -114
- package/dist/identity.d.ts +0 -74
- package/dist/identity.js +0 -101
- package/dist/openBrowser.d.ts +0 -33
- package/dist/openBrowser.js +0 -63
- package/dist/remoteBridge.d.ts +0 -61
- package/dist/remoteBridge.js +0 -307
- package/dist/replayCreate.d.ts +0 -36
- package/dist/replayCreate.js +0 -156
- package/dist/replayViewer.d.ts +0 -20
- package/dist/replayViewer.js +0 -168
- package/dist/sessionRouter.d.ts +0 -45
- package/dist/sessionRouter.js +0 -88
- package/dist/store/JsonMemoryStore.d.ts +0 -52
- package/dist/store/JsonMemoryStore.js +0 -119
- package/dist/store/JsonTaskStore.d.ts +0 -21
- package/dist/store/JsonTaskStore.js +0 -53
- package/dist/store/JsonlStore.d.ts +0 -128
- package/dist/store/JsonlStore.js +0 -1172
- package/dist/store/MemoryEventStore.d.ts +0 -47
- package/dist/store/MemoryEventStore.js +0 -111
- package/dist/store/WriteQueue.d.ts +0 -51
- package/dist/store/WriteQueue.js +0 -142
- package/dist/store/index.d.ts +0 -6
- package/dist/store/index.js +0 -5
- package/dist/store/types.d.ts +0 -427
- package/dist/store/types.js +0 -19
- package/dist/visitorTimeline.d.ts +0 -24
- package/dist/visitorTimeline.js +0 -68
- package/src/auth.test.ts +0 -90
- package/src/auth.ts +0 -248
- package/src/bridge-auth.test.ts +0 -196
- package/src/bridge.test.ts +0 -1708
- package/src/bridge.ts +0 -1854
- package/src/cli.ts +0 -338
- package/src/dashboardApi.test.ts +0 -235
- package/src/dashboardApi.ts +0 -184
- package/src/dashboardSpa.test.ts +0 -239
- package/src/dashboardSpa.ts +0 -195
- package/src/dashboardUrl.test.ts +0 -46
- package/src/dashboardUrl.ts +0 -28
- package/src/eventsHandler.test.ts +0 -247
- package/src/eventsHandler.ts +0 -136
- package/src/identity.test.ts +0 -86
- package/src/identity.ts +0 -116
- package/src/openBrowser.test.ts +0 -103
- package/src/openBrowser.ts +0 -81
- package/src/remoteBridge.test.ts +0 -119
- package/src/remoteBridge.ts +0 -404
- package/src/replay.test.ts +0 -271
- package/src/replayCreate.ts +0 -194
- package/src/replayViewer.ts +0 -173
- package/src/sessionRouter.ts +0 -119
- package/src/store/JsonMemoryStore.test.ts +0 -175
- package/src/store/JsonMemoryStore.ts +0 -128
- package/src/store/JsonTaskStore.test.ts +0 -212
- package/src/store/JsonTaskStore.ts +0 -59
- package/src/store/JsonlStore.test.ts +0 -1538
- package/src/store/JsonlStore.ts +0 -1325
- package/src/store/MemoryEventStore.test.ts +0 -119
- package/src/store/MemoryEventStore.ts +0 -151
- package/src/store/WriteQueue.ts +0 -165
- package/src/store/identityTagging.test.ts +0 -67
- package/src/store/index.ts +0 -29
- package/src/store/types.ts +0 -532
- package/src/visitorTimeline.test.ts +0 -197
- package/src/visitorTimeline.ts +0 -89
package/dist/bin.d.ts
ADDED
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 '
|
|
28
|
-
import type { EventStore, IStore } from '
|
|
29
|
-
import type { ITaskStore, IMemoryStore } from '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
7
|
-
export type { IStore, ITaskStore, IMemoryStore, EventStore, EventId, StreamId, ProjectMeta, ProjectTreeNode, BuildMeta, SessionMeta, TabMeta, } from '
|
|
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 '
|
|
1
|
+
export { Bridge, defaultDataDir } from '@harness-fe/daemon';
|
|
2
2
|
export { createDaemon } from './daemon.js';
|
|
3
|
-
export { SessionRouter } from '
|
|
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 '
|
|
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 '
|
|
10
|
-
import type { AuthOptions } from '
|
|
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 { identifyPrincipal } from '
|
|
13
|
-
import { RemoteBridge } from '
|
|
14
|
-
import { buildVisitorTimeline } from '
|
|
15
|
-
import { createReplayExport } from '
|
|
16
|
-
import { openBrowser } from '
|
|
17
|
-
import { buildDashboardUrl } from '
|
|
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.
|
|
@@ -59,7 +59,7 @@ export function createMcpServer(bridge, options = {}) {
|
|
|
59
59
|
const leaderStore = bridge.store;
|
|
60
60
|
if (leaderStore != null) {
|
|
61
61
|
const memoryStore = bridge.getMemoryStore();
|
|
62
|
-
registerStoreTools(server, leaderStore, memoryStore, bridge);
|
|
62
|
+
registerStoreTools(server, leaderStore, memoryStore, bridge, options.auth);
|
|
63
63
|
}
|
|
64
64
|
else if (bridge instanceof RemoteBridge) {
|
|
65
65
|
registerRemoteStoreTools(server, bridge);
|
|
@@ -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.',
|
|
@@ -460,8 +480,16 @@ function registerTools(server, bridge, auth) {
|
|
|
460
480
|
status: taskStatusEnum.optional(),
|
|
461
481
|
limit: z.number().int().positive().optional(),
|
|
462
482
|
},
|
|
463
|
-
}, async ({ status, limit }) => {
|
|
464
|
-
const
|
|
483
|
+
}, async ({ status, limit }, extra) => {
|
|
484
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
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, ownerChainOf(t.projectId, store)))
|
|
492
|
+
: all.filter((t) => canSee(principal, t.createdBy));
|
|
465
493
|
const summary = tasks.map((t) => ({
|
|
466
494
|
id: t.id,
|
|
467
495
|
status: t.status,
|
|
@@ -547,16 +575,19 @@ function registerExperimentalTools(server, bridge) {
|
|
|
547
575
|
void bridge;
|
|
548
576
|
}
|
|
549
577
|
// ─── Store tools (session history, timeline, memory) ──────────────────────────
|
|
550
|
-
function registerStoreTools(server, store, memoryStore, bridge) {
|
|
578
|
+
function registerStoreTools(server, store, memoryStore, bridge, auth) {
|
|
551
579
|
server.registerTool('session.list', {
|
|
552
580
|
description: 'List recent sessions for a project. Returns session IDs, start times, and status.',
|
|
553
581
|
inputSchema: {
|
|
554
582
|
projectId: z.string().describe('Project ID (package.json name)'),
|
|
555
583
|
limit: z.number().int().positive().default(10).optional(),
|
|
556
584
|
},
|
|
557
|
-
}, async ({ projectId, limit }) => {
|
|
558
|
-
const
|
|
559
|
-
|
|
585
|
+
}, async ({ projectId, limit }, extra) => {
|
|
586
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
587
|
+
// Owning a project (or a host ancestor) grants its whole session set.
|
|
588
|
+
if (!canSeeProject(principal, ownerChainOf(projectId, store)))
|
|
589
|
+
return ok([]);
|
|
590
|
+
return ok(store.listSessions({ projectId, limit: limit ?? 10 }));
|
|
560
591
|
});
|
|
561
592
|
server.registerTool('session.summary', {
|
|
562
593
|
description: 'Get a summary of a session: event counts, last error, active tabs.',
|
|
@@ -624,8 +655,11 @@ function registerStoreTools(server, store, memoryStore, bridge) {
|
|
|
624
655
|
server.registerTool('project.sessions', {
|
|
625
656
|
description: 'List all projects with their most recent session info.',
|
|
626
657
|
inputSchema: {},
|
|
627
|
-
}, async () => {
|
|
628
|
-
const
|
|
658
|
+
}, async (_args, extra) => {
|
|
659
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
660
|
+
const projects = store
|
|
661
|
+
.listProjects()
|
|
662
|
+
.filter((p) => canSeeProject(principal, ownerChainOf(p.id, store)));
|
|
629
663
|
const result = projects.map((p) => ({
|
|
630
664
|
...p,
|
|
631
665
|
recentSessions: store.listSessions({ projectId: p.id, limit: 3 }),
|
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 '
|
|
10
|
-
import type { EventStore } from '
|
|
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
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
10
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
11
11
|
import { createMcpServer } from './mcp.js';
|
|
12
|
-
import {
|
|
12
|
+
import { identifyPrincipal } from '@harness-fe/daemon';
|
|
13
|
+
import { runWithCaller } from '@harness-fe/daemon';
|
|
14
|
+
import { MemoryEventStore } from '@harness-fe/daemon';
|
|
13
15
|
/**
|
|
14
16
|
* Mount the MCP HTTP transport on the bridge's HTTP server. Bridge must
|
|
15
17
|
* already have been started; calls `prependHttpHandler` so it runs before
|
|
@@ -37,13 +39,17 @@ export async function startMcpHttpServer(bridge, opts = {}) {
|
|
|
37
39
|
if (typeof b.prependHttpHandler !== 'function') {
|
|
38
40
|
throw new Error('mcpHttp: bridge does not support prependHttpHandler (need a Bridge instance with HTTP server)');
|
|
39
41
|
}
|
|
42
|
+
const auth = b.getAuthOptions();
|
|
40
43
|
b.prependHttpHandler(async (req, res) => {
|
|
41
44
|
const url = req.url ?? '';
|
|
42
45
|
const qi = url.indexOf('?');
|
|
43
46
|
const reqPath = qi < 0 ? url : url.slice(0, qi);
|
|
44
47
|
if (reqPath !== path)
|
|
45
48
|
return false;
|
|
46
|
-
|
|
49
|
+
// Establish the per-call caller for command-target scoping (4.0 · A):
|
|
50
|
+
// every sendCommand within this request reads it via currentCaller().
|
|
51
|
+
const principal = identifyPrincipal(req.headers, auth);
|
|
52
|
+
await runWithCaller(principal, () => transport.handleRequest(req, res));
|
|
47
53
|
return true;
|
|
48
54
|
});
|
|
49
55
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harness-fe/mcp-server",
|
|
3
|
-
"version": "4.0.0-next.
|
|
3
|
+
"version": "4.0.0-next.3",
|
|
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/
|
|
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/
|
|
42
|
-
"@harness-fe/
|
|
40
|
+
"@harness-fe/protocol": "4.0.0-next.0",
|
|
41
|
+
"@harness-fe/daemon": "4.0.0-next.3"
|
|
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/
|
|
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 '
|
|
30
|
+
import { Bridge } from '@harness-fe/daemon';
|
|
31
31
|
import { startMcpHttpServer } from './mcpHttp.js';
|
|
32
|
-
import type { EventStore, IStore } from '
|
|
33
|
-
import type { ITaskStore, IMemoryStore } from '
|
|
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). */
|
package/src/experimental.test.ts
CHANGED
|
@@ -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 '
|
|
21
|
-
import { JsonlStore } from '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
28
|
-
import type { Bridge } from '
|
|
29
|
-
import type { AuthOptions } from '
|
|
30
|
-
import { identifyPrincipal } from '
|
|
31
|
-
import { RemoteBridge } from '
|
|
32
|
-
import type { IStore, IMemoryStore } from '
|
|
33
|
-
import { buildVisitorTimeline } from '
|
|
34
|
-
import { createReplayExport } from '
|
|
35
|
-
import { openBrowser } from '
|
|
36
|
-
import { buildDashboardUrl } from '
|
|
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
|
|
|
@@ -102,7 +102,7 @@ export function createMcpServer(bridge: IBridge, options: McpServerOptions = {})
|
|
|
102
102
|
const leaderStore = (bridge as Bridge).store;
|
|
103
103
|
if (leaderStore != null) {
|
|
104
104
|
const memoryStore = bridge.getMemoryStore();
|
|
105
|
-
registerStoreTools(server, leaderStore, memoryStore, bridge);
|
|
105
|
+
registerStoreTools(server, leaderStore, memoryStore, bridge, options.auth);
|
|
106
106
|
} else if (bridge instanceof RemoteBridge) {
|
|
107
107
|
registerRemoteStoreTools(server, bridge);
|
|
108
108
|
}
|
|
@@ -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(
|
|
@@ -750,8 +769,16 @@ function registerTools(server: McpServer, bridge: IBridge, auth?: AuthOptions):
|
|
|
750
769
|
limit: z.number().int().positive().optional(),
|
|
751
770
|
},
|
|
752
771
|
},
|
|
753
|
-
async ({ status, limit }) => {
|
|
754
|
-
const
|
|
772
|
+
async ({ status, limit }, extra) => {
|
|
773
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
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, ownerChainOf(t.projectId, store)))
|
|
781
|
+
: all.filter((t) => canSee(principal, t.createdBy));
|
|
755
782
|
const summary = tasks.map((t) => ({
|
|
756
783
|
id: t.id,
|
|
757
784
|
status: t.status,
|
|
@@ -865,7 +892,7 @@ function registerExperimentalTools(server: McpServer, bridge: IBridge): void {
|
|
|
865
892
|
|
|
866
893
|
// ─── Store tools (session history, timeline, memory) ──────────────────────────
|
|
867
894
|
|
|
868
|
-
function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemoryStore, bridge: IBridge): void {
|
|
895
|
+
function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemoryStore, bridge: IBridge, auth?: AuthOptions): void {
|
|
869
896
|
server.registerTool(
|
|
870
897
|
'session.list',
|
|
871
898
|
{
|
|
@@ -875,9 +902,11 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
|
|
|
875
902
|
limit: z.number().int().positive().default(10).optional(),
|
|
876
903
|
},
|
|
877
904
|
},
|
|
878
|
-
async ({ projectId, limit }) => {
|
|
879
|
-
const
|
|
880
|
-
|
|
905
|
+
async ({ projectId, limit }, extra) => {
|
|
906
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
907
|
+
// Owning a project (or a host ancestor) grants its whole session set.
|
|
908
|
+
if (!canSeeProject(principal, ownerChainOf(projectId, store))) return ok([]);
|
|
909
|
+
return ok(store.listSessions({ projectId, limit: limit ?? 10 }));
|
|
881
910
|
},
|
|
882
911
|
);
|
|
883
912
|
|
|
@@ -963,8 +992,11 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
|
|
|
963
992
|
description: 'List all projects with their most recent session info.',
|
|
964
993
|
inputSchema: {},
|
|
965
994
|
},
|
|
966
|
-
async () => {
|
|
967
|
-
const
|
|
995
|
+
async (_args, extra) => {
|
|
996
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
997
|
+
const projects = store
|
|
998
|
+
.listProjects()
|
|
999
|
+
.filter((p) => canSeeProject(principal, ownerChainOf(p.id, store)));
|
|
968
1000
|
const result = projects.map((p) => ({
|
|
969
1001
|
...p,
|
|
970
1002
|
recentSessions: store.listSessions({ projectId: p.id, limit: 3 }),
|
package/src/mcpHttp.test.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
2
|
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
-
import { Bridge } from '
|
|
3
|
+
import { Bridge } from '@harness-fe/daemon';
|
|
4
4
|
import { startMcpHttpServer } from './mcpHttp.js';
|
|
5
|
-
import { MemoryEventStore } from '
|
|
6
|
-
import type { EventStore } from '
|
|
5
|
+
import { MemoryEventStore } from '@harness-fe/daemon';
|
|
6
|
+
import type { EventStore } from '@harness-fe/daemon';
|
|
7
7
|
|
|
8
8
|
const cleanups: Array<() => Promise<void> | void> = [];
|
|
9
9
|
|
package/src/mcpHttp.ts
CHANGED
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
import { randomUUID } from 'node:crypto';
|
|
11
11
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
12
12
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
13
|
-
import type { Bridge, IBridge } from '
|
|
13
|
+
import type { Bridge, IBridge } from '@harness-fe/daemon';
|
|
14
14
|
import { createMcpServer } from './mcp.js';
|
|
15
|
-
import {
|
|
16
|
-
import
|
|
15
|
+
import { identifyPrincipal } from '@harness-fe/daemon';
|
|
16
|
+
import { runWithCaller } from '@harness-fe/daemon';
|
|
17
|
+
import { MemoryEventStore } from '@harness-fe/daemon';
|
|
18
|
+
import type { EventStore } from '@harness-fe/daemon';
|
|
17
19
|
|
|
18
20
|
export interface McpHttpOptions {
|
|
19
21
|
/** URL path the transport listens on. Default `/mcp`. */
|
|
@@ -81,12 +83,16 @@ export async function startMcpHttpServer(
|
|
|
81
83
|
);
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
const auth = b.getAuthOptions();
|
|
84
87
|
b.prependHttpHandler(async (req: IncomingMessage, res: ServerResponse) => {
|
|
85
88
|
const url = req.url ?? '';
|
|
86
89
|
const qi = url.indexOf('?');
|
|
87
90
|
const reqPath = qi < 0 ? url : url.slice(0, qi);
|
|
88
91
|
if (reqPath !== path) return false;
|
|
89
|
-
|
|
92
|
+
// Establish the per-call caller for command-target scoping (4.0 · A):
|
|
93
|
+
// every sendCommand within this request reads it via currentCaller().
|
|
94
|
+
const principal = identifyPrincipal(req.headers, auth);
|
|
95
|
+
await runWithCaller(principal, () => transport.handleRequest(req, res));
|
|
90
96
|
return true;
|
|
91
97
|
});
|
|
92
98
|
|
package/src/mcpLayer.e2e.test.ts
CHANGED
|
@@ -16,8 +16,8 @@ import { join } from 'node:path';
|
|
|
16
16
|
import { randomUUID } from 'node:crypto';
|
|
17
17
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
18
18
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
19
|
-
import { Bridge } from '
|
|
20
|
-
import { JsonlStore } from '
|
|
19
|
+
import { Bridge } from '@harness-fe/daemon';
|
|
20
|
+
import { JsonlStore } from '@harness-fe/daemon';
|
|
21
21
|
import { createMcpServer } from './mcp.js';
|
|
22
22
|
import type {
|
|
23
23
|
EventFrame,
|
|
@@ -14,8 +14,8 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|
|
14
14
|
import { tmpdir } from 'node:os';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { randomUUID } from 'node:crypto';
|
|
17
|
-
import { Bridge } from '
|
|
18
|
-
import { JsonlStore } from '
|
|
17
|
+
import { Bridge } from '@harness-fe/daemon';
|
|
18
|
+
import { JsonlStore } from '@harness-fe/daemon';
|
|
19
19
|
import type {
|
|
20
20
|
EventFrame,
|
|
21
21
|
HelloAckFrame,
|
|
@@ -23,7 +23,7 @@ import type {
|
|
|
23
23
|
StorageEntry,
|
|
24
24
|
WsEntry,
|
|
25
25
|
} from '@harness-fe/protocol';
|
|
26
|
-
import { buildVisitorTimeline } from '
|
|
26
|
+
import { buildVisitorTimeline } from '@harness-fe/daemon';
|
|
27
27
|
|
|
28
28
|
interface TestEnv {
|
|
29
29
|
bridge: Bridge;
|
package/dist/auth.d.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Token-based auth for the bridge's HTTP + WS surfaces.
|
|
3
|
-
*
|
|
4
|
-
* Loopback (127.*, localhost, ::1) — auth disabled by default; the daemon
|
|
5
|
-
* trusts everything that can reach the loopback socket. As soon as the
|
|
6
|
-
* daemon is bound to a non-loopback host (e.g. 0.0.0.0 for LAN debugging),
|
|
7
|
-
* the CLI requires a token and this module enforces it on every HTTP route
|
|
8
|
-
* and WS upgrade.
|
|
9
|
-
*
|
|
10
|
-
* Why a single module: dashboard / replay viewer / events handler /
|
|
11
|
-
* MCP HTTP transport all live behind the same bridge HTTP server. Bridge
|
|
12
|
-
* wraps requests with `isAuthorized` once, so individual handlers never
|
|
13
|
-
* see unauthenticated traffic and don't carry auth code.
|
|
14
|
-
*/
|
|
15
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
16
|
-
export declare const DEFAULT_COOKIE_NAME = "harness_fe_token";
|
|
17
|
-
export declare const DEFAULT_LOGIN_PATH = "/__auth";
|
|
18
|
-
export interface AuthOptions {
|
|
19
|
-
/** Expected token. Empty/undefined disables token auth. */
|
|
20
|
-
token?: string;
|
|
21
|
-
/**
|
|
22
|
-
* Custom authorization predicate. When supplied, runs *instead of* the
|
|
23
|
-
* token check on every HTTP request and WS upgrade. Synchronous: the
|
|
24
|
-
* WS upgrade handshake completes inline. For host-injected auth that
|
|
25
|
-
* needs an async lookup, cache the result in a cookie via the host's
|
|
26
|
-
* own middleware and have `authorize` read the cookie.
|
|
27
|
-
*/
|
|
28
|
-
authorize?: (req: IncomingMessage) => boolean;
|
|
29
|
-
/** Cookie name set after a successful login. Default: harness_fe_token. */
|
|
30
|
-
cookieName?: string;
|
|
31
|
-
/** POST path that consumes the login form. Default: /__auth. */
|
|
32
|
-
loginPath?: string;
|
|
33
|
-
}
|
|
34
|
-
export declare function isAuthEnabled(opts: AuthOptions): boolean;
|
|
35
|
-
/** Pull token from header / cookie / query / WS subprotocol (first match wins). */
|
|
36
|
-
export declare function extractToken(req: IncomingMessage, opts?: AuthOptions): string | undefined;
|
|
37
|
-
/**
|
|
38
|
-
* Constant-time token compare. Hashing both sides first means we always
|
|
39
|
-
* compare equal-length buffers, sidestepping the length-leak that a raw
|
|
40
|
-
* timingSafeEqual on user input would have.
|
|
41
|
-
*/
|
|
42
|
-
export declare function verifyToken(provided: string | undefined, expected: string): boolean;
|
|
43
|
-
/** True if request is allowed (auth disabled, custom predicate accepts, or token matches). */
|
|
44
|
-
export declare function isAuthorized(req: IncomingMessage, opts: AuthOptions): boolean;
|
|
45
|
-
/**
|
|
46
|
-
* Write a 401 response. Browsers (Accept: text/html) get a minimal login
|
|
47
|
-
* form they can post the token through; everything else gets JSON.
|
|
48
|
-
*/
|
|
49
|
-
export declare function sendUnauthorized(req: IncomingMessage, res: ServerResponse, opts: AuthOptions): void;
|
|
50
|
-
/**
|
|
51
|
-
* Handle POST {loginPath}: read form body, verify token, set cookie, 303 → next.
|
|
52
|
-
*/
|
|
53
|
-
export declare function handleLoginPost(req: IncomingMessage, res: ServerResponse, opts: AuthOptions): Promise<void>;
|