@harness-fe/mcp-server 4.0.0-next.2 → 4.0.0-next.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +65 -19
- package/dist/mcpHttp.d.ts +2 -2
- package/dist/mcpHttp.js +88 -18
- 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 +67 -23
- package/src/mcpHttp.test.ts +52 -3
- package/src/mcpHttp.ts +102 -23
- 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 -90
- package/dist/identity.js +0 -123
- 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 -109
- package/src/identity.ts +0 -137
- 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 { canSee, 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.
|
|
@@ -88,6 +88,26 @@ function err(message) {
|
|
|
88
88
|
isError: true,
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Owner chain of a project for tenant isolation (4.0 · A — binding/tagging):
|
|
93
|
+
* the project's own `createdBy` followed by its ancestors' (walked via
|
|
94
|
+
* `parentProjectId`, self → root, cycle-safe). Feed to `canSeeProject` so a
|
|
95
|
+
* host agent sees its sub-apps' data. Empty when the project is unknown.
|
|
96
|
+
*/
|
|
97
|
+
function ownerChainOf(projectId, store) {
|
|
98
|
+
const chain = [];
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
let id = projectId;
|
|
101
|
+
while (id && !seen.has(id)) {
|
|
102
|
+
seen.add(id);
|
|
103
|
+
const p = store.getProject(id);
|
|
104
|
+
if (!p)
|
|
105
|
+
break;
|
|
106
|
+
chain.push(p.createdBy);
|
|
107
|
+
id = p.parentProjectId;
|
|
108
|
+
}
|
|
109
|
+
return chain;
|
|
110
|
+
}
|
|
91
111
|
function registerTools(server, bridge, auth) {
|
|
92
112
|
server.registerTool(COMMAND.PAGE_CLICK, {
|
|
93
113
|
description: 'Click on a DOM element resolved by the selector.',
|
|
@@ -462,8 +482,14 @@ function registerTools(server, bridge, auth) {
|
|
|
462
482
|
},
|
|
463
483
|
}, async ({ status, limit }, extra) => {
|
|
464
484
|
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
465
|
-
|
|
466
|
-
|
|
485
|
+
// Tenant isolation by project ownership (4.0 · A): a task is visible
|
|
486
|
+
// when the caller owns its project (or a host ancestor). Falls back
|
|
487
|
+
// to the submitter tag when no store is configured (in-memory mode).
|
|
488
|
+
const store = bridge.store;
|
|
489
|
+
const all = await bridge.listTasks({ status: status ?? 'pending', limit });
|
|
490
|
+
const tasks = store
|
|
491
|
+
? all.filter((t) => canSeeProject(principal, t.projectId, ownerChainOf(t.projectId, store)))
|
|
492
|
+
: all.filter((t) => canSee(principal, t.createdBy));
|
|
467
493
|
const summary = tasks.map((t) => ({
|
|
468
494
|
id: t.id,
|
|
469
495
|
status: t.status,
|
|
@@ -492,14 +518,34 @@ function registerTools(server, bridge, auth) {
|
|
|
492
518
|
return ok(task);
|
|
493
519
|
});
|
|
494
520
|
server.registerTool(COMMAND.TASKS_RESOLVE, {
|
|
495
|
-
description: 'Mark a task as resolved with an optional note
|
|
521
|
+
description: 'Mark a task as resolved with an optional note and structured resolution. ' +
|
|
522
|
+
'Use after addressing the user request. Pass `resolution` to close the ' +
|
|
523
|
+
'feedback loop: how it was fixed (type), the fix commit/PR, and the ' +
|
|
524
|
+
'verificationSessionId of the post-fix re-test that proved the fix.',
|
|
496
525
|
inputSchema: {
|
|
497
526
|
taskId: z.string(),
|
|
498
527
|
note: z.string().optional(),
|
|
528
|
+
resolution: z
|
|
529
|
+
.object({
|
|
530
|
+
type: z
|
|
531
|
+
.enum(['code-fix', 'config', 'wontfix', 'duplicate', 'cannot-reproduce'])
|
|
532
|
+
.optional(),
|
|
533
|
+
commit: z.string().optional().describe('Git commit SHA of the fix.'),
|
|
534
|
+
prUrl: z.string().optional().describe('Pull-request URL for the fix.'),
|
|
535
|
+
verificationSessionId: z
|
|
536
|
+
.string()
|
|
537
|
+
.optional()
|
|
538
|
+
.describe('Session id of the post-fix re-test that verified the fix.'),
|
|
539
|
+
verifiedAt: z
|
|
540
|
+
.number()
|
|
541
|
+
.optional()
|
|
542
|
+
.describe('Epoch ms; defaulted when verificationSessionId is given.'),
|
|
543
|
+
})
|
|
544
|
+
.optional(),
|
|
499
545
|
},
|
|
500
|
-
}, async ({ taskId, note }, extra) => {
|
|
546
|
+
}, async ({ taskId, note, resolution }, extra) => {
|
|
501
547
|
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
502
|
-
const task = await bridge.resolveTask(taskId, note, principal);
|
|
548
|
+
const task = await bridge.resolveTask(taskId, note, resolution, principal);
|
|
503
549
|
if (!task) {
|
|
504
550
|
throw new Error(`tasks.resolve: no task with id "${taskId}"`);
|
|
505
551
|
}
|
|
@@ -558,10 +604,10 @@ function registerStoreTools(server, store, memoryStore, bridge, auth) {
|
|
|
558
604
|
},
|
|
559
605
|
}, async ({ projectId, limit }, extra) => {
|
|
560
606
|
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
return ok(
|
|
607
|
+
// Owning a project (or a host ancestor) grants its whole session set.
|
|
608
|
+
if (!canSeeProject(principal, projectId, ownerChainOf(projectId, store)))
|
|
609
|
+
return ok([]);
|
|
610
|
+
return ok(store.listSessions({ projectId, limit: limit ?? 10 }));
|
|
565
611
|
});
|
|
566
612
|
server.registerTool('session.summary', {
|
|
567
613
|
description: 'Get a summary of a session: event counts, last error, active tabs.',
|
|
@@ -631,12 +677,12 @@ function registerStoreTools(server, store, memoryStore, bridge, auth) {
|
|
|
631
677
|
inputSchema: {},
|
|
632
678
|
}, async (_args, extra) => {
|
|
633
679
|
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
634
|
-
const projects = store
|
|
680
|
+
const projects = store
|
|
681
|
+
.listProjects()
|
|
682
|
+
.filter((p) => canSeeProject(principal, p.id, ownerChainOf(p.id, store)));
|
|
635
683
|
const result = projects.map((p) => ({
|
|
636
684
|
...p,
|
|
637
|
-
recentSessions: store
|
|
638
|
-
.listSessions({ projectId: p.id, limit: 3 })
|
|
639
|
-
.filter((s) => canSee(principal, s.createdBy)),
|
|
685
|
+
recentSessions: store.listSessions({ projectId: p.id, limit: 3 }),
|
|
640
686
|
}));
|
|
641
687
|
return ok(result);
|
|
642
688
|
});
|
package/dist/mcpHttp.d.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* `http://<host>:<port>/mcp` and authenticate via `Authorization: Bearer
|
|
7
7
|
* <token>` like any other client.
|
|
8
8
|
*/
|
|
9
|
-
import type { IBridge } from '
|
|
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
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
10
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
11
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
11
12
|
import { createMcpServer } from './mcp.js';
|
|
12
|
-
import {
|
|
13
|
+
import { identifyPrincipal } from '@harness-fe/daemon';
|
|
14
|
+
import { runWithCaller } from '@harness-fe/daemon';
|
|
15
|
+
import { MemoryEventStore } from '@harness-fe/daemon';
|
|
13
16
|
/**
|
|
14
17
|
* Mount the MCP HTTP transport on the bridge's HTTP server. Bridge must
|
|
15
18
|
* already have been started; calls `prependHttpHandler` so it runs before
|
|
@@ -18,38 +21,105 @@ import { MemoryEventStore } from './store/MemoryEventStore.js';
|
|
|
18
21
|
export async function startMcpHttpServer(bridge, opts = {}) {
|
|
19
22
|
const path = opts.path ?? '/mcp';
|
|
20
23
|
const stateful = opts.stateful !== false;
|
|
21
|
-
const eventStore = opts.eventStore === null
|
|
22
|
-
? undefined
|
|
23
|
-
: opts.eventStore ?? new MemoryEventStore();
|
|
24
|
-
// Pass the daemon's auth so MCP tools can identify the per-call principal
|
|
25
|
-
// from request headers (4.0 · P4). stdio (startMcpStdioServer) omits this,
|
|
26
|
-
// so stdio calls resolve to the local principal.
|
|
27
|
-
const server = createMcpServer(bridge, {
|
|
28
|
-
experimentalEnvVar: opts.experimentalEnvVar,
|
|
29
|
-
auth: bridge.getAuthOptions(),
|
|
30
|
-
});
|
|
31
|
-
const transport = new StreamableHTTPServerTransport({
|
|
32
|
-
sessionIdGenerator: stateful ? () => randomUUID() : undefined,
|
|
33
|
-
eventStore,
|
|
34
|
-
});
|
|
35
|
-
await server.connect(transport);
|
|
36
24
|
const b = bridge;
|
|
37
25
|
if (typeof b.prependHttpHandler !== 'function') {
|
|
38
26
|
throw new Error('mcpHttp: bridge does not support prependHttpHandler (need a Bridge instance with HTTP server)');
|
|
39
27
|
}
|
|
28
|
+
// Pass the daemon's auth so MCP tools can identify the per-call principal
|
|
29
|
+
// from request headers (4.0 · P4). stdio (startMcpStdioServer) omits this,
|
|
30
|
+
// so stdio calls resolve to the local principal.
|
|
31
|
+
const auth = b.getAuthOptions();
|
|
32
|
+
const sessions = new Map();
|
|
33
|
+
function newSession() {
|
|
34
|
+
const server = createMcpServer(bridge, { experimentalEnvVar: opts.experimentalEnvVar, auth });
|
|
35
|
+
const transport = new StreamableHTTPServerTransport({
|
|
36
|
+
sessionIdGenerator: stateful ? () => randomUUID() : undefined,
|
|
37
|
+
// null → resumability off; an explicit store → use it; default → a
|
|
38
|
+
// fresh per-session MemoryEventStore.
|
|
39
|
+
eventStore: opts.eventStore === null ? undefined : (opts.eventStore ?? new MemoryEventStore()),
|
|
40
|
+
onsessioninitialized: (sid) => {
|
|
41
|
+
sessions.set(sid, { transport, server });
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
transport.onclose = () => {
|
|
45
|
+
const sid = transport.sessionId;
|
|
46
|
+
if (sid)
|
|
47
|
+
sessions.delete(sid);
|
|
48
|
+
};
|
|
49
|
+
return { transport, server };
|
|
50
|
+
}
|
|
40
51
|
b.prependHttpHandler(async (req, res) => {
|
|
41
52
|
const url = req.url ?? '';
|
|
42
53
|
const qi = url.indexOf('?');
|
|
43
54
|
const reqPath = qi < 0 ? url : url.slice(0, qi);
|
|
44
55
|
if (reqPath !== path)
|
|
45
56
|
return false;
|
|
46
|
-
|
|
57
|
+
// Per-call caller for command-target scoping (4.0 · A): every sendCommand
|
|
58
|
+
// within this request reads it via currentCaller().
|
|
59
|
+
const principal = identifyPrincipal(req.headers, auth);
|
|
60
|
+
const sid = req.headers['mcp-session-id'];
|
|
61
|
+
// Established session — route by id (POST follow-ups, GET SSE, DELETE).
|
|
62
|
+
if (typeof sid === 'string' && sessions.has(sid)) {
|
|
63
|
+
const { transport } = sessions.get(sid);
|
|
64
|
+
await runWithCaller(principal, () => transport.handleRequest(req, res));
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
// No (known) session id. A POST `initialize` opens one; anything else is invalid.
|
|
68
|
+
if (req.method === 'POST') {
|
|
69
|
+
let body;
|
|
70
|
+
try {
|
|
71
|
+
body = await readJsonBody(req);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
sendMcpError(res, 400, -32700, 'Parse error');
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (stateful && !isInitializeRequest(body)) {
|
|
78
|
+
sendMcpError(res, 400, -32600, 'Bad Request: no valid mcp-session-id (initialize first)');
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
const { server, transport } = newSession();
|
|
82
|
+
await server.connect(transport);
|
|
83
|
+
if (!stateful) {
|
|
84
|
+
// Stateless one-shot: tear down when the response ends.
|
|
85
|
+
res.on('close', () => {
|
|
86
|
+
void transport.close();
|
|
87
|
+
void server.close();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
await runWithCaller(principal, () => transport.handleRequest(req, res, body));
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
// GET/DELETE without a known session — nothing to attach to.
|
|
94
|
+
sendMcpError(res, 400, -32600, 'Bad Request: unknown or missing mcp-session-id');
|
|
47
95
|
return true;
|
|
48
96
|
});
|
|
49
97
|
return {
|
|
50
98
|
path,
|
|
51
99
|
async close() {
|
|
52
|
-
|
|
100
|
+
for (const { server } of sessions.values()) {
|
|
101
|
+
await server.close().catch(() => undefined);
|
|
102
|
+
}
|
|
103
|
+
sessions.clear();
|
|
53
104
|
},
|
|
54
105
|
};
|
|
55
106
|
}
|
|
107
|
+
async function readJsonBody(req) {
|
|
108
|
+
const chunks = [];
|
|
109
|
+
let total = 0;
|
|
110
|
+
const MAX = 4 * 1024 * 1024; // 4 MiB cap — MCP requests are small
|
|
111
|
+
for await (const c of req) {
|
|
112
|
+
const buf = c;
|
|
113
|
+
total += buf.length;
|
|
114
|
+
if (total > MAX)
|
|
115
|
+
throw new Error('mcp body too large');
|
|
116
|
+
chunks.push(buf);
|
|
117
|
+
}
|
|
118
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
119
|
+
return raw ? JSON.parse(raw) : undefined;
|
|
120
|
+
}
|
|
121
|
+
function sendMcpError(res, status, code, message) {
|
|
122
|
+
res.statusCode = status;
|
|
123
|
+
res.setHeader('content-type', 'application/json');
|
|
124
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id: null }));
|
|
125
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harness-fe/mcp-server",
|
|
3
|
-
"version": "4.0.0-next.
|
|
3
|
+
"version": "4.0.0-next.4",
|
|
4
4
|
"description": "Unified MCP daemon: stdio MCP for AI agents + WS bridge for Vite plugin and runtime client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"main": "dist/index.js",
|
|
17
17
|
"types": "dist/index.d.ts",
|
|
18
18
|
"bin": {
|
|
19
|
-
"harness-fe": "./dist/
|
|
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/protocol": "4.0.0-next.
|
|
40
|
+
"@harness-fe/daemon": "4.0.0-next.4",
|
|
41
|
+
"@harness-fe/protocol": "4.0.0-next.4"
|
|
43
42
|
},
|
|
44
43
|
"devDependencies": {
|
|
45
44
|
"@types/ws": "^8.5.10",
|
|
@@ -53,10 +52,9 @@
|
|
|
53
52
|
},
|
|
54
53
|
"scripts": {
|
|
55
54
|
"build": "tsc",
|
|
56
|
-
"postbuild": "node -e \"require('fs').chmodSync('dist/
|
|
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 { canSee, 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
|
|
|
@@ -141,6 +141,25 @@ function err(message: string): {
|
|
|
141
141
|
};
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Owner chain of a project for tenant isolation (4.0 · A — binding/tagging):
|
|
146
|
+
* the project's own `createdBy` followed by its ancestors' (walked via
|
|
147
|
+
* `parentProjectId`, self → root, cycle-safe). Feed to `canSeeProject` so a
|
|
148
|
+
* host agent sees its sub-apps' data. Empty when the project is unknown.
|
|
149
|
+
*/
|
|
150
|
+
function ownerChainOf(projectId: string, store: IStore): Array<string | undefined> {
|
|
151
|
+
const chain: Array<string | undefined> = [];
|
|
152
|
+
const seen = new Set<string>();
|
|
153
|
+
let id: string | undefined = projectId;
|
|
154
|
+
while (id && !seen.has(id)) {
|
|
155
|
+
seen.add(id);
|
|
156
|
+
const p = store.getProject(id);
|
|
157
|
+
if (!p) break;
|
|
158
|
+
chain.push(p.createdBy);
|
|
159
|
+
id = p.parentProjectId;
|
|
160
|
+
}
|
|
161
|
+
return chain;
|
|
162
|
+
}
|
|
144
163
|
|
|
145
164
|
function registerTools(server: McpServer, bridge: IBridge, auth?: AuthOptions): void {
|
|
146
165
|
server.registerTool(
|
|
@@ -752,8 +771,14 @@ function registerTools(server: McpServer, bridge: IBridge, auth?: AuthOptions):
|
|
|
752
771
|
},
|
|
753
772
|
async ({ status, limit }, extra) => {
|
|
754
773
|
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
755
|
-
|
|
756
|
-
|
|
774
|
+
// Tenant isolation by project ownership (4.0 · A): a task is visible
|
|
775
|
+
// when the caller owns its project (or a host ancestor). Falls back
|
|
776
|
+
// to the submitter tag when no store is configured (in-memory mode).
|
|
777
|
+
const store = (bridge as Bridge).store;
|
|
778
|
+
const all = await bridge.listTasks({ status: status ?? 'pending', limit });
|
|
779
|
+
const tasks = store
|
|
780
|
+
? all.filter((t) => canSeeProject(principal, t.projectId, ownerChainOf(t.projectId, store)))
|
|
781
|
+
: all.filter((t) => canSee(principal, t.createdBy));
|
|
757
782
|
const summary = tasks.map((t) => ({
|
|
758
783
|
id: t.id,
|
|
759
784
|
status: t.status,
|
|
@@ -793,15 +818,35 @@ function registerTools(server: McpServer, bridge: IBridge, auth?: AuthOptions):
|
|
|
793
818
|
COMMAND.TASKS_RESOLVE,
|
|
794
819
|
{
|
|
795
820
|
description:
|
|
796
|
-
'Mark a task as resolved with an optional note
|
|
821
|
+
'Mark a task as resolved with an optional note and structured resolution. ' +
|
|
822
|
+
'Use after addressing the user request. Pass `resolution` to close the ' +
|
|
823
|
+
'feedback loop: how it was fixed (type), the fix commit/PR, and the ' +
|
|
824
|
+
'verificationSessionId of the post-fix re-test that proved the fix.',
|
|
797
825
|
inputSchema: {
|
|
798
826
|
taskId: z.string(),
|
|
799
827
|
note: z.string().optional(),
|
|
828
|
+
resolution: z
|
|
829
|
+
.object({
|
|
830
|
+
type: z
|
|
831
|
+
.enum(['code-fix', 'config', 'wontfix', 'duplicate', 'cannot-reproduce'])
|
|
832
|
+
.optional(),
|
|
833
|
+
commit: z.string().optional().describe('Git commit SHA of the fix.'),
|
|
834
|
+
prUrl: z.string().optional().describe('Pull-request URL for the fix.'),
|
|
835
|
+
verificationSessionId: z
|
|
836
|
+
.string()
|
|
837
|
+
.optional()
|
|
838
|
+
.describe('Session id of the post-fix re-test that verified the fix.'),
|
|
839
|
+
verifiedAt: z
|
|
840
|
+
.number()
|
|
841
|
+
.optional()
|
|
842
|
+
.describe('Epoch ms; defaulted when verificationSessionId is given.'),
|
|
843
|
+
})
|
|
844
|
+
.optional(),
|
|
800
845
|
},
|
|
801
846
|
},
|
|
802
|
-
async ({ taskId, note }, extra) => {
|
|
847
|
+
async ({ taskId, note, resolution }, extra) => {
|
|
803
848
|
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
804
|
-
const task = await bridge.resolveTask(taskId, note, principal);
|
|
849
|
+
const task = await bridge.resolveTask(taskId, note, resolution, principal);
|
|
805
850
|
if (!task) {
|
|
806
851
|
throw new Error(`tasks.resolve: no task with id "${taskId}"`);
|
|
807
852
|
}
|
|
@@ -879,10 +924,9 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
|
|
|
879
924
|
},
|
|
880
925
|
async ({ projectId, limit }, extra) => {
|
|
881
926
|
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
return ok(sessions);
|
|
927
|
+
// Owning a project (or a host ancestor) grants its whole session set.
|
|
928
|
+
if (!canSeeProject(principal, projectId, ownerChainOf(projectId, store))) return ok([]);
|
|
929
|
+
return ok(store.listSessions({ projectId, limit: limit ?? 10 }));
|
|
886
930
|
},
|
|
887
931
|
);
|
|
888
932
|
|
|
@@ -970,12 +1014,12 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
|
|
|
970
1014
|
},
|
|
971
1015
|
async (_args, extra) => {
|
|
972
1016
|
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
973
|
-
const projects = store
|
|
1017
|
+
const projects = store
|
|
1018
|
+
.listProjects()
|
|
1019
|
+
.filter((p) => canSeeProject(principal, p.id, ownerChainOf(p.id, store)));
|
|
974
1020
|
const result = projects.map((p) => ({
|
|
975
1021
|
...p,
|
|
976
|
-
recentSessions: store
|
|
977
|
-
.listSessions({ projectId: p.id, limit: 3 })
|
|
978
|
-
.filter((s) => canSee(principal, s.createdBy)),
|
|
1022
|
+
recentSessions: store.listSessions({ projectId: p.id, limit: 3 }),
|
|
979
1023
|
}));
|
|
980
1024
|
return ok(result);
|
|
981
1025
|
},
|