@harness-fe/mcp-server 3.4.1 → 4.0.0-next.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/dist/bridge.d.ts CHANGED
@@ -12,7 +12,8 @@
12
12
  */
13
13
  import { type IncomingMessage, type ServerResponse } from 'node:http';
14
14
  import { type AuthOptions } from './auth.js';
15
- import { type EventFrame, type HttpBatch, type TabInfo, type Task, type TaskStatus } from '@harness-fe/protocol';
15
+ import { type Principal } from './identity.js';
16
+ import { type ConsentPolicy, type EventFrame, type HttpBatch, type TabInfo, type Task, type TaskStatus } from '@harness-fe/protocol';
16
17
  import { SessionRouter, type PeerSession } from './sessionRouter.js';
17
18
  import { type IStore, type ITaskStore, type IMemoryStore, type RetentionPolicy } from './store/index.js';
18
19
  /**
@@ -29,8 +30,8 @@ export interface IBridge {
29
30
  status?: TaskStatus | 'all';
30
31
  limit?: number;
31
32
  }): Promise<Task[]>;
32
- claimTask(id: string): Promise<Task | undefined>;
33
- resolveTask(id: string, note?: string): Promise<Task | undefined>;
33
+ claimTask(id: string, principal?: Principal): Promise<Task | undefined>;
34
+ resolveTask(id: string, note?: string, principal?: Principal): Promise<Task | undefined>;
34
35
  getMemoryStore(): IMemoryStore;
35
36
  /**
36
37
  * Base URL (e.g. http://127.0.0.1:47729) where the replay viewer is reachable.
@@ -75,6 +76,13 @@ export interface BridgeOptions {
75
76
  * token disables auth (only valid when bound to a loopback host).
76
77
  */
77
78
  auth?: AuthOptions;
79
+ /**
80
+ * Browser-consent policy for control commands (4.0 · P2). When omitted it
81
+ * defaults to `session` while auth is enabled (non-loopback / exposed) and
82
+ * `off` on loopback — so solo dev keeps its zero-friction flow and any
83
+ * exposed daemon prompts the user before an agent drives the page.
84
+ */
85
+ consent?: ConsentPolicy;
78
86
  /**
79
87
  * Override the host used when building outbound URLs (dashboard links,
80
88
  * replay viewer URLs). When omitted and `host` is `0.0.0.0` / `::`, the
@@ -157,6 +165,8 @@ export declare class Bridge implements IBridge {
157
165
  private tasks;
158
166
  private opts;
159
167
  private auth;
168
+ /** Browser-consent policy pushed to runtime clients in hello.ack (4.0 · P2). */
169
+ private readonly consentPolicy;
160
170
  private publicHostOverride;
161
171
  private readonly attachDataDir;
162
172
  private autoPurgeOpts;
@@ -167,6 +177,15 @@ export declare class Bridge implements IBridge {
167
177
  * or sessionId (for runtime-client connections).
168
178
  */
169
179
  private connToStoreId;
180
+ /** Caller identity per connection (4.0 · P1). Resolved at WS upgrade. */
181
+ private connToPrincipal;
182
+ /**
183
+ * Identity attributed to MCP-driven writes (task claim/resolve). The MCP
184
+ * tool layer is a daemon-wide singleton today with no per-call caller
185
+ * context, so stdio/HTTP agents collapse to one principal. P4 (per-session
186
+ * MCP transport) replaces this with the real per-call principal.
187
+ */
188
+ private readonly defaultPrincipal;
170
189
  /** Connections that already logged a "no store session" warning. */
171
190
  private warnedNoSession;
172
191
  /**
@@ -228,6 +247,8 @@ export declare class Bridge implements IBridge {
228
247
  getBoundPort(): number | undefined;
229
248
  getViewerBaseUrl(): string | undefined;
230
249
  getAuthToken(): string | undefined;
250
+ /** Daemon auth options — used by the MCP layer to identify the per-call principal (4.0 · P4). */
251
+ getAuthOptions(): AuthOptions;
231
252
  /**
232
253
  * Broadcast a `dashboard.update` frame to every subscribed dashboard SPA.
233
254
  *
@@ -264,9 +285,9 @@ export declare class Bridge implements IBridge {
264
285
  status?: TaskStatus | 'all';
265
286
  limit?: number;
266
287
  }): Promise<Task[]>;
267
- claimTask(id: string): Promise<Task | undefined>;
288
+ claimTask(id: string, principal?: Principal): Promise<Task | undefined>;
268
289
  getTaskAttachmentData(taskId: string, attachmentId: string): Promise<string | null>;
269
- resolveTask(id: string, note?: string): Promise<Task | undefined>;
290
+ resolveTask(id: string, note?: string, principal?: Principal): Promise<Task | undefined>;
270
291
  private persistTaskEvent;
271
292
  private recordTask;
272
293
  /**
package/dist/bridge.js CHANGED
@@ -14,7 +14,8 @@ import { WebSocket, WebSocketServer } from 'ws';
14
14
  import { randomUUID } from 'node:crypto';
15
15
  import { createServer } from 'node:http';
16
16
  import { networkInterfaces } from 'node:os';
17
- import { DEFAULT_LOGIN_PATH, handleLoginPost, isAuthorized, sendUnauthorized, } from './auth.js';
17
+ import { DEFAULT_LOGIN_PATH, handleLoginPost, isAuthEnabled, isAuthorized, sendUnauthorized, } from './auth.js';
18
+ import { LOCAL_PRINCIPAL, resolvePrincipal } from './identity.js';
18
19
  import { join as joinPath } from 'node:path';
19
20
  import { homedir } from 'node:os';
20
21
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -59,6 +60,8 @@ export class Bridge {
59
60
  tasks = new Map();
60
61
  opts;
61
62
  auth;
63
+ /** Browser-consent policy pushed to runtime clients in hello.ack (4.0 · P2). */
64
+ consentPolicy;
62
65
  publicHostOverride;
63
66
  attachDataDir;
64
67
  autoPurgeOpts;
@@ -69,6 +72,15 @@ export class Bridge {
69
72
  * or sessionId (for runtime-client connections).
70
73
  */
71
74
  connToStoreId = new Map();
75
+ /** Caller identity per connection (4.0 · P1). Resolved at WS upgrade. */
76
+ connToPrincipal = new Map();
77
+ /**
78
+ * Identity attributed to MCP-driven writes (task claim/resolve). The MCP
79
+ * tool layer is a daemon-wide singleton today with no per-call caller
80
+ * context, so stdio/HTTP agents collapse to one principal. P4 (per-session
81
+ * MCP transport) replaces this with the real per-call principal.
82
+ */
83
+ defaultPrincipal = LOCAL_PRINCIPAL;
72
84
  /** Connections that already logged a "no store session" warning. */
73
85
  warnedNoSession = new Set();
74
86
  /**
@@ -107,6 +119,12 @@ export class Bridge {
107
119
  host: opts.host ?? '127.0.0.1',
108
120
  };
109
121
  this.auth = opts.auth ?? {};
122
+ // Consent defaults track the auth boundary: exposed (auth on) ⇒ prompt
123
+ // once per session; loopback solo (auth off) ⇒ no prompts. Explicit
124
+ // opts.consent always wins.
125
+ this.consentPolicy = opts.consent ?? {
126
+ mode: isAuthEnabled(this.auth) ? 'session' : 'off',
127
+ };
110
128
  this.publicHostOverride = opts.publicHost;
111
129
  // Default auto-purge ON. CI / tests pass `enabled: false` (or set
112
130
  // env HARNESS_FE_PURGE_DISABLED=1) to opt out.
@@ -272,7 +290,7 @@ export class Bridge {
272
290
  res.end('Not Found');
273
291
  });
274
292
  const wss = new WebSocketServer({ noServer: true });
275
- wss.on('connection', (ws) => this.onConnection(ws));
293
+ wss.on('connection', (ws, req) => this.onConnection(ws, req));
276
294
  httpServer.on('upgrade', (req, socket, head) => {
277
295
  if (!isAuthorized(req, this.auth)) {
278
296
  // Spec-compliant 401 on the upgrade reply so client sees a
@@ -379,6 +397,10 @@ export class Bridge {
379
397
  getAuthToken() {
380
398
  return this.auth.token;
381
399
  }
400
+ /** Daemon auth options — used by the MCP layer to identify the per-call principal (4.0 · P4). */
401
+ getAuthOptions() {
402
+ return this.auth;
403
+ }
382
404
  /**
383
405
  * Broadcast a `dashboard.update` frame to every subscribed dashboard SPA.
384
406
  *
@@ -539,12 +561,16 @@ export class Bridge {
539
561
  filtered.sort((a, b) => b.createdAt - a.createdAt);
540
562
  return filtered.slice(0, limit);
541
563
  }
542
- async claimTask(id) {
564
+ async claimTask(id, principal) {
543
565
  const task = this.tasks.get(id);
544
566
  if (!task)
545
567
  return undefined;
546
568
  task.status = 'claimed';
547
569
  task.claimedAt = Date.now();
570
+ // Tag which agent picked it up (4.0 · P1/P4). The per-call principal
571
+ // (HTTP MCP, resolved from request headers) wins; stdio / no-caller
572
+ // falls back to the daemon's local principal.
573
+ task.agentId = (principal ?? this.defaultPrincipal).id;
548
574
  this.persistTasks();
549
575
  // Persist status change to store
550
576
  this.persistTaskEvent(task, 'task:claim');
@@ -556,7 +582,7 @@ export class Bridge {
556
582
  return null;
557
583
  return this.readTaskAttachment(task.projectId, taskId, attachmentId);
558
584
  }
559
- async resolveTask(id, note) {
585
+ async resolveTask(id, note, principal) {
560
586
  const task = this.tasks.get(id);
561
587
  if (!task)
562
588
  return undefined;
@@ -564,6 +590,8 @@ export class Bridge {
564
590
  task.resolvedAt = Date.now();
565
591
  if (note !== undefined)
566
592
  task.note = note;
593
+ if (!task.agentId)
594
+ task.agentId = (principal ?? this.defaultPrincipal).id;
567
595
  this.persistTasks();
568
596
  // Persist status change to store
569
597
  this.persistTaskEvent(task, 'task:resolve');
@@ -818,9 +846,13 @@ export class Bridge {
818
846
  }
819
847
  return false;
820
848
  }
821
- onConnection(ws) {
849
+ onConnection(ws, req) {
822
850
  const connectionId = randomUUID();
823
851
  this.sockets.set(connectionId, ws);
852
+ // Resolve caller identity once at connection time (4.0 · P1). The
853
+ // upgrade handler already enforced isAuthorized, so resolvePrincipal
854
+ // won't reject here; fall back to LOCAL for the loopback / no-req path.
855
+ this.connToPrincipal.set(connectionId, (req && resolvePrincipal(req, this.auth)) || LOCAL_PRINCIPAL);
824
856
  ws.on('message', (raw) => {
825
857
  let parsed;
826
858
  try {
@@ -880,6 +912,7 @@ export class Bridge {
880
912
  }
881
913
  }
882
914
  this.router.unregister(connectionId);
915
+ this.connToPrincipal.delete(connectionId);
883
916
  });
884
917
  ws.on('error', () => {
885
918
  /* swallow; close will follow */
@@ -924,6 +957,7 @@ export class Bridge {
924
957
  // production / staging deployment where the bundler plugin is
925
958
  // absent. The runtime-client branch below opens its own store
926
959
  // session if one does not already exist for this project.
960
+ const principal = this.connToPrincipal.get(connectionId) ?? LOCAL_PRINCIPAL;
927
961
  const session = this.router.register({
928
962
  role: frame.role,
929
963
  projectId: frame.projectId,
@@ -933,6 +967,7 @@ export class Bridge {
933
967
  userId: frame.userId,
934
968
  connectionId,
935
969
  page: frame.page,
970
+ principal,
936
971
  });
937
972
  // Persist to store
938
973
  if (this.store) {
@@ -944,6 +979,7 @@ export class Bridge {
944
979
  this.store.upsertProject(frame.projectId, {
945
980
  parentProjectId: frame.parentProjectId,
946
981
  displayName: frame.displayName,
982
+ createdBy: principal.id,
947
983
  });
948
984
  }
949
985
  catch (err) {
@@ -1018,6 +1054,7 @@ export class Bridge {
1018
1054
  referrer: undefined,
1019
1055
  userAgent: frame.page?.userAgent,
1020
1056
  participants,
1057
+ createdBy: principal.id,
1021
1058
  });
1022
1059
  this.connToStoreId.set(connectionId, sessionId);
1023
1060
  this.notifyDashboard({
@@ -1060,6 +1097,7 @@ export class Bridge {
1060
1097
  id: frame.id,
1061
1098
  tabId: session.tabId,
1062
1099
  serverVersion: PROTOCOL_VERSION,
1100
+ consent: this.consentPolicy,
1063
1101
  };
1064
1102
  ws.send(JSON.stringify(ack));
1065
1103
  // One concise line per accepted peer. Visibility for "is the
package/dist/daemon.d.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  * not pushed into the factory.
24
24
  */
25
25
  import type { IncomingMessage } from 'node:http';
26
+ import type { ConsentPolicy } from '@harness-fe/protocol';
26
27
  import { Bridge } from './bridge.js';
27
28
  import type { EventStore, IStore } from './store/types.js';
28
29
  import type { ITaskStore, IMemoryStore } from './store/types.js';
@@ -56,6 +57,12 @@ export interface DaemonOptions {
56
57
  * flows through here. Mutually exclusive with `authorize`.
57
58
  */
58
59
  token?: string;
60
+ /**
61
+ * Browser-consent policy for control commands (4.0 · P2). Omit to track the
62
+ * auth boundary automatically: `session` when auth is on (exposed daemon),
63
+ * `off` on loopback solo dev. Pass `{ mode }` to force a policy.
64
+ */
65
+ consent?: ConsentPolicy;
59
66
  /**
60
67
  * IStore implementation. Omit for the default JSONL store at `dataDir`.
61
68
  * Pass `null` to disable session/event persistence entirely.
package/dist/daemon.js CHANGED
@@ -45,6 +45,7 @@ export function createDaemon(opts = {}) {
45
45
  dataDir: opts.dataDir,
46
46
  label: opts.label,
47
47
  auth,
48
+ consent: opts.consent,
48
49
  });
49
50
  let mcpHandle;
50
51
  let started = false;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Caller identity (4.0 · P1) — turns the auth boundary from a plain
3
+ * allow/deny into "allow/deny + *who*".
4
+ *
5
+ * Phase 1 scope: this module only *establishes* and *carries* a Principal,
6
+ * and the bridge *tags* writes with it (createdBy). It deliberately does NOT
7
+ * filter reads by owner — that's P3 (tenant isolation). Keeping the two apart
8
+ * means identity plumbing lands with zero behaviour change: loopback solo dev
9
+ * stays a single implicit `local` principal, and an authorized caller sees
10
+ * everything exactly as before.
11
+ *
12
+ * Why a separate module from auth.ts: `isAuthorized` answers a boolean and is
13
+ * consumed on the hot path of every HTTP route / WS upgrade. `resolvePrincipal`
14
+ * is the richer, additive view layered on top — it reuses the same primitives
15
+ * (isAuthEnabled / extractToken / verifyToken) so the two can never disagree on
16
+ * who is allowed in.
17
+ */
18
+ import type { IncomingMessage } from 'node:http';
19
+ import { type AuthOptions } from './auth.js';
20
+ export type PrincipalKind = 'local' | 'token' | 'host';
21
+ export interface Principal {
22
+ /** Stable id for this caller. Loopback / stdio solo dev → `local`. */
23
+ id: string;
24
+ /** How the identity was established. */
25
+ kind: PrincipalKind;
26
+ /** Optional human-readable label (for dashboards / audit). */
27
+ displayName?: string;
28
+ }
29
+ /**
30
+ * The implicit single principal for loopback and stdio solo dev. The daemon
31
+ * trusts everything that can reach the loopback socket, so there is one
32
+ * caller and it owns everything — exactly today's behaviour, now named.
33
+ */
34
+ export declare const LOCAL_PRINCIPAL: Principal;
35
+ /**
36
+ * Principal for the custom-`authorize` path. Hosts that embed the daemon own
37
+ * their own user model; until `authorize` can return a richer identity
38
+ * (future work), an authorized host caller maps to this single principal.
39
+ */
40
+ export declare const HOST_PRINCIPAL: Principal;
41
+ /**
42
+ * Derive a stable principal id from a bearer token. One token = one principal
43
+ * in 4.0's trusted-team model. We hash so the raw secret never becomes an id
44
+ * that could leak into stored `createdBy` tags or audit logs.
45
+ */
46
+ export declare function tokenPrincipalId(token: string): string;
47
+ /**
48
+ * Resolve the caller behind a request.
49
+ *
50
+ * Returns `null` when auth is enabled and the request is NOT authorized — this
51
+ * mirrors `isAuthorized(req) === false` exactly, so callers can treat a null
52
+ * principal as "reject" without a second auth check.
53
+ *
54
+ * - auth disabled (loopback): {@link LOCAL_PRINCIPAL}
55
+ * - custom `authorize`: {@link HOST_PRINCIPAL} when it accepts, else `null`
56
+ * - token: a {@link tokenPrincipalId}-derived principal when it matches, else `null`
57
+ */
58
+ export declare function resolvePrincipal(req: IncomingMessage, opts: AuthOptions): Principal | null;
59
+ type HeaderBag = Record<string, string | string[] | undefined>;
60
+ /**
61
+ * Identify (not authorize) the caller behind an MCP tool call (4.0 · P4).
62
+ *
63
+ * The HTTP MCP request has already cleared the bridge's auth wrapper by the
64
+ * time a tool runs, so this only needs to *name* the caller — never to
65
+ * re-check them. Pass the per-request headers from the MCP SDK's
66
+ * `extra.requestInfo`; stdio calls have no requestInfo and resolve to `local`
67
+ * (the daemon trusts its local stdio agent).
68
+ *
69
+ * - auth disabled, or no headers (stdio) → {@link LOCAL_PRINCIPAL}
70
+ * - custom authorize → {@link HOST_PRINCIPAL}
71
+ * - token mode → token principal from the Authorization header (LOCAL if absent)
72
+ */
73
+ export declare function identifyPrincipal(headers: HeaderBag | undefined, opts: AuthOptions): Principal;
74
+ export {};
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Caller identity (4.0 · P1) — turns the auth boundary from a plain
3
+ * allow/deny into "allow/deny + *who*".
4
+ *
5
+ * Phase 1 scope: this module only *establishes* and *carries* a Principal,
6
+ * and the bridge *tags* writes with it (createdBy). It deliberately does NOT
7
+ * filter reads by owner — that's P3 (tenant isolation). Keeping the two apart
8
+ * means identity plumbing lands with zero behaviour change: loopback solo dev
9
+ * stays a single implicit `local` principal, and an authorized caller sees
10
+ * everything exactly as before.
11
+ *
12
+ * Why a separate module from auth.ts: `isAuthorized` answers a boolean and is
13
+ * consumed on the hot path of every HTTP route / WS upgrade. `resolvePrincipal`
14
+ * is the richer, additive view layered on top — it reuses the same primitives
15
+ * (isAuthEnabled / extractToken / verifyToken) so the two can never disagree on
16
+ * who is allowed in.
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+ import { extractToken, isAuthEnabled, verifyToken } from './auth.js';
20
+ /**
21
+ * The implicit single principal for loopback and stdio solo dev. The daemon
22
+ * trusts everything that can reach the loopback socket, so there is one
23
+ * caller and it owns everything — exactly today's behaviour, now named.
24
+ */
25
+ export const LOCAL_PRINCIPAL = Object.freeze({
26
+ id: 'local',
27
+ kind: 'local',
28
+ displayName: 'local',
29
+ });
30
+ /**
31
+ * Principal for the custom-`authorize` path. Hosts that embed the daemon own
32
+ * their own user model; until `authorize` can return a richer identity
33
+ * (future work), an authorized host caller maps to this single principal.
34
+ */
35
+ export const HOST_PRINCIPAL = Object.freeze({
36
+ id: 'host',
37
+ kind: 'host',
38
+ displayName: 'host',
39
+ });
40
+ /**
41
+ * Derive a stable principal id from a bearer token. One token = one principal
42
+ * in 4.0's trusted-team model. We hash so the raw secret never becomes an id
43
+ * that could leak into stored `createdBy` tags or audit logs.
44
+ */
45
+ export function tokenPrincipalId(token) {
46
+ return `token:${createHash('sha256').update(token).digest('hex').slice(0, 12)}`;
47
+ }
48
+ /**
49
+ * Resolve the caller behind a request.
50
+ *
51
+ * Returns `null` when auth is enabled and the request is NOT authorized — this
52
+ * mirrors `isAuthorized(req) === false` exactly, so callers can treat a null
53
+ * principal as "reject" without a second auth check.
54
+ *
55
+ * - auth disabled (loopback): {@link LOCAL_PRINCIPAL}
56
+ * - custom `authorize`: {@link HOST_PRINCIPAL} when it accepts, else `null`
57
+ * - token: a {@link tokenPrincipalId}-derived principal when it matches, else `null`
58
+ */
59
+ export function resolvePrincipal(req, opts) {
60
+ if (!isAuthEnabled(opts))
61
+ return LOCAL_PRINCIPAL;
62
+ if (opts.authorize)
63
+ return opts.authorize(req) ? HOST_PRINCIPAL : null;
64
+ const token = extractToken(req, opts);
65
+ if (!verifyToken(token, opts.token))
66
+ return null;
67
+ return { id: tokenPrincipalId(token), kind: 'token' };
68
+ }
69
+ function bearerFromHeaders(headers) {
70
+ const raw = headers['authorization'] ?? headers['Authorization'];
71
+ const v = Array.isArray(raw) ? raw[0] : raw;
72
+ if (typeof v === 'string' && v.startsWith('Bearer ')) {
73
+ const t = v.slice(7).trim();
74
+ if (t)
75
+ return t;
76
+ }
77
+ return undefined;
78
+ }
79
+ /**
80
+ * Identify (not authorize) the caller behind an MCP tool call (4.0 · P4).
81
+ *
82
+ * The HTTP MCP request has already cleared the bridge's auth wrapper by the
83
+ * time a tool runs, so this only needs to *name* the caller — never to
84
+ * re-check them. Pass the per-request headers from the MCP SDK's
85
+ * `extra.requestInfo`; stdio calls have no requestInfo and resolve to `local`
86
+ * (the daemon trusts its local stdio agent).
87
+ *
88
+ * - auth disabled, or no headers (stdio) → {@link LOCAL_PRINCIPAL}
89
+ * - custom authorize → {@link HOST_PRINCIPAL}
90
+ * - token mode → token principal from the Authorization header (LOCAL if absent)
91
+ */
92
+ export function identifyPrincipal(headers, opts) {
93
+ if (!isAuthEnabled(opts))
94
+ return LOCAL_PRINCIPAL;
95
+ if (opts.authorize)
96
+ return HOST_PRINCIPAL;
97
+ if (!headers)
98
+ return LOCAL_PRINCIPAL;
99
+ const token = bearerFromHeaders(headers);
100
+ return token ? { id: tokenPrincipalId(token), kind: 'token' } : LOCAL_PRINCIPAL;
101
+ }
package/dist/mcp.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
9
  import type { IBridge } from './bridge.js';
10
+ import type { AuthOptions } from './auth.js';
10
11
  export interface McpServerOptions {
11
12
  /**
12
13
  * Name of the environment variable that gates experimental tools.
@@ -17,6 +18,12 @@ export interface McpServerOptions {
17
18
  * var is set to a non-empty value at server-construction time.
18
19
  */
19
20
  experimentalEnvVar?: string;
21
+ /**
22
+ * Daemon auth options, used to identify the per-call principal from MCP
23
+ * request headers (4.0 · P4). Omit for stdio / no-auth — calls resolve to
24
+ * the local principal.
25
+ */
26
+ auth?: AuthOptions;
20
27
  }
21
28
  /**
22
29
  * Experimental-feature gate.
package/dist/mcp.js CHANGED
@@ -9,6 +9,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
9
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
10
  import { z } from 'zod';
11
11
  import { COMMAND, PROTOCOL_VERSION, clickArgsSchema, evaluateArgsSchema, navigateArgsSchema, reloadArgsSchema, screenshotArgsSchema, scrollArgsSchema, setHtmlArgsSchema, setStyleArgsSchema, selectorSchema, typeArgsSchema, waitForArgsSchema, } from '@harness-fe/protocol';
12
+ import { identifyPrincipal } from './identity.js';
12
13
  import { RemoteBridge } from './remoteBridge.js';
13
14
  import { buildVisitorTimeline } from './visitorTimeline.js';
14
15
  import { createReplayExport } from './replayCreate.js';
@@ -47,7 +48,7 @@ export function createMcpServer(bridge, options = {}) {
47
48
  name: SERVER_NAME,
48
49
  version: PROTOCOL_VERSION,
49
50
  });
50
- registerTools(server, bridge);
51
+ registerTools(server, bridge, options.auth);
51
52
  // Experimental tools are on by default. They only get gated when the host
52
53
  // supplies an env-var name to key off; see experimentalEnabled().
53
54
  if (experimentalEnabled(options.experimentalEnvVar)) {
@@ -87,7 +88,7 @@ function err(message) {
87
88
  isError: true,
88
89
  };
89
90
  }
90
- function registerTools(server, bridge) {
91
+ function registerTools(server, bridge, auth) {
91
92
  server.registerTool(COMMAND.PAGE_CLICK, {
92
93
  description: 'Click on a DOM element resolved by the selector.',
93
94
  inputSchema: {
@@ -452,83 +453,79 @@ function registerTools(server, bridge) {
452
453
  return ok(out);
453
454
  });
454
455
  // ─── tasks.* tools (user annotations submitted from page) ─────────────
455
- // TODO: temporarily hidden re-enable when the report feature is ready.
456
- // To re-enable: remove the `if (false)` wrapper below and restore the block.
457
- /* eslint-disable no-constant-condition */
458
- if (false) {
459
- const taskStatusEnum = z.enum(['pending', 'claimed', 'resolved', 'all']);
460
- server.registerTool(COMMAND.TASKS_PENDING, {
461
- description: 'List user-submitted annotation tasks. Default `status="pending"`. Returns id/question/selector/url — call tasks.claim to fetch full element payload.',
462
- inputSchema: {
463
- status: taskStatusEnum.optional(),
464
- limit: z.number().int().positive().optional(),
465
- },
466
- }, async ({ status, limit }) => {
467
- const tasks = await bridge.listTasks({ status: status ?? 'pending', limit });
468
- const summary = tasks.map((t) => ({
469
- id: t.id,
470
- status: t.status,
471
- question: t.question,
472
- selector: t.selector,
473
- url: t.url,
474
- tabId: t.tabId,
475
- createdAt: t.createdAt,
476
- claimedAt: t.claimedAt,
477
- resolvedAt: t.resolvedAt,
478
- note: t.note,
479
- }));
480
- return ok({ count: summary.length, tasks: summary });
481
- });
482
- server.registerTool(COMMAND.TASKS_CLAIM, {
483
- description: 'Claim a task by id. Marks it claimed, returns full payload (selector + element outerHTML + rect).',
484
- inputSchema: {
485
- taskId: z.string(),
486
- },
487
- }, async ({ taskId }) => {
488
- const task = await bridge.claimTask(taskId);
489
- if (!task) {
490
- throw new Error(`tasks.claim: no task with id "${taskId}"`);
491
- }
492
- return ok(task);
493
- });
494
- server.registerTool(COMMAND.TASKS_RESOLVE, {
495
- description: 'Mark a task as resolved with an optional note. Use after addressing the user request.',
496
- inputSchema: {
497
- taskId: z.string(),
498
- note: z.string().optional(),
499
- },
500
- }, async ({ taskId, note }) => {
501
- const task = await bridge.resolveTask(taskId, note);
502
- if (!task) {
503
- throw new Error(`tasks.resolve: no task with id "${taskId}"`);
504
- }
505
- return ok({ ok: true, task });
506
- });
507
- server.registerTool('tasks.get_attachment', {
508
- description: 'Return a task screenshot attachment as a vision-ready image block. ' +
509
- 'Call after tasks.claim when the task summary includes an attachment pointer. ' +
510
- 'Compatible with Claude vision and GPT-4V.',
511
- inputSchema: {
512
- taskId: z.string().describe('Task id (from tasks.pending or tasks.claim).'),
513
- attachmentId: z.string().describe('Attachment id (from task.attachments[].id).'),
514
- },
515
- }, async ({ taskId, attachmentId }) => {
516
- const base64 = await bridge.getTaskAttachmentData(taskId, attachmentId);
517
- if (!base64) {
518
- throw new Error(`tasks.get_attachment: attachment not found (taskId=${taskId}, attachmentId=${attachmentId})`);
519
- }
520
- return {
521
- content: [
522
- {
523
- type: 'image',
524
- mimeType: 'image/png',
525
- data: base64,
526
- },
527
- ],
528
- };
529
- });
530
- }
531
- /* eslint-enable no-constant-condition */
456
+ const taskStatusEnum = z.enum(['pending', 'claimed', 'resolved', 'all']);
457
+ server.registerTool(COMMAND.TASKS_PENDING, {
458
+ description: 'List user-submitted annotation tasks. Default `status="pending"`. Returns id/question/selector/url — call tasks.claim to fetch full element payload.',
459
+ inputSchema: {
460
+ status: taskStatusEnum.optional(),
461
+ limit: z.number().int().positive().optional(),
462
+ },
463
+ }, async ({ status, limit }) => {
464
+ const tasks = await bridge.listTasks({ status: status ?? 'pending', limit });
465
+ const summary = tasks.map((t) => ({
466
+ id: t.id,
467
+ status: t.status,
468
+ question: t.question,
469
+ selector: t.selector,
470
+ url: t.url,
471
+ tabId: t.tabId,
472
+ createdAt: t.createdAt,
473
+ claimedAt: t.claimedAt,
474
+ resolvedAt: t.resolvedAt,
475
+ note: t.note,
476
+ }));
477
+ return ok({ count: summary.length, tasks: summary });
478
+ });
479
+ server.registerTool(COMMAND.TASKS_CLAIM, {
480
+ description: 'Claim a task by id. Marks it claimed, returns full payload (selector + element outerHTML + rect).',
481
+ inputSchema: {
482
+ taskId: z.string(),
483
+ },
484
+ }, async ({ taskId }, extra) => {
485
+ const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
486
+ const task = await bridge.claimTask(taskId, principal);
487
+ if (!task) {
488
+ throw new Error(`tasks.claim: no task with id "${taskId}"`);
489
+ }
490
+ return ok(task);
491
+ });
492
+ server.registerTool(COMMAND.TASKS_RESOLVE, {
493
+ description: 'Mark a task as resolved with an optional note. Use after addressing the user request.',
494
+ inputSchema: {
495
+ taskId: z.string(),
496
+ note: z.string().optional(),
497
+ },
498
+ }, async ({ taskId, note }, extra) => {
499
+ const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
500
+ const task = await bridge.resolveTask(taskId, note, principal);
501
+ if (!task) {
502
+ throw new Error(`tasks.resolve: no task with id "${taskId}"`);
503
+ }
504
+ return ok({ ok: true, task });
505
+ });
506
+ server.registerTool('tasks.get_attachment', {
507
+ description: 'Return a task screenshot attachment as a vision-ready image block. ' +
508
+ 'Call after tasks.claim when the task summary includes an attachment pointer. ' +
509
+ 'Compatible with Claude vision and GPT-4V.',
510
+ inputSchema: {
511
+ taskId: z.string().describe('Task id (from tasks.pending or tasks.claim).'),
512
+ attachmentId: z.string().describe('Attachment id (from task.attachments[].id).'),
513
+ },
514
+ }, async ({ taskId, attachmentId }) => {
515
+ const base64 = await bridge.getTaskAttachmentData(taskId, attachmentId);
516
+ if (!base64) {
517
+ throw new Error(`tasks.get_attachment: attachment not found (taskId=${taskId}, attachmentId=${attachmentId})`);
518
+ }
519
+ return {
520
+ content: [
521
+ {
522
+ type: 'image',
523
+ mimeType: 'image/png',
524
+ data: base64,
525
+ },
526
+ ],
527
+ };
528
+ });
532
529
  }
533
530
  // ─── Experimental tools (gated by HARNESS_FE_EXPERIMENTAL) ────────────────────
534
531
  /**