@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.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/dist/auth.d.ts +53 -0
- package/dist/auth.js +212 -0
- package/dist/bridge.d.ts +302 -0
- package/dist/bridge.js +1580 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +277 -0
- package/dist/daemon.d.ts +98 -0
- package/dist/daemon.js +80 -0
- package/dist/dashboardApi.d.ts +40 -0
- package/dist/dashboardApi.js +142 -0
- package/dist/dashboardSpa.d.ts +18 -0
- package/dist/dashboardSpa.js +180 -0
- package/dist/dashboardUrl.d.ts +13 -0
- package/dist/dashboardUrl.js +18 -0
- package/dist/eventsHandler.d.ts +24 -0
- package/dist/eventsHandler.js +114 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/mcp.d.ts +15 -0
- package/dist/mcp.js +923 -0
- package/dist/mcpHttp.d.ts +39 -0
- package/dist/mcpHttp.js +49 -0
- package/dist/openBrowser.d.ts +33 -0
- package/dist/openBrowser.js +63 -0
- package/dist/remoteBridge.d.ts +61 -0
- package/dist/remoteBridge.js +307 -0
- package/dist/replayCreate.d.ts +36 -0
- package/dist/replayCreate.js +156 -0
- package/dist/replayViewer.d.ts +20 -0
- package/dist/replayViewer.js +168 -0
- package/dist/sessionRouter.d.ts +42 -0
- package/dist/sessionRouter.js +88 -0
- package/dist/store/JsonMemoryStore.d.ts +52 -0
- package/dist/store/JsonMemoryStore.js +119 -0
- package/dist/store/JsonTaskStore.d.ts +21 -0
- package/dist/store/JsonTaskStore.js +53 -0
- package/dist/store/JsonlStore.d.ts +128 -0
- package/dist/store/JsonlStore.js +1168 -0
- package/dist/store/MemoryEventStore.d.ts +47 -0
- package/dist/store/MemoryEventStore.js +111 -0
- package/dist/store/WriteQueue.d.ts +51 -0
- package/dist/store/WriteQueue.js +142 -0
- package/dist/store/index.d.ts +6 -0
- package/dist/store/index.js +5 -0
- package/dist/store/types.d.ts +416 -0
- package/dist/store/types.js +19 -0
- package/package.json +63 -0
- package/src/auth.test.ts +90 -0
- package/src/auth.ts +248 -0
- package/src/bridge-auth.test.ts +196 -0
- package/src/bridge.test.ts +1708 -0
- package/src/bridge.ts +1804 -0
- package/src/cli.ts +315 -0
- package/src/daemon.test.ts +123 -0
- package/src/daemon.ts +161 -0
- package/src/dashboardApi.test.ts +235 -0
- package/src/dashboardApi.ts +184 -0
- package/src/dashboardSpa.test.ts +239 -0
- package/src/dashboardSpa.ts +195 -0
- package/src/dashboardUrl.test.ts +46 -0
- package/src/dashboardUrl.ts +28 -0
- package/src/eventsHandler.test.ts +247 -0
- package/src/eventsHandler.ts +136 -0
- package/src/index.ts +26 -0
- package/src/mcp.ts +1407 -0
- package/src/mcpHttp.test.ts +101 -0
- package/src/mcpHttp.ts +88 -0
- package/src/openBrowser.test.ts +103 -0
- package/src/openBrowser.ts +81 -0
- package/src/remoteBridge.test.ts +119 -0
- package/src/remoteBridge.ts +404 -0
- package/src/replay.test.ts +271 -0
- package/src/replayCreate.ts +194 -0
- package/src/replayViewer.ts +173 -0
- package/src/sessionRouter.ts +116 -0
- package/src/store/JsonMemoryStore.test.ts +175 -0
- package/src/store/JsonMemoryStore.ts +128 -0
- package/src/store/JsonTaskStore.test.ts +212 -0
- package/src/store/JsonTaskStore.ts +59 -0
- package/src/store/JsonlStore.test.ts +1538 -0
- package/src/store/JsonlStore.ts +1321 -0
- package/src/store/MemoryEventStore.test.ts +119 -0
- package/src/store/MemoryEventStore.ts +151 -0
- package/src/store/WriteQueue.ts +165 -0
- package/src/store/index.ts +29 -0
- package/src/store/types.ts +517 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP over HTTP transport mounted onto the bridge's existing HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the bridge's auth wrapper, so the same `--token` that protects
|
|
5
|
+
* the dashboard also protects MCP tool calls. Remote agents talk to
|
|
6
|
+
* `http://<host>:<port>/mcp` and authenticate via `Authorization: Bearer
|
|
7
|
+
* <token>` like any other client.
|
|
8
|
+
*/
|
|
9
|
+
import type { IBridge } from './bridge.js';
|
|
10
|
+
import type { EventStore } from './store/types.js';
|
|
11
|
+
export interface McpHttpOptions {
|
|
12
|
+
/** URL path the transport listens on. Default `/mcp`. */
|
|
13
|
+
path?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Whether to use stateful sessions (sessionId in headers) or stateless
|
|
16
|
+
* one-shot requests. Stateful is the spec default and matches what
|
|
17
|
+
* Claude Code expects.
|
|
18
|
+
*/
|
|
19
|
+
stateful?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* EventStore for SSE resumability via `Last-Event-ID`. If a client
|
|
22
|
+
* reconnects after a transient disconnect, the transport replays the
|
|
23
|
+
* events it missed. Defaults to a `MemoryEventStore` with conservative
|
|
24
|
+
* caps (1000 events / 5 minutes / 50 MiB total). Pass `null` to
|
|
25
|
+
* disable resumability entirely.
|
|
26
|
+
*/
|
|
27
|
+
eventStore?: EventStore | null;
|
|
28
|
+
}
|
|
29
|
+
export interface McpHttpHandle {
|
|
30
|
+
/** Close the MCP server and detach the transport. */
|
|
31
|
+
close(): Promise<void>;
|
|
32
|
+
path: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Mount the MCP HTTP transport on the bridge's HTTP server. Bridge must
|
|
36
|
+
* already have been started; calls `prependHttpHandler` so it runs before
|
|
37
|
+
* the dashboard/replay/events handler chain.
|
|
38
|
+
*/
|
|
39
|
+
export declare function startMcpHttpServer(bridge: IBridge, opts?: McpHttpOptions): Promise<McpHttpHandle>;
|
package/dist/mcpHttp.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP over HTTP transport mounted onto the bridge's existing HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the bridge's auth wrapper, so the same `--token` that protects
|
|
5
|
+
* the dashboard also protects MCP tool calls. Remote agents talk to
|
|
6
|
+
* `http://<host>:<port>/mcp` and authenticate via `Authorization: Bearer
|
|
7
|
+
* <token>` like any other client.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
11
|
+
import { createMcpServer } from './mcp.js';
|
|
12
|
+
import { MemoryEventStore } from './store/MemoryEventStore.js';
|
|
13
|
+
/**
|
|
14
|
+
* Mount the MCP HTTP transport on the bridge's HTTP server. Bridge must
|
|
15
|
+
* already have been started; calls `prependHttpHandler` so it runs before
|
|
16
|
+
* the dashboard/replay/events handler chain.
|
|
17
|
+
*/
|
|
18
|
+
export async function startMcpHttpServer(bridge, opts = {}) {
|
|
19
|
+
const path = opts.path ?? '/mcp';
|
|
20
|
+
const stateful = opts.stateful !== false;
|
|
21
|
+
const eventStore = opts.eventStore === null
|
|
22
|
+
? undefined
|
|
23
|
+
: opts.eventStore ?? new MemoryEventStore();
|
|
24
|
+
const server = createMcpServer(bridge);
|
|
25
|
+
const transport = new StreamableHTTPServerTransport({
|
|
26
|
+
sessionIdGenerator: stateful ? () => randomUUID() : undefined,
|
|
27
|
+
eventStore,
|
|
28
|
+
});
|
|
29
|
+
await server.connect(transport);
|
|
30
|
+
const b = bridge;
|
|
31
|
+
if (typeof b.prependHttpHandler !== 'function') {
|
|
32
|
+
throw new Error('mcpHttp: bridge does not support prependHttpHandler (need a Bridge instance with HTTP server)');
|
|
33
|
+
}
|
|
34
|
+
b.prependHttpHandler(async (req, res) => {
|
|
35
|
+
const url = req.url ?? '';
|
|
36
|
+
const qi = url.indexOf('?');
|
|
37
|
+
const reqPath = qi < 0 ? url : url.slice(0, qi);
|
|
38
|
+
if (reqPath !== path)
|
|
39
|
+
return false;
|
|
40
|
+
await transport.handleRequest(req, res);
|
|
41
|
+
return true;
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
path,
|
|
45
|
+
async close() {
|
|
46
|
+
await server.close();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform "open this URL in the user's default browser" — a tiny
|
|
3
|
+
* wrapper around the OS-native command.
|
|
4
|
+
*
|
|
5
|
+
* Detection rules:
|
|
6
|
+
* - darwin → `open <url>`
|
|
7
|
+
* - linux → `xdg-open <url>`
|
|
8
|
+
* - win32 → `cmd /c start "" <url>` (the empty title is required, otherwise `start` treats the URL as a title)
|
|
9
|
+
*
|
|
10
|
+
* Escape hatches:
|
|
11
|
+
* - `HARNESS_FE_HEADLESS=1` short-circuits and returns `false` without
|
|
12
|
+
* spawning anything — useful when the daemon runs in Docker / CI /
|
|
13
|
+
* remote host where there's no GUI to open
|
|
14
|
+
* - any other platform returns `false`
|
|
15
|
+
*
|
|
16
|
+
* The spawned process is detached and stdio'd to ignore so we don't
|
|
17
|
+
* accidentally tie its lifetime to ours.
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
export interface OpenBrowserOptions {
|
|
21
|
+
/** Inject an alternate `process.platform` value, for tests. */
|
|
22
|
+
platformOverride?: NodeJS.Platform;
|
|
23
|
+
/** Inject the env lookup, for tests. */
|
|
24
|
+
envOverride?: Record<string, string | undefined>;
|
|
25
|
+
/** Inject the spawn function, for tests. */
|
|
26
|
+
spawnOverride?: typeof spawn;
|
|
27
|
+
}
|
|
28
|
+
export interface OpenBrowserResult {
|
|
29
|
+
opened: boolean;
|
|
30
|
+
/** Set when `opened` is false to explain why. */
|
|
31
|
+
reason?: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function openBrowser(url: string, opts?: OpenBrowserOptions): OpenBrowserResult;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform "open this URL in the user's default browser" — a tiny
|
|
3
|
+
* wrapper around the OS-native command.
|
|
4
|
+
*
|
|
5
|
+
* Detection rules:
|
|
6
|
+
* - darwin → `open <url>`
|
|
7
|
+
* - linux → `xdg-open <url>`
|
|
8
|
+
* - win32 → `cmd /c start "" <url>` (the empty title is required, otherwise `start` treats the URL as a title)
|
|
9
|
+
*
|
|
10
|
+
* Escape hatches:
|
|
11
|
+
* - `HARNESS_FE_HEADLESS=1` short-circuits and returns `false` without
|
|
12
|
+
* spawning anything — useful when the daemon runs in Docker / CI /
|
|
13
|
+
* remote host where there's no GUI to open
|
|
14
|
+
* - any other platform returns `false`
|
|
15
|
+
*
|
|
16
|
+
* The spawned process is detached and stdio'd to ignore so we don't
|
|
17
|
+
* accidentally tie its lifetime to ours.
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
export function openBrowser(url, opts = {}) {
|
|
21
|
+
const env = opts.envOverride ?? process.env;
|
|
22
|
+
if (env.HARNESS_FE_HEADLESS === '1') {
|
|
23
|
+
return { opened: false, reason: 'HARNESS_FE_HEADLESS=1' };
|
|
24
|
+
}
|
|
25
|
+
const platform = opts.platformOverride ?? process.platform;
|
|
26
|
+
const spawnFn = opts.spawnOverride ?? spawn;
|
|
27
|
+
let cmd;
|
|
28
|
+
let args;
|
|
29
|
+
switch (platform) {
|
|
30
|
+
case 'darwin':
|
|
31
|
+
cmd = 'open';
|
|
32
|
+
args = [url];
|
|
33
|
+
break;
|
|
34
|
+
case 'linux':
|
|
35
|
+
cmd = 'xdg-open';
|
|
36
|
+
args = [url];
|
|
37
|
+
break;
|
|
38
|
+
case 'win32':
|
|
39
|
+
// `start` is a cmd builtin, not a standalone exe. The first
|
|
40
|
+
// empty-string arg is the window title — required, because
|
|
41
|
+
// otherwise `start "https://…"` treats the URL as the title
|
|
42
|
+
// and never opens anything.
|
|
43
|
+
cmd = 'cmd';
|
|
44
|
+
args = ['/c', 'start', '', url];
|
|
45
|
+
break;
|
|
46
|
+
default:
|
|
47
|
+
return { opened: false, reason: `unsupported platform: ${platform}` };
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const child = spawnFn(cmd, args, {
|
|
51
|
+
detached: true,
|
|
52
|
+
stdio: 'ignore',
|
|
53
|
+
});
|
|
54
|
+
child.unref();
|
|
55
|
+
return { opened: true };
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
return {
|
|
59
|
+
opened: false,
|
|
60
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RemoteBridge — IBridge implementation backed by a WS connection to an
|
|
3
|
+
* already-running daemon (leader).
|
|
4
|
+
*
|
|
5
|
+
* Used when this process starts as a follower: another cli.js is already
|
|
6
|
+
* listening on :47729, so we attach as a ws client and proxy every MCP tool
|
|
7
|
+
* call through the new `mcp.call` / `mcp.return` control frames.
|
|
8
|
+
*/
|
|
9
|
+
import { type McpCallFrame, type TabInfo, type Task, type TaskStatus } from '@harness-fe/protocol';
|
|
10
|
+
import type { IBridge, SendCommandOptions } from './bridge.js';
|
|
11
|
+
import type { IMemoryStore, IStore } from './store/index.js';
|
|
12
|
+
export interface RemoteBridgeOptions {
|
|
13
|
+
port: number;
|
|
14
|
+
host?: string;
|
|
15
|
+
/** Per-call timeout. Must be ≥ daemon's command timeout to surface upstream errors first. */
|
|
16
|
+
callTimeoutMs?: number;
|
|
17
|
+
/** Token used to authenticate against the leader, if it requires one. */
|
|
18
|
+
token?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare class RemoteBridge implements IBridge {
|
|
21
|
+
private ws?;
|
|
22
|
+
private pending;
|
|
23
|
+
private closed;
|
|
24
|
+
private readonly url;
|
|
25
|
+
private readonly callTimeoutMs;
|
|
26
|
+
private readonly host;
|
|
27
|
+
private readonly port;
|
|
28
|
+
private readonly token;
|
|
29
|
+
constructor(opts: RemoteBridgeOptions);
|
|
30
|
+
connect(): Promise<void>;
|
|
31
|
+
stop(): Promise<void>;
|
|
32
|
+
sendCommand(command: string, args: unknown, opts?: SendCommandOptions): Promise<unknown>;
|
|
33
|
+
listTabs(): Promise<TabInfo[]>;
|
|
34
|
+
listTasks(filter?: {
|
|
35
|
+
status?: TaskStatus | 'all';
|
|
36
|
+
limit?: number;
|
|
37
|
+
}): Promise<Task[]>;
|
|
38
|
+
claimTask(id: string): Promise<Task | undefined>;
|
|
39
|
+
resolveTask(id: string, note?: string): Promise<Task | undefined>;
|
|
40
|
+
/**
|
|
41
|
+
* Returns a RemoteMemoryStore that proxies all memory operations to the
|
|
42
|
+
* leader via the mcp.call channel. This allows follower instances to use
|
|
43
|
+
* the same project.memory.* tools as the leader.
|
|
44
|
+
*/
|
|
45
|
+
getMemoryStore(): IMemoryStore;
|
|
46
|
+
getViewerBaseUrl(): string | undefined;
|
|
47
|
+
getAuthToken(): string | undefined;
|
|
48
|
+
getTaskAttachmentData(_taskId: string, _attachmentId: string): Promise<string | null>;
|
|
49
|
+
/**
|
|
50
|
+
* Returns a RemoteStore that proxies all store read/query operations to
|
|
51
|
+
* the leader via the mcp.call channel. Write operations (openSession,
|
|
52
|
+
* append, etc.) are not proxied — followers are read-only.
|
|
53
|
+
*/
|
|
54
|
+
getStore(): IStore;
|
|
55
|
+
/** @internal — used by RemoteMemoryStore and RemoteStore */
|
|
56
|
+
invokeRemote(method: McpCallFrame['method'], args: unknown): Promise<unknown>;
|
|
57
|
+
private invoke;
|
|
58
|
+
private attachHandlers;
|
|
59
|
+
private handleReturn;
|
|
60
|
+
private handleClose;
|
|
61
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RemoteBridge — IBridge implementation backed by a WS connection to an
|
|
3
|
+
* already-running daemon (leader).
|
|
4
|
+
*
|
|
5
|
+
* Used when this process starts as a follower: another cli.js is already
|
|
6
|
+
* listening on :47729, so we attach as a ws client and proxy every MCP tool
|
|
7
|
+
* call through the new `mcp.call` / `mcp.return` control frames.
|
|
8
|
+
*/
|
|
9
|
+
import { WebSocket } from 'ws';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { frameSchema, } from '@harness-fe/protocol';
|
|
12
|
+
const DEFAULT_CALL_TIMEOUT_MS = 30_000;
|
|
13
|
+
export class RemoteBridge {
|
|
14
|
+
ws;
|
|
15
|
+
pending = new Map();
|
|
16
|
+
closed = false;
|
|
17
|
+
url;
|
|
18
|
+
callTimeoutMs;
|
|
19
|
+
host;
|
|
20
|
+
port;
|
|
21
|
+
token;
|
|
22
|
+
constructor(opts) {
|
|
23
|
+
const host = opts.host ?? '127.0.0.1';
|
|
24
|
+
this.host = host;
|
|
25
|
+
this.port = opts.port;
|
|
26
|
+
this.token = opts.token;
|
|
27
|
+
const tokenQs = opts.token ? `?token=${encodeURIComponent(opts.token)}` : '';
|
|
28
|
+
this.url = `ws://${host}:${opts.port}${tokenQs}`;
|
|
29
|
+
this.callTimeoutMs = opts.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
|
30
|
+
}
|
|
31
|
+
async connect() {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const headers = {};
|
|
34
|
+
if (this.token)
|
|
35
|
+
headers.authorization = `Bearer ${this.token}`;
|
|
36
|
+
const ws = new WebSocket(this.url, { headers });
|
|
37
|
+
this.ws = ws;
|
|
38
|
+
const onOpen = () => {
|
|
39
|
+
ws.off('error', onErr);
|
|
40
|
+
this.attachHandlers(ws);
|
|
41
|
+
resolve();
|
|
42
|
+
};
|
|
43
|
+
const onErr = (err) => {
|
|
44
|
+
ws.off('open', onOpen);
|
|
45
|
+
reject(err);
|
|
46
|
+
};
|
|
47
|
+
ws.once('open', onOpen);
|
|
48
|
+
ws.once('error', onErr);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async stop() {
|
|
52
|
+
this.closed = true;
|
|
53
|
+
const ws = this.ws;
|
|
54
|
+
if (!ws)
|
|
55
|
+
return;
|
|
56
|
+
try {
|
|
57
|
+
ws.close();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* swallow */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
sendCommand(command, args, opts) {
|
|
64
|
+
return this.invoke('sendCommand', { command, args, opts });
|
|
65
|
+
}
|
|
66
|
+
listTabs() {
|
|
67
|
+
return this.invoke('listTabs', {});
|
|
68
|
+
}
|
|
69
|
+
listTasks(filter = {}) {
|
|
70
|
+
return this.invoke('listTasks', filter);
|
|
71
|
+
}
|
|
72
|
+
claimTask(id) {
|
|
73
|
+
return this.invoke('claimTask', { id });
|
|
74
|
+
}
|
|
75
|
+
resolveTask(id, note) {
|
|
76
|
+
return this.invoke('resolveTask', { id, note });
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Returns a RemoteMemoryStore that proxies all memory operations to the
|
|
80
|
+
* leader via the mcp.call channel. This allows follower instances to use
|
|
81
|
+
* the same project.memory.* tools as the leader.
|
|
82
|
+
*/
|
|
83
|
+
getMemoryStore() {
|
|
84
|
+
return new RemoteMemoryStore(this);
|
|
85
|
+
}
|
|
86
|
+
getViewerBaseUrl() {
|
|
87
|
+
// Followers share the same WS/HTTP port as the leader.
|
|
88
|
+
return `http://${this.host}:${this.port}`;
|
|
89
|
+
}
|
|
90
|
+
getAuthToken() {
|
|
91
|
+
// Followers connect to the leader using their own configured token
|
|
92
|
+
// (passed in via RemoteBridge constructor). Surface it so dashboard
|
|
93
|
+
// links the follower hands out are pre-authenticated.
|
|
94
|
+
return this.token;
|
|
95
|
+
}
|
|
96
|
+
async getTaskAttachmentData(_taskId, _attachmentId) {
|
|
97
|
+
// Follower mode: attachment reads are not proxied in v0.6; direct leader access needed.
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Returns a RemoteStore that proxies all store read/query operations to
|
|
102
|
+
* the leader via the mcp.call channel. Write operations (openSession,
|
|
103
|
+
* append, etc.) are not proxied — followers are read-only.
|
|
104
|
+
*/
|
|
105
|
+
getStore() {
|
|
106
|
+
return new RemoteStore(this);
|
|
107
|
+
}
|
|
108
|
+
/** @internal — used by RemoteMemoryStore and RemoteStore */
|
|
109
|
+
invokeRemote(method, args) {
|
|
110
|
+
return this.invoke(method, args);
|
|
111
|
+
}
|
|
112
|
+
invoke(method, args) {
|
|
113
|
+
const ws = this.ws;
|
|
114
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
115
|
+
return Promise.reject(new Error('remote-bridge: not connected'));
|
|
116
|
+
}
|
|
117
|
+
const id = randomUUID();
|
|
118
|
+
const frame = { type: 'mcp.call', id, method, args };
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
this.pending.delete(id);
|
|
122
|
+
reject(new Error(`remote-bridge: "${method}" timed out after ${this.callTimeoutMs}ms`));
|
|
123
|
+
}, this.callTimeoutMs);
|
|
124
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
125
|
+
try {
|
|
126
|
+
ws.send(JSON.stringify(frame));
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
this.pending.delete(id);
|
|
131
|
+
reject(err);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
attachHandlers(ws) {
|
|
136
|
+
ws.on('message', (raw) => {
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(raw.toString());
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const frame = frameSchema.safeParse(parsed);
|
|
145
|
+
if (!frame.success)
|
|
146
|
+
return;
|
|
147
|
+
if (frame.data.type !== 'mcp.return')
|
|
148
|
+
return;
|
|
149
|
+
this.handleReturn(frame.data);
|
|
150
|
+
});
|
|
151
|
+
ws.on('close', () => this.handleClose());
|
|
152
|
+
ws.on('error', () => {
|
|
153
|
+
/* close will follow */
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
handleReturn(frame) {
|
|
157
|
+
const p = this.pending.get(frame.id);
|
|
158
|
+
if (!p)
|
|
159
|
+
return;
|
|
160
|
+
clearTimeout(p.timer);
|
|
161
|
+
this.pending.delete(frame.id);
|
|
162
|
+
if (frame.ok) {
|
|
163
|
+
p.resolve(frame.result);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
p.reject(new Error(frame.error?.message ?? 'remote-bridge: unknown error'));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
handleClose() {
|
|
170
|
+
const err = new Error(this.closed
|
|
171
|
+
? 'remote-bridge: connection closed'
|
|
172
|
+
: 'remote-bridge: lost connection to daemon');
|
|
173
|
+
for (const p of this.pending.values()) {
|
|
174
|
+
clearTimeout(p.timer);
|
|
175
|
+
p.reject(err);
|
|
176
|
+
}
|
|
177
|
+
this.pending.clear();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// ─── RemoteMemoryStore ────────────────────────────────────────────────────────
|
|
181
|
+
//
|
|
182
|
+
// Proxies IMemoryStore operations to the leader via mcp.call frames.
|
|
183
|
+
// Used by follower instances so project.memory.* tools work in all windows.
|
|
184
|
+
class RemoteMemoryStore {
|
|
185
|
+
bridge;
|
|
186
|
+
constructor(bridge) {
|
|
187
|
+
this.bridge = bridge;
|
|
188
|
+
}
|
|
189
|
+
get(projectId, key) {
|
|
190
|
+
// Synchronous interface — not directly awaitable. The MCP tool layer
|
|
191
|
+
// wraps calls in async handlers, so we return a thenable-compatible
|
|
192
|
+
// object. In practice mcp.ts awaits the result via the async handler.
|
|
193
|
+
// We throw here to signal that callers must use the async path.
|
|
194
|
+
throw new Error('RemoteMemoryStore.get() must be called via the async MCP tool handler. ' +
|
|
195
|
+
'Use remoteMemoryStore.getAsync() instead.');
|
|
196
|
+
}
|
|
197
|
+
/** Async variant used by the MCP tool handlers in mcp.ts. */
|
|
198
|
+
async getAsync(projectId, key) {
|
|
199
|
+
return this.bridge.invokeRemote('memoryGet', { projectId, key });
|
|
200
|
+
}
|
|
201
|
+
set(projectId, key, value) {
|
|
202
|
+
throw new Error('RemoteMemoryStore.set() must be called via setAsync().');
|
|
203
|
+
}
|
|
204
|
+
async setAsync(projectId, key, value) {
|
|
205
|
+
return this.bridge.invokeRemote('memorySet', { projectId, key, value });
|
|
206
|
+
}
|
|
207
|
+
delete(projectId, key) {
|
|
208
|
+
throw new Error('RemoteMemoryStore.delete() must be called via deleteAsync().');
|
|
209
|
+
}
|
|
210
|
+
async deleteAsync(projectId, key) {
|
|
211
|
+
return this.bridge.invokeRemote('memoryDelete', { projectId, key });
|
|
212
|
+
}
|
|
213
|
+
list(projectId) {
|
|
214
|
+
throw new Error('RemoteMemoryStore.list() must be called via listAsync().');
|
|
215
|
+
}
|
|
216
|
+
async listAsync(projectId) {
|
|
217
|
+
return this.bridge.invokeRemote('memoryList', { projectId });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// ─── RemoteStore ──────────────────────────────────────────────────────────────
|
|
221
|
+
//
|
|
222
|
+
// Proxies IStore read operations to the leader via mcp.call frames.
|
|
223
|
+
// Write operations throw — followers are read-only for the store.
|
|
224
|
+
class RemoteStore {
|
|
225
|
+
bridge;
|
|
226
|
+
constructor(bridge) {
|
|
227
|
+
this.bridge = bridge;
|
|
228
|
+
}
|
|
229
|
+
// ── Read operations (proxied) ──────────────────────────────────────────
|
|
230
|
+
async listProjectsAsync() {
|
|
231
|
+
return this.bridge.invokeRemote('storeListProjects', {});
|
|
232
|
+
}
|
|
233
|
+
async listSessionsAsync(opts) {
|
|
234
|
+
return this.bridge.invokeRemote('storeListSessions', opts ?? {});
|
|
235
|
+
}
|
|
236
|
+
async summaryAsync(sessionId) {
|
|
237
|
+
return this.bridge.invokeRemote('storeSummary', { sessionId });
|
|
238
|
+
}
|
|
239
|
+
async tailAsync(sessionId, opts) {
|
|
240
|
+
return this.bridge.invokeRemote('storeTail', { sessionId, opts });
|
|
241
|
+
}
|
|
242
|
+
async searchAsync(sessionId, query, opts) {
|
|
243
|
+
return this.bridge.invokeRemote('storeSearch', { sessionId, query, opts });
|
|
244
|
+
}
|
|
245
|
+
async listRecordingsAsync(sessionId) {
|
|
246
|
+
return this.bridge.invokeRemote('storeRecordingsList', { sessionId });
|
|
247
|
+
}
|
|
248
|
+
async sliceRecordingsAsync(sessionId, since, until) {
|
|
249
|
+
return this.bridge.invokeRemote('storeRecordingsSlice', { sessionId, since, until });
|
|
250
|
+
}
|
|
251
|
+
async replayCreateAsync(args) {
|
|
252
|
+
return this.bridge.invokeRemote('storeReplayCreate', args);
|
|
253
|
+
}
|
|
254
|
+
async purgeAsync(policy) {
|
|
255
|
+
return this.bridge.invokeRemote('storePurge', policy ?? {});
|
|
256
|
+
}
|
|
257
|
+
// ── Synchronous IStore interface stubs (not used by follower) ─────────
|
|
258
|
+
// These satisfy the interface but throw — the MCP tool handlers in mcp.ts
|
|
259
|
+
// use the async variants above when running in follower mode.
|
|
260
|
+
// Build lifecycle
|
|
261
|
+
openBuild(_p, _patch) { throw notSupported('openBuild'); }
|
|
262
|
+
closeBuild(_b, _c) { throw notSupported('closeBuild'); }
|
|
263
|
+
// Tab lifecycle
|
|
264
|
+
upsertTab(_t, _patch) { throw notSupported('upsertTab'); }
|
|
265
|
+
getTab(_t) { throw notSupported('getTab'); }
|
|
266
|
+
closeTab(_t, _d) { throw notSupported('closeTab'); }
|
|
267
|
+
// Session lifecycle
|
|
268
|
+
upsertSession(_s, _m) { throw notSupported('upsertSession'); }
|
|
269
|
+
closeSession(_s, _e) { throw notSupported('closeSession'); }
|
|
270
|
+
getSession(_id) { throw notSupported('getSession'); }
|
|
271
|
+
listSessions(_opts) { throw notSupported('listSessions'); }
|
|
272
|
+
// Write
|
|
273
|
+
appendEvent(_s, _e) { throw notSupported('appendEvent'); }
|
|
274
|
+
appendEventBatch(_s, _e) { throw notSupported('appendEventBatch'); }
|
|
275
|
+
appendRecording(_s, _c) { throw notSupported('appendRecording'); }
|
|
276
|
+
writeNote(_p, _k, _v) { throw notSupported('writeNote'); }
|
|
277
|
+
// Project metadata
|
|
278
|
+
listProjects() { throw notSupported('listProjects'); }
|
|
279
|
+
upsertProject(_p, _patch) { throw notSupported('upsertProject'); }
|
|
280
|
+
getProject(_p) { throw notSupported('getProject'); }
|
|
281
|
+
getProjectTree(_r) { throw notSupported('getProjectTree'); }
|
|
282
|
+
// Build metadata
|
|
283
|
+
upsertBuild(_p, _b, _patch) { throw notSupported('upsertBuild'); }
|
|
284
|
+
getBuild(_p, _b) { throw notSupported('getBuild'); }
|
|
285
|
+
listBuilds(_p, _l) { throw notSupported('listBuilds'); }
|
|
286
|
+
// Visitor metadata (0.5+) — followers don't proxy yet; leader-only for now.
|
|
287
|
+
upsertVisitor(_v, _patch) { throw notSupported('upsertVisitor'); }
|
|
288
|
+
getVisitor(_v) { throw notSupported('getVisitor'); }
|
|
289
|
+
listVisitors(_opts) { throw notSupported('listVisitors'); }
|
|
290
|
+
// Read
|
|
291
|
+
tail(_s, _o) { throw notSupported('tail'); }
|
|
292
|
+
search(_s, _q, _o) { throw notSupported('search'); }
|
|
293
|
+
listRecordings(_s) { throw notSupported('listRecordings'); }
|
|
294
|
+
sliceRecordings(_s, _since, _until) { throw notSupported('sliceRecordings'); }
|
|
295
|
+
writeExport(_i) { throw notSupported('writeExport'); }
|
|
296
|
+
getExport(_id) { throw notSupported('getExport'); }
|
|
297
|
+
readExportEvents(_id) { throw notSupported('readExportEvents'); }
|
|
298
|
+
listExports(_p, _l) { throw notSupported('listExports'); }
|
|
299
|
+
summary(_s) { throw notSupported('summary'); }
|
|
300
|
+
listNotes(_p) { throw notSupported('listNotes'); }
|
|
301
|
+
// Maintenance
|
|
302
|
+
purge(_p) { throw notSupported('purge'); }
|
|
303
|
+
close() { }
|
|
304
|
+
}
|
|
305
|
+
function notSupported(method) {
|
|
306
|
+
return new Error(`remote-bridge: IStore.${method}() is not available in follower mode`);
|
|
307
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared replay-export logic used by both the leader's MCP tool handler and
|
|
3
|
+
* the leader's mcp.call dispatcher (for follower proxy calls).
|
|
4
|
+
*
|
|
5
|
+
* Takes a time window (or a center timestamp), pulls the overlapping rrweb
|
|
6
|
+
* chunks for a single tab, concatenates the events, persists them as an
|
|
7
|
+
* export, and returns the metadata + viewerUrl.
|
|
8
|
+
*/
|
|
9
|
+
import type { IStore } from './store/index.js';
|
|
10
|
+
export interface ReplayCreateArgs {
|
|
11
|
+
sessionId: string;
|
|
12
|
+
tabId?: string;
|
|
13
|
+
ts?: number;
|
|
14
|
+
windowMs?: number;
|
|
15
|
+
since?: number;
|
|
16
|
+
until?: number;
|
|
17
|
+
label?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ReplayCreateResult {
|
|
20
|
+
exportId?: string;
|
|
21
|
+
viewerUrl?: string;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
tabId?: string;
|
|
24
|
+
since: number;
|
|
25
|
+
until: number;
|
|
26
|
+
startTs?: number;
|
|
27
|
+
endTs?: number;
|
|
28
|
+
durationMs?: number;
|
|
29
|
+
eventCount?: number;
|
|
30
|
+
chunkCount?: number;
|
|
31
|
+
bytes?: number;
|
|
32
|
+
createdAt?: number;
|
|
33
|
+
label?: string;
|
|
34
|
+
error?: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function createReplayExport(store: IStore, baseUrl: string | undefined, input: ReplayCreateArgs): ReplayCreateResult;
|