@harness-fe/mcp-server 3.2.0 → 3.4.0
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/README.md +9 -1
- package/dist/cli.js +20 -6
- package/dist/daemon.d.ts +7 -0
- package/dist/daemon.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mcp.d.ts +25 -2
- package/dist/mcp.js +46 -3
- package/dist/mcpHttp.d.ts +5 -0
- package/dist/mcpHttp.js +1 -1
- package/package.json +3 -3
- package/src/cli.ts +24 -6
- package/src/daemon.ts +8 -0
- package/src/experimental.test.ts +141 -0
- package/src/index.ts +6 -1
- package/src/mcp.ts +70 -3
- package/src/mcpHttp.ts +6 -1
package/README.md
CHANGED
|
@@ -116,12 +116,19 @@ Remote Claude Code / Cursor config:
|
|
|
116
116
|
--mcp-transport <kind> stdio (default) | http
|
|
117
117
|
--mcp-path <path> Default /mcp
|
|
118
118
|
--public-host <addr> Override the host printed in outbound URLs
|
|
119
|
+
--experimental-env-var <name>
|
|
120
|
+
Restrict experimental tools to hosts where <name> is
|
|
121
|
+
set. Omit for fully-on (default).
|
|
119
122
|
-h, --help
|
|
120
123
|
```
|
|
121
124
|
|
|
122
125
|
Matching env vars: `HARNESS_FE_HOST`, `HARNESS_FE_PORT`,
|
|
123
126
|
`HARNESS_FE_TOKEN`, `HARNESS_FE_MCP_TRANSPORT`, `HARNESS_FE_MCP_PATH`,
|
|
124
|
-
`HARNESS_FE_HEADLESS`.
|
|
127
|
+
`HARNESS_FE_HEADLESS`. Experimental (in-testing) tools are **on by default** —
|
|
128
|
+
no config. To restrict them, pass `--experimental-env-var <name>` /
|
|
129
|
+
`HARNESS_FE_EXPERIMENTAL_ENV_VAR` (or `createDaemon({ experimentalEnvVar })`
|
|
130
|
+
when embedding): the tools then show up only on machines where `<name>` is set
|
|
131
|
+
to a non-empty value.
|
|
125
132
|
|
|
126
133
|
## Embedding into a host app
|
|
127
134
|
|
|
@@ -159,6 +166,7 @@ the hood — there is exactly one boot path.
|
|
|
159
166
|
| `mcpHttp: false` | Boot only the WS bridge; skip mounting `/mcp`. Use when you want to wire MCP through stdio yourself (this is how the CLI's stdio mode embeds the daemon). |
|
|
160
167
|
| `mcpPath: '/agents/mcp'` | Move the MCP HTTP endpoint to a non-default path. |
|
|
161
168
|
| `dataDir` | Override the on-disk root for default JSONL stores. |
|
|
169
|
+
| `experimentalEnvVar: 'MY_FLAG'` | Restrict experimental (in-testing) tools to hosts where `MY_FLAG` is set to a non-empty value. Omit for fully-on (the default). |
|
|
162
170
|
|
|
163
171
|
### Resumable SSE
|
|
164
172
|
|
package/dist/cli.js
CHANGED
|
@@ -37,14 +37,19 @@ Options:
|
|
|
37
37
|
--mcp-path <path> URL path for the MCP HTTP endpoint. Default /mcp.
|
|
38
38
|
--public-host <addr> Override the host printed in outbound URLs. Useful when
|
|
39
39
|
binding 0.0.0.0 and the auto-detected LAN IP is wrong.
|
|
40
|
+
--experimental-env-var <name>
|
|
41
|
+
Restrict experimental (in-testing) tools to machines
|
|
42
|
+
where <name> is set to a non-empty value. Omit this and
|
|
43
|
+
experimental tools are fully on (the default).
|
|
40
44
|
-h, --help Show this help.
|
|
41
45
|
|
|
42
46
|
Environment:
|
|
43
|
-
HARNESS_FE_HOST
|
|
44
|
-
HARNESS_FE_TOKEN
|
|
45
|
-
HARNESS_FE_MCP_TRANSPORT
|
|
46
|
-
HARNESS_FE_MCP_PATH
|
|
47
|
-
|
|
47
|
+
HARNESS_FE_HOST Same as --host
|
|
48
|
+
HARNESS_FE_TOKEN Same as --token (use "auto" to generate)
|
|
49
|
+
HARNESS_FE_MCP_TRANSPORT Same as --mcp-transport
|
|
50
|
+
HARNESS_FE_MCP_PATH Same as --mcp-path
|
|
51
|
+
HARNESS_FE_EXPERIMENTAL_ENV_VAR Same as --experimental-env-var
|
|
52
|
+
HARNESS_FE_URL Full ws:// URL (legacy; --host/--port override it)
|
|
48
53
|
`;
|
|
49
54
|
process.stderr.write(help);
|
|
50
55
|
process.exit(0);
|
|
@@ -57,6 +62,7 @@ function parseArgs(argv) {
|
|
|
57
62
|
let mcpTransport;
|
|
58
63
|
let mcpPath;
|
|
59
64
|
let publicHost;
|
|
65
|
+
let experimentalEnvVar;
|
|
60
66
|
for (let i = 0; i < args.length; i++) {
|
|
61
67
|
const a = args[i];
|
|
62
68
|
const next = () => {
|
|
@@ -100,6 +106,9 @@ function parseArgs(argv) {
|
|
|
100
106
|
case '--public-host':
|
|
101
107
|
publicHost = next();
|
|
102
108
|
break;
|
|
109
|
+
case '--experimental-env-var':
|
|
110
|
+
experimentalEnvVar = next();
|
|
111
|
+
break;
|
|
103
112
|
default:
|
|
104
113
|
process.stderr.write(`harness-fe: unknown argument ${a}\n`);
|
|
105
114
|
process.exit(2);
|
|
@@ -137,6 +146,9 @@ function parseArgs(argv) {
|
|
|
137
146
|
// Data dir defaults to port-keyed path. Explicit env override wins.
|
|
138
147
|
const finalDataDir = process.env.HARNESS_FE_DATA_DIR ?? defaultDataDir(finalPort);
|
|
139
148
|
const finalLabel = process.env.HARNESS_FE_LABEL || undefined;
|
|
149
|
+
// Omitted → undefined → experimental tools fully on (no gate). Supply a
|
|
150
|
+
// name only to restrict them to machines where that var is set.
|
|
151
|
+
const finalExperimentalEnvVar = experimentalEnvVar || process.env.HARNESS_FE_EXPERIMENTAL_ENV_VAR || undefined;
|
|
140
152
|
return {
|
|
141
153
|
host: finalHost,
|
|
142
154
|
port: finalPort,
|
|
@@ -146,6 +158,7 @@ function parseArgs(argv) {
|
|
|
146
158
|
publicHost,
|
|
147
159
|
label: finalLabel,
|
|
148
160
|
dataDir: finalDataDir,
|
|
161
|
+
experimentalEnvVar: finalExperimentalEnvVar,
|
|
149
162
|
};
|
|
150
163
|
}
|
|
151
164
|
function validate(_cfg) {
|
|
@@ -212,7 +225,7 @@ async function main() {
|
|
|
212
225
|
const { active, shutdown, role } = await startBridgeOrAttach(cfg);
|
|
213
226
|
printBanner(cfg, role, active.getViewerBaseUrl());
|
|
214
227
|
if (cfg.mcpTransport === 'stdio') {
|
|
215
|
-
await startMcpStdioServer(active);
|
|
228
|
+
await startMcpStdioServer(active, { experimentalEnvVar: cfg.experimentalEnvVar });
|
|
216
229
|
process.stderr.write('[harness-fe] MCP stdio server connected\n');
|
|
217
230
|
}
|
|
218
231
|
else {
|
|
@@ -249,6 +262,7 @@ async function startBridgeOrAttach(cfg) {
|
|
|
249
262
|
publicHost: cfg.publicHost,
|
|
250
263
|
mcpHttp: cfg.mcpTransport === 'http',
|
|
251
264
|
mcpPath: cfg.mcpPath,
|
|
265
|
+
experimentalEnvVar: cfg.experimentalEnvVar,
|
|
252
266
|
});
|
|
253
267
|
try {
|
|
254
268
|
await daemon.start();
|
package/dist/daemon.d.ts
CHANGED
|
@@ -95,6 +95,13 @@ export interface DaemonOptions {
|
|
|
95
95
|
* exposing an HTTP MCP endpoint).
|
|
96
96
|
*/
|
|
97
97
|
mcpHttp?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Name of the environment variable that gates experimental (in-testing)
|
|
100
|
+
* tools. Omit (the default) for fully-on, zero-config. Supply a name only
|
|
101
|
+
* when the host wants the tools restricted to machines where that var is
|
|
102
|
+
* set to a non-empty value.
|
|
103
|
+
*/
|
|
104
|
+
experimentalEnvVar?: string;
|
|
98
105
|
}
|
|
99
106
|
export interface DaemonHandle {
|
|
100
107
|
/**
|
package/dist/daemon.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { Bridge, defaultDataDir, type BridgeOptions } from './bridge.js';
|
|
2
2
|
export { createDaemon, type DaemonOptions, type DaemonHandle } from './daemon.js';
|
|
3
3
|
export { SessionRouter, type PeerSession } from './sessionRouter.js';
|
|
4
|
-
export { startMcpStdioServer } from './mcp.js';
|
|
4
|
+
export { startMcpStdioServer, createMcpServer, experimentalEnabled, type McpServerOptions, } from './mcp.js';
|
|
5
5
|
export { startMcpHttpServer, type McpHttpOptions, type McpHttpHandle } from './mcpHttp.js';
|
|
6
6
|
export { JsonlStore, JsonTaskStore, JsonMemoryStore, MemoryEventStore, sanitizeId, type MemoryEventStoreOptions, } from './store/index.js';
|
|
7
7
|
export type { IStore, ITaskStore, IMemoryStore, EventStore, EventId, StreamId, ProjectMeta, ProjectTreeNode, BuildMeta, SessionMeta, TabMeta, } from './store/index.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { Bridge, defaultDataDir } from './bridge.js';
|
|
2
2
|
export { createDaemon } from './daemon.js';
|
|
3
3
|
export { SessionRouter } from './sessionRouter.js';
|
|
4
|
-
export { startMcpStdioServer } from './mcp.js';
|
|
4
|
+
export { startMcpStdioServer, createMcpServer, experimentalEnabled, } from './mcp.js';
|
|
5
5
|
export { startMcpHttpServer } from './mcpHttp.js';
|
|
6
6
|
export { JsonlStore, JsonTaskStore, JsonMemoryStore, MemoryEventStore, sanitizeId, } from './store/index.js';
|
package/dist/mcp.d.ts
CHANGED
|
@@ -7,9 +7,32 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
9
|
import type { IBridge } from './bridge.js';
|
|
10
|
+
export interface McpServerOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Name of the environment variable that gates experimental tools.
|
|
13
|
+
*
|
|
14
|
+
* **Omit it (the default) and experimental tools are fully on** — no env
|
|
15
|
+
* var needed, lowest mental burden. Only supply a name when you *don't*
|
|
16
|
+
* want them unconditionally on: the tools then show up only if that env
|
|
17
|
+
* var is set to a non-empty value at server-construction time.
|
|
18
|
+
*/
|
|
19
|
+
experimentalEnvVar?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Experimental-feature gate.
|
|
23
|
+
*
|
|
24
|
+
* Default (no `envVar`): **fully enabled**. Experimental tools are registered
|
|
25
|
+
* unconditionally, so a plain dev setup gets them with zero config.
|
|
26
|
+
*
|
|
27
|
+
* Gated (an `envVar` name supplied): enabled only when that env var is set on
|
|
28
|
+
* the machine running the daemon. *Presence* enables — any non-empty value
|
|
29
|
+
* (after trimming) counts as "on"; unset or empty means off. There's
|
|
30
|
+
* deliberately no required magic value, so `=1`, `=true`, `=yes` all work.
|
|
31
|
+
*/
|
|
32
|
+
export declare function experimentalEnabled(envVar?: string): boolean;
|
|
10
33
|
/**
|
|
11
34
|
* Build an McpServer with every harness-fe tool registered for the given
|
|
12
35
|
* bridge. Transport (stdio / HTTP) is attached separately.
|
|
13
36
|
*/
|
|
14
|
-
export declare function createMcpServer(bridge: IBridge): McpServer;
|
|
15
|
-
export declare function startMcpStdioServer(bridge: IBridge): Promise<McpServer>;
|
|
37
|
+
export declare function createMcpServer(bridge: IBridge, options?: McpServerOptions): McpServer;
|
|
38
|
+
export declare function startMcpStdioServer(bridge: IBridge, options?: McpServerOptions): Promise<McpServer>;
|
package/dist/mcp.js
CHANGED
|
@@ -15,6 +15,25 @@ import { createReplayExport } from './replayCreate.js';
|
|
|
15
15
|
import { openBrowser } from './openBrowser.js';
|
|
16
16
|
import { buildDashboardUrl } from './dashboardUrl.js';
|
|
17
17
|
const SERVER_NAME = 'harness-fe';
|
|
18
|
+
/**
|
|
19
|
+
* Experimental-feature gate.
|
|
20
|
+
*
|
|
21
|
+
* Default (no `envVar`): **fully enabled**. Experimental tools are registered
|
|
22
|
+
* unconditionally, so a plain dev setup gets them with zero config.
|
|
23
|
+
*
|
|
24
|
+
* Gated (an `envVar` name supplied): enabled only when that env var is set on
|
|
25
|
+
* the machine running the daemon. *Presence* enables — any non-empty value
|
|
26
|
+
* (after trimming) counts as "on"; unset or empty means off. There's
|
|
27
|
+
* deliberately no required magic value, so `=1`, `=true`, `=yes` all work.
|
|
28
|
+
*/
|
|
29
|
+
export function experimentalEnabled(envVar) {
|
|
30
|
+
// No gate configured → fully on.
|
|
31
|
+
if (envVar == null || envVar.trim() === '')
|
|
32
|
+
return true;
|
|
33
|
+
// Gated → on only when the named env var carries a non-empty value.
|
|
34
|
+
const raw = process.env[envVar];
|
|
35
|
+
return typeof raw === 'string' && raw.trim() !== '';
|
|
36
|
+
}
|
|
18
37
|
const tabIdParam = z
|
|
19
38
|
.string()
|
|
20
39
|
.optional()
|
|
@@ -23,12 +42,17 @@ const tabIdParam = z
|
|
|
23
42
|
* Build an McpServer with every harness-fe tool registered for the given
|
|
24
43
|
* bridge. Transport (stdio / HTTP) is attached separately.
|
|
25
44
|
*/
|
|
26
|
-
export function createMcpServer(bridge) {
|
|
45
|
+
export function createMcpServer(bridge, options = {}) {
|
|
27
46
|
const server = new McpServer({
|
|
28
47
|
name: SERVER_NAME,
|
|
29
48
|
version: PROTOCOL_VERSION,
|
|
30
49
|
});
|
|
31
50
|
registerTools(server, bridge);
|
|
51
|
+
// Experimental tools are on by default. They only get gated when the host
|
|
52
|
+
// supplies an env-var name to key off; see experimentalEnabled().
|
|
53
|
+
if (experimentalEnabled(options.experimentalEnvVar)) {
|
|
54
|
+
registerExperimentalTools(server, bridge);
|
|
55
|
+
}
|
|
32
56
|
// Register store tools for both leader (direct store access) and follower
|
|
33
57
|
// (proxied via RemoteBridge → mcp.call channel to the leader).
|
|
34
58
|
const leaderStore = bridge.store;
|
|
@@ -41,8 +65,8 @@ export function createMcpServer(bridge) {
|
|
|
41
65
|
}
|
|
42
66
|
return server;
|
|
43
67
|
}
|
|
44
|
-
export async function startMcpStdioServer(bridge) {
|
|
45
|
-
const server = createMcpServer(bridge);
|
|
68
|
+
export async function startMcpStdioServer(bridge, options = {}) {
|
|
69
|
+
const server = createMcpServer(bridge, options);
|
|
46
70
|
const transport = new StdioServerTransport();
|
|
47
71
|
await server.connect(transport);
|
|
48
72
|
return server;
|
|
@@ -500,6 +524,25 @@ function registerTools(server, bridge) {
|
|
|
500
524
|
};
|
|
501
525
|
});
|
|
502
526
|
}
|
|
527
|
+
// ─── Experimental tools (gated by HARNESS_FE_EXPERIMENTAL) ────────────────────
|
|
528
|
+
/**
|
|
529
|
+
* Tools that are still in the testing phase. They are only registered when
|
|
530
|
+
* `experimentalEnabled()` is true, so default/production setups never see them
|
|
531
|
+
* in the tool list. When a feature graduates, move its `registerTool` call up
|
|
532
|
+
* into `registerTools` and drop it from here.
|
|
533
|
+
*/
|
|
534
|
+
function registerExperimentalTools(server, bridge) {
|
|
535
|
+
// Probe tool: lets a developer confirm experimental mode is active on the
|
|
536
|
+
// daemon they're connected to. Also serves as the canonical example for how
|
|
537
|
+
// to add a gated tool. Safe to keep around — it touches nothing.
|
|
538
|
+
server.registerTool('experimental.ping', {
|
|
539
|
+
description: 'Experimental-mode probe. Present whenever experimental tools are enabled (the default; ' +
|
|
540
|
+
'suppressed only when a gate env var is configured and unset on the daemon host). ' +
|
|
541
|
+
'Returns ok plus the protocol version — use it to confirm experimental tools are reachable.',
|
|
542
|
+
inputSchema: {},
|
|
543
|
+
}, async () => ok({ ok: true, experimental: true, protocolVersion: PROTOCOL_VERSION }));
|
|
544
|
+
void bridge;
|
|
545
|
+
}
|
|
503
546
|
// ─── Store tools (session history, timeline, memory) ──────────────────────────
|
|
504
547
|
function registerStoreTools(server, store, memoryStore, bridge) {
|
|
505
548
|
server.registerTool('session.list', {
|
package/dist/mcpHttp.d.ts
CHANGED
|
@@ -25,6 +25,11 @@ export interface McpHttpOptions {
|
|
|
25
25
|
* disable resumability entirely.
|
|
26
26
|
*/
|
|
27
27
|
eventStore?: EventStore | null;
|
|
28
|
+
/**
|
|
29
|
+
* Name of the environment variable that gates experimental tools.
|
|
30
|
+
* Forwarded to `createMcpServer`. Omit for fully-on (no gate).
|
|
31
|
+
*/
|
|
32
|
+
experimentalEnvVar?: string;
|
|
28
33
|
}
|
|
29
34
|
export interface McpHttpHandle {
|
|
30
35
|
/** Close the MCP server and detach the transport. */
|
package/dist/mcpHttp.js
CHANGED
|
@@ -21,7 +21,7 @@ export async function startMcpHttpServer(bridge, opts = {}) {
|
|
|
21
21
|
const eventStore = opts.eventStore === null
|
|
22
22
|
? undefined
|
|
23
23
|
: opts.eventStore ?? new MemoryEventStore();
|
|
24
|
-
const server = createMcpServer(bridge);
|
|
24
|
+
const server = createMcpServer(bridge, { experimentalEnvVar: opts.experimentalEnvVar });
|
|
25
25
|
const transport = new StreamableHTTPServerTransport({
|
|
26
26
|
sessionIdGenerator: stateful ? () => randomUUID() : undefined,
|
|
27
27
|
eventStore,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harness-fe/mcp-server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
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",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"rrweb-player": "1.0.0-alpha.4",
|
|
39
39
|
"ws": "^8.18.0",
|
|
40
40
|
"zod": "^4.4.3",
|
|
41
|
-
"@harness-fe/
|
|
42
|
-
"@harness-fe/
|
|
41
|
+
"@harness-fe/protocol": "3.2.0",
|
|
42
|
+
"@harness-fe/dashboard-ui": "0.2.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/ws": "^8.5.10",
|
package/src/cli.ts
CHANGED
|
@@ -42,6 +42,8 @@ interface CliConfig {
|
|
|
42
42
|
label: string | undefined;
|
|
43
43
|
/** Resolved data directory. Defaults to defaultDataDir(port). */
|
|
44
44
|
dataDir: string;
|
|
45
|
+
/** Env-var name that gates experimental tools. Undefined = fully on (no gate). */
|
|
46
|
+
experimentalEnvVar: string | undefined;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
function printHelpAndExit(): never {
|
|
@@ -60,14 +62,19 @@ Options:
|
|
|
60
62
|
--mcp-path <path> URL path for the MCP HTTP endpoint. Default /mcp.
|
|
61
63
|
--public-host <addr> Override the host printed in outbound URLs. Useful when
|
|
62
64
|
binding 0.0.0.0 and the auto-detected LAN IP is wrong.
|
|
65
|
+
--experimental-env-var <name>
|
|
66
|
+
Restrict experimental (in-testing) tools to machines
|
|
67
|
+
where <name> is set to a non-empty value. Omit this and
|
|
68
|
+
experimental tools are fully on (the default).
|
|
63
69
|
-h, --help Show this help.
|
|
64
70
|
|
|
65
71
|
Environment:
|
|
66
|
-
HARNESS_FE_HOST
|
|
67
|
-
HARNESS_FE_TOKEN
|
|
68
|
-
HARNESS_FE_MCP_TRANSPORT
|
|
69
|
-
HARNESS_FE_MCP_PATH
|
|
70
|
-
|
|
72
|
+
HARNESS_FE_HOST Same as --host
|
|
73
|
+
HARNESS_FE_TOKEN Same as --token (use "auto" to generate)
|
|
74
|
+
HARNESS_FE_MCP_TRANSPORT Same as --mcp-transport
|
|
75
|
+
HARNESS_FE_MCP_PATH Same as --mcp-path
|
|
76
|
+
HARNESS_FE_EXPERIMENTAL_ENV_VAR Same as --experimental-env-var
|
|
77
|
+
HARNESS_FE_URL Full ws:// URL (legacy; --host/--port override it)
|
|
71
78
|
`;
|
|
72
79
|
process.stderr.write(help);
|
|
73
80
|
process.exit(0);
|
|
@@ -82,6 +89,7 @@ function parseArgs(argv: string[]): CliConfig {
|
|
|
82
89
|
let mcpTransport: McpTransport | undefined;
|
|
83
90
|
let mcpPath: string | undefined;
|
|
84
91
|
let publicHost: string | undefined;
|
|
92
|
+
let experimentalEnvVar: string | undefined;
|
|
85
93
|
|
|
86
94
|
for (let i = 0; i < args.length; i++) {
|
|
87
95
|
const a = args[i];
|
|
@@ -126,6 +134,9 @@ function parseArgs(argv: string[]): CliConfig {
|
|
|
126
134
|
case '--public-host':
|
|
127
135
|
publicHost = next();
|
|
128
136
|
break;
|
|
137
|
+
case '--experimental-env-var':
|
|
138
|
+
experimentalEnvVar = next();
|
|
139
|
+
break;
|
|
129
140
|
default:
|
|
130
141
|
process.stderr.write(`harness-fe: unknown argument ${a}\n`);
|
|
131
142
|
process.exit(2);
|
|
@@ -169,6 +180,11 @@ function parseArgs(argv: string[]): CliConfig {
|
|
|
169
180
|
|
|
170
181
|
const finalLabel = process.env.HARNESS_FE_LABEL || undefined;
|
|
171
182
|
|
|
183
|
+
// Omitted → undefined → experimental tools fully on (no gate). Supply a
|
|
184
|
+
// name only to restrict them to machines where that var is set.
|
|
185
|
+
const finalExperimentalEnvVar =
|
|
186
|
+
experimentalEnvVar || process.env.HARNESS_FE_EXPERIMENTAL_ENV_VAR || undefined;
|
|
187
|
+
|
|
172
188
|
return {
|
|
173
189
|
host: finalHost,
|
|
174
190
|
port: finalPort,
|
|
@@ -178,6 +194,7 @@ function parseArgs(argv: string[]): CliConfig {
|
|
|
178
194
|
publicHost,
|
|
179
195
|
label: finalLabel,
|
|
180
196
|
dataDir: finalDataDir,
|
|
197
|
+
experimentalEnvVar: finalExperimentalEnvVar,
|
|
181
198
|
};
|
|
182
199
|
}
|
|
183
200
|
|
|
@@ -248,7 +265,7 @@ async function main() {
|
|
|
248
265
|
printBanner(cfg, role, active.getViewerBaseUrl());
|
|
249
266
|
|
|
250
267
|
if (cfg.mcpTransport === 'stdio') {
|
|
251
|
-
await startMcpStdioServer(active);
|
|
268
|
+
await startMcpStdioServer(active, { experimentalEnvVar: cfg.experimentalEnvVar });
|
|
252
269
|
process.stderr.write('[harness-fe] MCP stdio server connected\n');
|
|
253
270
|
} else {
|
|
254
271
|
// HTTP transport: the leader's createDaemon() call already mounted
|
|
@@ -290,6 +307,7 @@ async function startBridgeOrAttach(
|
|
|
290
307
|
publicHost: cfg.publicHost,
|
|
291
308
|
mcpHttp: cfg.mcpTransport === 'http',
|
|
292
309
|
mcpPath: cfg.mcpPath,
|
|
310
|
+
experimentalEnvVar: cfg.experimentalEnvVar,
|
|
293
311
|
});
|
|
294
312
|
try {
|
|
295
313
|
await daemon.start();
|
package/src/daemon.ts
CHANGED
|
@@ -99,6 +99,13 @@ export interface DaemonOptions {
|
|
|
99
99
|
* exposing an HTTP MCP endpoint).
|
|
100
100
|
*/
|
|
101
101
|
mcpHttp?: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Name of the environment variable that gates experimental (in-testing)
|
|
104
|
+
* tools. Omit (the default) for fully-on, zero-config. Supply a name only
|
|
105
|
+
* when the host wants the tools restricted to machines where that var is
|
|
106
|
+
* set to a non-empty value.
|
|
107
|
+
*/
|
|
108
|
+
experimentalEnvVar?: string;
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
export interface DaemonHandle {
|
|
@@ -164,6 +171,7 @@ export function createDaemon(opts: DaemonOptions = {}): DaemonHandle {
|
|
|
164
171
|
path: mcpPath,
|
|
165
172
|
stateful: opts.mcpStateful,
|
|
166
173
|
eventStore: opts.eventStore,
|
|
174
|
+
experimentalEnvVar: opts.experimentalEnvVar,
|
|
167
175
|
});
|
|
168
176
|
}
|
|
169
177
|
},
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the experimental-feature gate.
|
|
3
|
+
*
|
|
4
|
+
* Default (no gate var named) → experimental tools fully on. Supplying a gate
|
|
5
|
+
* var name restricts them to machines where that var is set (presence
|
|
6
|
+
* semantics — any non-empty value enables). Covered at three layers:
|
|
7
|
+
* 1. experimentalEnabled() — the predicate
|
|
8
|
+
* 2. createMcpServer() over InMemory transport — registration ↔ listTools
|
|
9
|
+
* 3. createDaemon() over real HTTP — the full option-threading path
|
|
10
|
+
* (daemon → mcpHttp → createMcpServer)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
14
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
18
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
19
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
20
|
+
import { Bridge } from './bridge.js';
|
|
21
|
+
import { JsonlStore } from './store/index.js';
|
|
22
|
+
import { createMcpServer, experimentalEnabled } from './mcp.js';
|
|
23
|
+
import { createDaemon } from './daemon.js';
|
|
24
|
+
|
|
25
|
+
const cleanups: Array<() => Promise<void>> = [];
|
|
26
|
+
const TRACKED = ['HARNESS_FE_EXPERIMENTAL', 'CUSTOM_EXP_FLAG'];
|
|
27
|
+
const saved: Record<string, string | undefined> = {};
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
for (const k of TRACKED) saved[k] = process.env[k];
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
for (const k of TRACKED) {
|
|
35
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
36
|
+
else process.env[k] = saved[k];
|
|
37
|
+
}
|
|
38
|
+
while (cleanups.length > 0) await cleanups.pop()!();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
async function listToolNames(envVar?: string): Promise<string[]> {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), 'harness-exp-'));
|
|
43
|
+
const store = new JsonlStore(dir);
|
|
44
|
+
const bridge = new Bridge({
|
|
45
|
+
port: 0,
|
|
46
|
+
host: '127.0.0.1',
|
|
47
|
+
store,
|
|
48
|
+
taskStore: null,
|
|
49
|
+
autoPurge: { enabled: false },
|
|
50
|
+
});
|
|
51
|
+
await bridge.start();
|
|
52
|
+
const server = createMcpServer(bridge, { experimentalEnvVar: envVar });
|
|
53
|
+
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
|
|
54
|
+
const client = new Client({ name: 'test-client', version: '0.0.0' }, { capabilities: {} });
|
|
55
|
+
await server.connect(serverTransport);
|
|
56
|
+
await client.connect(clientTransport);
|
|
57
|
+
cleanups.push(async () => {
|
|
58
|
+
await client.close();
|
|
59
|
+
await server.close();
|
|
60
|
+
await bridge.stop();
|
|
61
|
+
await store.close();
|
|
62
|
+
});
|
|
63
|
+
const { tools } = await client.listTools();
|
|
64
|
+
return tools.map((t) => t.name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('experimentalEnabled()', () => {
|
|
68
|
+
it('is fully on when no gate var is configured', () => {
|
|
69
|
+
expect(experimentalEnabled()).toBe(true);
|
|
70
|
+
expect(experimentalEnabled('')).toBe(true);
|
|
71
|
+
expect(experimentalEnabled(' ')).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('is off when the configured gate var is unset/empty', () => {
|
|
75
|
+
delete process.env.CUSTOM_EXP_FLAG;
|
|
76
|
+
expect(experimentalEnabled('CUSTOM_EXP_FLAG')).toBe(false);
|
|
77
|
+
process.env.CUSTOM_EXP_FLAG = '';
|
|
78
|
+
expect(experimentalEnabled('CUSTOM_EXP_FLAG')).toBe(false);
|
|
79
|
+
process.env.CUSTOM_EXP_FLAG = ' ';
|
|
80
|
+
expect(experimentalEnabled('CUSTOM_EXP_FLAG')).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('is on when the configured gate var carries any non-empty value', () => {
|
|
84
|
+
process.env.CUSTOM_EXP_FLAG = '1';
|
|
85
|
+
expect(experimentalEnabled('CUSTOM_EXP_FLAG')).toBe(true);
|
|
86
|
+
process.env.CUSTOM_EXP_FLAG = 'true';
|
|
87
|
+
expect(experimentalEnabled('CUSTOM_EXP_FLAG')).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('experimental tool gating', () => {
|
|
92
|
+
it('exposes experimental tools by default (no gate configured)', async () => {
|
|
93
|
+
const names = await listToolNames();
|
|
94
|
+
expect(names).toContain('experimental.ping');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('hides experimental tools when a gate var is configured but unset', async () => {
|
|
98
|
+
delete process.env.CUSTOM_EXP_FLAG;
|
|
99
|
+
const names = await listToolNames('CUSTOM_EXP_FLAG');
|
|
100
|
+
expect(names).not.toContain('experimental.ping');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('exposes them again when the configured gate var is set', async () => {
|
|
104
|
+
process.env.CUSTOM_EXP_FLAG = '1';
|
|
105
|
+
const names = await listToolNames('CUSTOM_EXP_FLAG');
|
|
106
|
+
expect(names).toContain('experimental.ping');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Proves the option actually threads createDaemon → startMcpHttpServer →
|
|
111
|
+
// createMcpServer over a real HTTP transport, not just the in-process path.
|
|
112
|
+
async function daemonToolNames(experimentalEnvVar?: string): Promise<string[]> {
|
|
113
|
+
const dir = mkdtempSync(join(tmpdir(), 'harness-exp-daemon-'));
|
|
114
|
+
const daemon = createDaemon({ port: 0, host: '127.0.0.1', dataDir: dir, experimentalEnvVar });
|
|
115
|
+
await daemon.start();
|
|
116
|
+
const port = daemon.getBoundPort();
|
|
117
|
+
const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`));
|
|
118
|
+
const client = new Client({ name: 'test-client', version: '0.0.0' }, { capabilities: {} });
|
|
119
|
+
await client.connect(transport);
|
|
120
|
+
cleanups.push(async () => {
|
|
121
|
+
await client.close();
|
|
122
|
+
await daemon.stop();
|
|
123
|
+
rmSync(dir, { recursive: true, force: true });
|
|
124
|
+
});
|
|
125
|
+
const { tools } = await client.listTools();
|
|
126
|
+
return tools.map((t) => t.name);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe('experimental tool gating — createDaemon over HTTP', () => {
|
|
130
|
+
it('exposes experimental tools by default through the daemon', async () => {
|
|
131
|
+
const names = await daemonToolNames();
|
|
132
|
+
expect(names).toContain('experimental.ping');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('threads the gate var: hidden when unset, shown when set', async () => {
|
|
136
|
+
delete process.env.CUSTOM_EXP_FLAG;
|
|
137
|
+
expect(await daemonToolNames('CUSTOM_EXP_FLAG')).not.toContain('experimental.ping');
|
|
138
|
+
process.env.CUSTOM_EXP_FLAG = '1';
|
|
139
|
+
expect(await daemonToolNames('CUSTOM_EXP_FLAG')).toContain('experimental.ping');
|
|
140
|
+
});
|
|
141
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
export { Bridge, defaultDataDir, type BridgeOptions } from './bridge.js';
|
|
2
2
|
export { createDaemon, type DaemonOptions, type DaemonHandle } from './daemon.js';
|
|
3
3
|
export { SessionRouter, type PeerSession } from './sessionRouter.js';
|
|
4
|
-
export {
|
|
4
|
+
export {
|
|
5
|
+
startMcpStdioServer,
|
|
6
|
+
createMcpServer,
|
|
7
|
+
experimentalEnabled,
|
|
8
|
+
type McpServerOptions,
|
|
9
|
+
} from './mcp.js';
|
|
5
10
|
export { startMcpHttpServer, type McpHttpOptions, type McpHttpHandle } from './mcpHttp.js';
|
|
6
11
|
export {
|
|
7
12
|
JsonlStore,
|
package/src/mcp.ts
CHANGED
|
@@ -34,6 +34,38 @@ import { openBrowser } from './openBrowser.js';
|
|
|
34
34
|
import { buildDashboardUrl } from './dashboardUrl.js';
|
|
35
35
|
|
|
36
36
|
const SERVER_NAME = 'harness-fe';
|
|
37
|
+
|
|
38
|
+
export interface McpServerOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Name of the environment variable that gates experimental tools.
|
|
41
|
+
*
|
|
42
|
+
* **Omit it (the default) and experimental tools are fully on** — no env
|
|
43
|
+
* var needed, lowest mental burden. Only supply a name when you *don't*
|
|
44
|
+
* want them unconditionally on: the tools then show up only if that env
|
|
45
|
+
* var is set to a non-empty value at server-construction time.
|
|
46
|
+
*/
|
|
47
|
+
experimentalEnvVar?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Experimental-feature gate.
|
|
52
|
+
*
|
|
53
|
+
* Default (no `envVar`): **fully enabled**. Experimental tools are registered
|
|
54
|
+
* unconditionally, so a plain dev setup gets them with zero config.
|
|
55
|
+
*
|
|
56
|
+
* Gated (an `envVar` name supplied): enabled only when that env var is set on
|
|
57
|
+
* the machine running the daemon. *Presence* enables — any non-empty value
|
|
58
|
+
* (after trimming) counts as "on"; unset or empty means off. There's
|
|
59
|
+
* deliberately no required magic value, so `=1`, `=true`, `=yes` all work.
|
|
60
|
+
*/
|
|
61
|
+
export function experimentalEnabled(envVar?: string): boolean {
|
|
62
|
+
// No gate configured → fully on.
|
|
63
|
+
if (envVar == null || envVar.trim() === '') return true;
|
|
64
|
+
// Gated → on only when the named env var carries a non-empty value.
|
|
65
|
+
const raw = process.env[envVar];
|
|
66
|
+
return typeof raw === 'string' && raw.trim() !== '';
|
|
67
|
+
}
|
|
68
|
+
|
|
37
69
|
const tabIdParam = z
|
|
38
70
|
.string()
|
|
39
71
|
.optional()
|
|
@@ -43,7 +75,7 @@ const tabIdParam = z
|
|
|
43
75
|
* Build an McpServer with every harness-fe tool registered for the given
|
|
44
76
|
* bridge. Transport (stdio / HTTP) is attached separately.
|
|
45
77
|
*/
|
|
46
|
-
export function createMcpServer(bridge: IBridge): McpServer {
|
|
78
|
+
export function createMcpServer(bridge: IBridge, options: McpServerOptions = {}): McpServer {
|
|
47
79
|
const server = new McpServer({
|
|
48
80
|
name: SERVER_NAME,
|
|
49
81
|
version: PROTOCOL_VERSION,
|
|
@@ -51,6 +83,12 @@ export function createMcpServer(bridge: IBridge): McpServer {
|
|
|
51
83
|
|
|
52
84
|
registerTools(server, bridge);
|
|
53
85
|
|
|
86
|
+
// Experimental tools are on by default. They only get gated when the host
|
|
87
|
+
// supplies an env-var name to key off; see experimentalEnabled().
|
|
88
|
+
if (experimentalEnabled(options.experimentalEnvVar)) {
|
|
89
|
+
registerExperimentalTools(server, bridge);
|
|
90
|
+
}
|
|
91
|
+
|
|
54
92
|
// Register store tools for both leader (direct store access) and follower
|
|
55
93
|
// (proxied via RemoteBridge → mcp.call channel to the leader).
|
|
56
94
|
const leaderStore = (bridge as Bridge).store;
|
|
@@ -64,8 +102,11 @@ export function createMcpServer(bridge: IBridge): McpServer {
|
|
|
64
102
|
return server;
|
|
65
103
|
}
|
|
66
104
|
|
|
67
|
-
export async function startMcpStdioServer(
|
|
68
|
-
|
|
105
|
+
export async function startMcpStdioServer(
|
|
106
|
+
bridge: IBridge,
|
|
107
|
+
options: McpServerOptions = {},
|
|
108
|
+
): Promise<McpServer> {
|
|
109
|
+
const server = createMcpServer(bridge, options);
|
|
69
110
|
const transport = new StdioServerTransport();
|
|
70
111
|
await server.connect(transport);
|
|
71
112
|
return server;
|
|
@@ -786,6 +827,32 @@ function registerTools(server: McpServer, bridge: IBridge): void {
|
|
|
786
827
|
);
|
|
787
828
|
}
|
|
788
829
|
|
|
830
|
+
// ─── Experimental tools (gated by HARNESS_FE_EXPERIMENTAL) ────────────────────
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Tools that are still in the testing phase. They are only registered when
|
|
834
|
+
* `experimentalEnabled()` is true, so default/production setups never see them
|
|
835
|
+
* in the tool list. When a feature graduates, move its `registerTool` call up
|
|
836
|
+
* into `registerTools` and drop it from here.
|
|
837
|
+
*/
|
|
838
|
+
function registerExperimentalTools(server: McpServer, bridge: IBridge): void {
|
|
839
|
+
// Probe tool: lets a developer confirm experimental mode is active on the
|
|
840
|
+
// daemon they're connected to. Also serves as the canonical example for how
|
|
841
|
+
// to add a gated tool. Safe to keep around — it touches nothing.
|
|
842
|
+
server.registerTool(
|
|
843
|
+
'experimental.ping',
|
|
844
|
+
{
|
|
845
|
+
description:
|
|
846
|
+
'Experimental-mode probe. Present whenever experimental tools are enabled (the default; ' +
|
|
847
|
+
'suppressed only when a gate env var is configured and unset on the daemon host). ' +
|
|
848
|
+
'Returns ok plus the protocol version — use it to confirm experimental tools are reachable.',
|
|
849
|
+
inputSchema: {},
|
|
850
|
+
},
|
|
851
|
+
async () => ok({ ok: true, experimental: true, protocolVersion: PROTOCOL_VERSION }),
|
|
852
|
+
);
|
|
853
|
+
void bridge;
|
|
854
|
+
}
|
|
855
|
+
|
|
789
856
|
// ─── Store tools (session history, timeline, memory) ──────────────────────────
|
|
790
857
|
|
|
791
858
|
function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemoryStore, bridge: IBridge): void {
|
package/src/mcpHttp.ts
CHANGED
|
@@ -32,6 +32,11 @@ export interface McpHttpOptions {
|
|
|
32
32
|
* disable resumability entirely.
|
|
33
33
|
*/
|
|
34
34
|
eventStore?: EventStore | null;
|
|
35
|
+
/**
|
|
36
|
+
* Name of the environment variable that gates experimental tools.
|
|
37
|
+
* Forwarded to `createMcpServer`. Omit for fully-on (no gate).
|
|
38
|
+
*/
|
|
39
|
+
experimentalEnvVar?: string;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
export interface McpHttpHandle {
|
|
@@ -56,7 +61,7 @@ export async function startMcpHttpServer(
|
|
|
56
61
|
? undefined
|
|
57
62
|
: opts.eventStore ?? new MemoryEventStore();
|
|
58
63
|
|
|
59
|
-
const server = createMcpServer(bridge);
|
|
64
|
+
const server = createMcpServer(bridge, { experimentalEnvVar: opts.experimentalEnvVar });
|
|
60
65
|
const transport = new StreamableHTTPServerTransport({
|
|
61
66
|
sessionIdGenerator: stateful ? () => randomUUID() : undefined,
|
|
62
67
|
eventStore,
|