@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 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 Same as --host
44
- HARNESS_FE_TOKEN Same as --token (use "auto" to generate)
45
- HARNESS_FE_MCP_TRANSPORT Same as --mcp-transport
46
- HARNESS_FE_MCP_PATH Same as --mcp-path
47
- HARNESS_FE_URL Full ws:// URL (legacy; --host/--port override it)
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
@@ -64,6 +64,7 @@ export function createDaemon(opts = {}) {
64
64
  path: mcpPath,
65
65
  stateful: opts.mcpStateful,
66
66
  eventStore: opts.eventStore,
67
+ experimentalEnvVar: opts.experimentalEnvVar,
67
68
  });
68
69
  }
69
70
  },
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.2.0",
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/dashboard-ui": "0.2.0",
42
- "@harness-fe/protocol": "3.2.0"
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 Same as --host
67
- HARNESS_FE_TOKEN Same as --token (use "auto" to generate)
68
- HARNESS_FE_MCP_TRANSPORT Same as --mcp-transport
69
- HARNESS_FE_MCP_PATH Same as --mcp-path
70
- HARNESS_FE_URL Full ws:// URL (legacy; --host/--port override it)
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 { startMcpStdioServer } from './mcp.js';
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(bridge: IBridge): Promise<McpServer> {
68
- const server = createMcpServer(bridge);
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,