@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/mcpHttp.js CHANGED
@@ -21,7 +21,13 @@ 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, { experimentalEnvVar: opts.experimentalEnvVar });
24
+ // Pass the daemon's auth so MCP tools can identify the per-call principal
25
+ // from request headers (4.0 · P4). stdio (startMcpStdioServer) omits this,
26
+ // so stdio calls resolve to the local principal.
27
+ const server = createMcpServer(bridge, {
28
+ experimentalEnvVar: opts.experimentalEnvVar,
29
+ auth: bridge.getAuthOptions(),
30
+ });
25
31
  const transport = new StreamableHTTPServerTransport({
26
32
  sessionIdGenerator: stateful ? () => randomUUID() : undefined,
27
33
  eventStore,
@@ -8,10 +8,13 @@
8
8
  * Caller can override via explicit tabId on every command.
9
9
  */
10
10
  import type { PeerRole, TabInfo } from '@harness-fe/protocol';
11
+ import type { Principal } from './identity.js';
11
12
  export interface PeerSession {
12
13
  role: PeerRole;
13
14
  projectId: string;
14
15
  tabId?: string;
16
+ /** Caller identity behind this connection (4.0 · P1). Defaults to `local`. */
17
+ principal?: Principal;
15
18
  /** Runtime-client only: identifies the page load (sessionId) this connection belongs to. */
16
19
  sessionId?: string;
17
20
  /** Runtime-client only: stable per-browser visitor identifier. */
@@ -401,6 +401,8 @@ export class JsonlStore {
401
401
  ...existing,
402
402
  ...meta,
403
403
  id: sessionId,
404
+ // Write-once: first principal to open the session owns it.
405
+ createdBy: existing?.createdBy ?? meta.createdBy,
404
406
  };
405
407
  // Reset participants — we'll rebuild via dedup loop below
406
408
  merged.participants = [];
@@ -552,6 +554,8 @@ export class JsonlStore {
552
554
  id: projectId,
553
555
  createdAt: existing?.createdAt ?? Date.now(),
554
556
  lastActiveAt: Date.now(),
557
+ // Write-once: the first principal to create the project owns it.
558
+ createdBy: existing?.createdBy ?? patch.createdBy,
555
559
  };
556
560
  enforceExtensionBudget(merged, `project ${projectId}`);
557
561
  writeJson(metaPath, merged);
@@ -63,6 +63,12 @@ export interface ProjectMeta {
63
63
  parentProjectId?: string;
64
64
  displayName?: string;
65
65
  tags?: string[];
66
+ /**
67
+ * Caller-identity tag (4.0 · P1): principal id that first created this
68
+ * project. Write-once (locked on creation like `createdAt`); informational
69
+ * in P1, the basis for `project → agent` routing/isolation in P3.
70
+ */
71
+ createdBy?: string;
66
72
  metadata?: Record<string, unknown>;
67
73
  }
68
74
  /**
@@ -140,6 +146,11 @@ export interface SessionMeta {
140
146
  };
141
147
  storageTruncated?: boolean;
142
148
  };
149
+ /**
150
+ * Caller-identity tag (4.0 · P1): principal id of the connection that
151
+ * opened this session. Write-once. Informational in P1.
152
+ */
153
+ createdBy?: string;
143
154
  metadata?: Record<string, unknown>;
144
155
  }
145
156
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-fe/mcp-server",
3
- "version": "3.4.1",
3
+ "version": "4.0.0-next.1",
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",
@@ -39,7 +39,7 @@
39
39
  "ws": "^8.18.0",
40
40
  "zod": "^4.4.3",
41
41
  "@harness-fe/dashboard-ui": "0.2.0",
42
- "@harness-fe/protocol": "3.2.0"
42
+ "@harness-fe/protocol": "4.0.0-next.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/ws": "^8.5.10",
package/src/bridge.ts CHANGED
@@ -18,10 +18,12 @@ import { networkInterfaces } from 'node:os';
18
18
  import {
19
19
  DEFAULT_LOGIN_PATH,
20
20
  handleLoginPost,
21
+ isAuthEnabled,
21
22
  isAuthorized,
22
23
  sendUnauthorized,
23
24
  type AuthOptions,
24
25
  } from './auth.js';
26
+ import { LOCAL_PRINCIPAL, resolvePrincipal, type Principal } from './identity.js';
25
27
  import { join as joinPath } from 'node:path';
26
28
  import { homedir } from 'node:os';
27
29
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -29,6 +31,7 @@ import {
29
31
  DEFAULT_WS_PORT,
30
32
  EVENT_NAME,
31
33
  PROTOCOL_VERSION,
34
+ type ConsentPolicy,
32
35
  isLoopbackHost,
33
36
  pageLoadPayloadSchema,
34
37
  rrwebChunkPayloadSchema,
@@ -79,8 +82,8 @@ export interface IBridge {
79
82
  ): Promise<unknown>;
80
83
  listTabs(): Promise<TabInfo[]>;
81
84
  listTasks(filter?: { status?: TaskStatus | 'all'; limit?: number }): Promise<Task[]>;
82
- claimTask(id: string): Promise<Task | undefined>;
83
- resolveTask(id: string, note?: string): Promise<Task | undefined>;
85
+ claimTask(id: string, principal?: Principal): Promise<Task | undefined>;
86
+ resolveTask(id: string, note?: string, principal?: Principal): Promise<Task | undefined>;
84
87
  getMemoryStore(): IMemoryStore;
85
88
  /**
86
89
  * Base URL (e.g. http://127.0.0.1:47729) where the replay viewer is reachable.
@@ -139,6 +142,13 @@ export interface BridgeOptions {
139
142
  * token disables auth (only valid when bound to a loopback host).
140
143
  */
141
144
  auth?: AuthOptions;
145
+ /**
146
+ * Browser-consent policy for control commands (4.0 · P2). When omitted it
147
+ * defaults to `session` while auth is enabled (non-loopback / exposed) and
148
+ * `off` on loopback — so solo dev keeps its zero-friction flow and any
149
+ * exposed daemon prompts the user before an agent drives the page.
150
+ */
151
+ consent?: ConsentPolicy;
142
152
  /**
143
153
  * Override the host used when building outbound URLs (dashboard links,
144
154
  * replay viewer URLs). When omitted and `host` is `0.0.0.0` / `::`, the
@@ -221,8 +231,10 @@ export class Bridge implements IBridge {
221
231
  private pending = new Map<string, PendingCommand>();
222
232
  private eventListeners = new Set<EventListener>();
223
233
  private tasks = new Map<string, Task>();
224
- private opts: Required<Omit<BridgeOptions, 'store' | 'taskStore' | 'memoryStore' | 'autoPurge' | 'attachmentsDataDir' | 'auth' | 'publicHost' | 'dataDir' | 'label'>>;
234
+ private opts: Required<Omit<BridgeOptions, 'store' | 'taskStore' | 'memoryStore' | 'autoPurge' | 'attachmentsDataDir' | 'auth' | 'consent' | 'publicHost' | 'dataDir' | 'label'>>;
225
235
  private auth: AuthOptions;
236
+ /** Browser-consent policy pushed to runtime clients in hello.ack (4.0 · P2). */
237
+ private readonly consentPolicy: ConsentPolicy;
226
238
  private publicHostOverride: string | undefined;
227
239
  private readonly attachDataDir: string;
228
240
  private autoPurgeOpts: Required<NonNullable<BridgeOptions['autoPurge']>>;
@@ -233,6 +245,15 @@ export class Bridge implements IBridge {
233
245
  * or sessionId (for runtime-client connections).
234
246
  */
235
247
  private connToStoreId = new Map<string, string>();
248
+ /** Caller identity per connection (4.0 · P1). Resolved at WS upgrade. */
249
+ private connToPrincipal = new Map<string, Principal>();
250
+ /**
251
+ * Identity attributed to MCP-driven writes (task claim/resolve). The MCP
252
+ * tool layer is a daemon-wide singleton today with no per-call caller
253
+ * context, so stdio/HTTP agents collapse to one principal. P4 (per-session
254
+ * MCP transport) replaces this with the real per-call principal.
255
+ */
256
+ private readonly defaultPrincipal: Principal = LOCAL_PRINCIPAL;
236
257
  /** Connections that already logged a "no store session" warning. */
237
258
  private warnedNoSession = new Set<string>();
238
259
  /**
@@ -273,6 +294,12 @@ export class Bridge implements IBridge {
273
294
  host: opts.host ?? '127.0.0.1',
274
295
  };
275
296
  this.auth = opts.auth ?? {};
297
+ // Consent defaults track the auth boundary: exposed (auth on) ⇒ prompt
298
+ // once per session; loopback solo (auth off) ⇒ no prompts. Explicit
299
+ // opts.consent always wins.
300
+ this.consentPolicy = opts.consent ?? {
301
+ mode: isAuthEnabled(this.auth) ? 'session' : 'off',
302
+ };
276
303
  this.publicHostOverride = opts.publicHost;
277
304
  // Default auto-purge ON. CI / tests pass `enabled: false` (or set
278
305
  // env HARNESS_FE_PURGE_DISABLED=1) to opt out.
@@ -440,7 +467,7 @@ export class Bridge implements IBridge {
440
467
  });
441
468
 
442
469
  const wss = new WebSocketServer({ noServer: true });
443
- wss.on('connection', (ws) => this.onConnection(ws));
470
+ wss.on('connection', (ws, req: IncomingMessage) => this.onConnection(ws, req));
444
471
 
445
472
  httpServer.on('upgrade', (req, socket, head) => {
446
473
  if (!isAuthorized(req, this.auth)) {
@@ -560,6 +587,11 @@ export class Bridge implements IBridge {
560
587
  return this.auth.token;
561
588
  }
562
589
 
590
+ /** Daemon auth options — used by the MCP layer to identify the per-call principal (4.0 · P4). */
591
+ getAuthOptions(): AuthOptions {
592
+ return this.auth;
593
+ }
594
+
563
595
  /**
564
596
  * Broadcast a `dashboard.update` frame to every subscribed dashboard SPA.
565
597
  *
@@ -740,11 +772,15 @@ export class Bridge implements IBridge {
740
772
  return filtered.slice(0, limit);
741
773
  }
742
774
 
743
- async claimTask(id: string): Promise<Task | undefined> {
775
+ async claimTask(id: string, principal?: Principal): Promise<Task | undefined> {
744
776
  const task = this.tasks.get(id);
745
777
  if (!task) return undefined;
746
778
  task.status = 'claimed';
747
779
  task.claimedAt = Date.now();
780
+ // Tag which agent picked it up (4.0 · P1/P4). The per-call principal
781
+ // (HTTP MCP, resolved from request headers) wins; stdio / no-caller
782
+ // falls back to the daemon's local principal.
783
+ task.agentId = (principal ?? this.defaultPrincipal).id;
748
784
  this.persistTasks();
749
785
  // Persist status change to store
750
786
  this.persistTaskEvent(task, 'task:claim');
@@ -757,12 +793,13 @@ export class Bridge implements IBridge {
757
793
  return this.readTaskAttachment(task.projectId, taskId, attachmentId);
758
794
  }
759
795
 
760
- async resolveTask(id: string, note?: string): Promise<Task | undefined> {
796
+ async resolveTask(id: string, note?: string, principal?: Principal): Promise<Task | undefined> {
761
797
  const task = this.tasks.get(id);
762
798
  if (!task) return undefined;
763
799
  task.status = 'resolved';
764
800
  task.resolvedAt = Date.now();
765
801
  if (note !== undefined) task.note = note;
802
+ if (!task.agentId) task.agentId = (principal ?? this.defaultPrincipal).id;
766
803
  this.persistTasks();
767
804
  // Persist status change to store
768
805
  this.persistTaskEvent(task, 'task:resolve');
@@ -1036,9 +1073,16 @@ export class Bridge implements IBridge {
1036
1073
  return false;
1037
1074
  }
1038
1075
 
1039
- private onConnection(ws: WebSocket): void {
1076
+ private onConnection(ws: WebSocket, req?: IncomingMessage): void {
1040
1077
  const connectionId = randomUUID();
1041
1078
  this.sockets.set(connectionId, ws);
1079
+ // Resolve caller identity once at connection time (4.0 · P1). The
1080
+ // upgrade handler already enforced isAuthorized, so resolvePrincipal
1081
+ // won't reject here; fall back to LOCAL for the loopback / no-req path.
1082
+ this.connToPrincipal.set(
1083
+ connectionId,
1084
+ (req && resolvePrincipal(req, this.auth)) || LOCAL_PRINCIPAL,
1085
+ );
1042
1086
 
1043
1087
  ws.on('message', (raw) => {
1044
1088
  let parsed: unknown;
@@ -1096,6 +1140,7 @@ export class Bridge implements IBridge {
1096
1140
  }
1097
1141
  }
1098
1142
  this.router.unregister(connectionId);
1143
+ this.connToPrincipal.delete(connectionId);
1099
1144
  });
1100
1145
 
1101
1146
  ws.on('error', () => {
@@ -1145,6 +1190,7 @@ export class Bridge implements IBridge {
1145
1190
  // absent. The runtime-client branch below opens its own store
1146
1191
  // session if one does not already exist for this project.
1147
1192
 
1193
+ const principal = this.connToPrincipal.get(connectionId) ?? LOCAL_PRINCIPAL;
1148
1194
  const session = this.router.register({
1149
1195
  role: frame.role,
1150
1196
  projectId: frame.projectId,
@@ -1154,6 +1200,7 @@ export class Bridge implements IBridge {
1154
1200
  userId: frame.userId,
1155
1201
  connectionId,
1156
1202
  page: frame.page,
1203
+ principal,
1157
1204
  });
1158
1205
  // Persist to store
1159
1206
  if (this.store) {
@@ -1167,6 +1214,7 @@ export class Bridge implements IBridge {
1167
1214
  this.store.upsertProject(frame.projectId, {
1168
1215
  parentProjectId: frame.parentProjectId,
1169
1216
  displayName: frame.displayName,
1217
+ createdBy: principal.id,
1170
1218
  });
1171
1219
  } catch (err) {
1172
1220
  // Cycle detection or other validation failure —
@@ -1244,6 +1292,7 @@ export class Bridge implements IBridge {
1244
1292
  referrer: undefined,
1245
1293
  userAgent: frame.page?.userAgent,
1246
1294
  participants,
1295
+ createdBy: principal.id,
1247
1296
  });
1248
1297
  this.connToStoreId.set(connectionId, sessionId);
1249
1298
  this.notifyDashboard({
@@ -1286,6 +1335,7 @@ export class Bridge implements IBridge {
1286
1335
  id: frame.id,
1287
1336
  tabId: session.tabId,
1288
1337
  serverVersion: PROTOCOL_VERSION,
1338
+ consent: this.consentPolicy,
1289
1339
  };
1290
1340
  ws.send(JSON.stringify(ack));
1291
1341
  // One concise line per accepted peer. Visibility for "is the
package/src/daemon.ts CHANGED
@@ -25,6 +25,8 @@
25
25
 
26
26
  import type { IncomingMessage } from 'node:http';
27
27
 
28
+ import type { ConsentPolicy } from '@harness-fe/protocol';
29
+
28
30
  import { Bridge } from './bridge.js';
29
31
  import { startMcpHttpServer } from './mcpHttp.js';
30
32
  import type { EventStore, IStore } from './store/types.js';
@@ -60,6 +62,12 @@ export interface DaemonOptions {
60
62
  * flows through here. Mutually exclusive with `authorize`.
61
63
  */
62
64
  token?: string;
65
+ /**
66
+ * Browser-consent policy for control commands (4.0 · P2). Omit to track the
67
+ * auth boundary automatically: `session` when auth is on (exposed daemon),
68
+ * `off` on loopback solo dev. Pass `{ mode }` to force a policy.
69
+ */
70
+ consent?: ConsentPolicy;
63
71
  /**
64
72
  * IStore implementation. Omit for the default JSONL store at `dataDir`.
65
73
  * Pass `null` to disable session/event persistence entirely.
@@ -150,6 +158,7 @@ export function createDaemon(opts: DaemonOptions = {}): DaemonHandle {
150
158
  dataDir: opts.dataDir,
151
159
  label: opts.label,
152
160
  auth,
161
+ consent: opts.consent,
153
162
  });
154
163
 
155
164
  let mcpHandle: Awaited<ReturnType<typeof startMcpHttpServer>> | undefined;
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { IncomingMessage } from 'node:http';
3
+ import {
4
+ HOST_PRINCIPAL,
5
+ LOCAL_PRINCIPAL,
6
+ identifyPrincipal,
7
+ resolvePrincipal,
8
+ tokenPrincipalId,
9
+ } from './identity.js';
10
+
11
+ function fakeReq(init: { headers?: Record<string, string>; url?: string } = {}): IncomingMessage {
12
+ return {
13
+ headers: init.headers ?? {},
14
+ url: init.url ?? '/',
15
+ } as unknown as IncomingMessage;
16
+ }
17
+
18
+ describe('identity: tokenPrincipalId', () => {
19
+ it('is stable + prefixed + hashed (never the raw token)', () => {
20
+ const id = tokenPrincipalId('super-secret');
21
+ expect(id).toMatch(/^token:[0-9a-f]{12}$/);
22
+ expect(id).toBe(tokenPrincipalId('super-secret'));
23
+ expect(id).not.toContain('super-secret');
24
+ });
25
+ it('different tokens → different ids', () => {
26
+ expect(tokenPrincipalId('a')).not.toBe(tokenPrincipalId('b'));
27
+ });
28
+ });
29
+
30
+ describe('identity: resolvePrincipal', () => {
31
+ it('loopback (auth disabled) → LOCAL_PRINCIPAL', () => {
32
+ expect(resolvePrincipal(fakeReq(), {})).toBe(LOCAL_PRINCIPAL);
33
+ expect(resolvePrincipal(fakeReq(), { token: '' })).toBe(LOCAL_PRINCIPAL);
34
+ });
35
+
36
+ it('token mode: matching token → token principal', () => {
37
+ const req = fakeReq({ headers: { authorization: 'Bearer s3cr3t' } });
38
+ const p = resolvePrincipal(req, { token: 's3cr3t' });
39
+ expect(p).toEqual({ id: tokenPrincipalId('s3cr3t'), kind: 'token' });
40
+ });
41
+
42
+ it('token mode: missing/wrong token → null (mirrors deny)', () => {
43
+ expect(resolvePrincipal(fakeReq(), { token: 's3cr3t' })).toBeNull();
44
+ expect(
45
+ resolvePrincipal(fakeReq({ headers: { authorization: 'Bearer nope' } }), { token: 's3cr3t' }),
46
+ ).toBeNull();
47
+ });
48
+
49
+ it('custom authorize: accept → HOST_PRINCIPAL, reject → null', () => {
50
+ expect(resolvePrincipal(fakeReq(), { authorize: () => true })).toBe(HOST_PRINCIPAL);
51
+ expect(resolvePrincipal(fakeReq(), { authorize: () => false })).toBeNull();
52
+ });
53
+
54
+ it('authorize wins over token (same precedence as isAuthorized)', () => {
55
+ const req = fakeReq({ headers: { authorization: 'Bearer wrong' } });
56
+ expect(resolvePrincipal(req, { token: 'right', authorize: () => true })).toBe(HOST_PRINCIPAL);
57
+ });
58
+ });
59
+
60
+ describe('identity: identifyPrincipal (P4 — identify, not authorize)', () => {
61
+ it('no auth → local', () => {
62
+ expect(identifyPrincipal({ authorization: 'Bearer x' }, {})).toBe(LOCAL_PRINCIPAL);
63
+ });
64
+
65
+ it('stdio (no headers) → local even when a token is configured', () => {
66
+ expect(identifyPrincipal(undefined, { token: 's3cr3t' })).toBe(LOCAL_PRINCIPAL);
67
+ });
68
+
69
+ it('token mode: names the caller from the Authorization header', () => {
70
+ const p = identifyPrincipal({ authorization: 'Bearer s3cr3t' }, { token: 's3cr3t' });
71
+ expect(p).toEqual({ id: tokenPrincipalId('s3cr3t'), kind: 'token' });
72
+ });
73
+
74
+ it('token mode: handles array-valued headers', () => {
75
+ const p = identifyPrincipal({ authorization: ['Bearer s3cr3t'] }, { token: 's3cr3t' });
76
+ expect(p.id).toBe(tokenPrincipalId('s3cr3t'));
77
+ });
78
+
79
+ it('token mode without a bearer header → local (already past auth wrapper)', () => {
80
+ expect(identifyPrincipal({}, { token: 's3cr3t' })).toBe(LOCAL_PRINCIPAL);
81
+ });
82
+
83
+ it('authorize mode → host', () => {
84
+ expect(identifyPrincipal({ authorization: 'Bearer x' }, { authorize: () => true })).toBe(HOST_PRINCIPAL);
85
+ });
86
+ });
@@ -0,0 +1,116 @@
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
+
19
+ import { createHash } from 'node:crypto';
20
+ import type { IncomingMessage } from 'node:http';
21
+
22
+ import { extractToken, isAuthEnabled, verifyToken, type AuthOptions } from './auth.js';
23
+
24
+ export type PrincipalKind = 'local' | 'token' | 'host';
25
+
26
+ export interface Principal {
27
+ /** Stable id for this caller. Loopback / stdio solo dev → `local`. */
28
+ id: string;
29
+ /** How the identity was established. */
30
+ kind: PrincipalKind;
31
+ /** Optional human-readable label (for dashboards / audit). */
32
+ displayName?: string;
33
+ }
34
+
35
+ /**
36
+ * The implicit single principal for loopback and stdio solo dev. The daemon
37
+ * trusts everything that can reach the loopback socket, so there is one
38
+ * caller and it owns everything — exactly today's behaviour, now named.
39
+ */
40
+ export const LOCAL_PRINCIPAL: Principal = Object.freeze({
41
+ id: 'local',
42
+ kind: 'local',
43
+ displayName: 'local',
44
+ });
45
+
46
+ /**
47
+ * Principal for the custom-`authorize` path. Hosts that embed the daemon own
48
+ * their own user model; until `authorize` can return a richer identity
49
+ * (future work), an authorized host caller maps to this single principal.
50
+ */
51
+ export const HOST_PRINCIPAL: Principal = Object.freeze({
52
+ id: 'host',
53
+ kind: 'host',
54
+ displayName: 'host',
55
+ });
56
+
57
+ /**
58
+ * Derive a stable principal id from a bearer token. One token = one principal
59
+ * in 4.0's trusted-team model. We hash so the raw secret never becomes an id
60
+ * that could leak into stored `createdBy` tags or audit logs.
61
+ */
62
+ export function tokenPrincipalId(token: string): string {
63
+ return `token:${createHash('sha256').update(token).digest('hex').slice(0, 12)}`;
64
+ }
65
+
66
+ /**
67
+ * Resolve the caller behind a request.
68
+ *
69
+ * Returns `null` when auth is enabled and the request is NOT authorized — this
70
+ * mirrors `isAuthorized(req) === false` exactly, so callers can treat a null
71
+ * principal as "reject" without a second auth check.
72
+ *
73
+ * - auth disabled (loopback): {@link LOCAL_PRINCIPAL}
74
+ * - custom `authorize`: {@link HOST_PRINCIPAL} when it accepts, else `null`
75
+ * - token: a {@link tokenPrincipalId}-derived principal when it matches, else `null`
76
+ */
77
+ export function resolvePrincipal(req: IncomingMessage, opts: AuthOptions): Principal | null {
78
+ if (!isAuthEnabled(opts)) return LOCAL_PRINCIPAL;
79
+ if (opts.authorize) return opts.authorize(req) ? HOST_PRINCIPAL : null;
80
+ const token = extractToken(req, opts);
81
+ if (!verifyToken(token, opts.token!)) return null;
82
+ return { id: tokenPrincipalId(token!), kind: 'token' };
83
+ }
84
+
85
+ type HeaderBag = Record<string, string | string[] | undefined>;
86
+
87
+ function bearerFromHeaders(headers: HeaderBag): string | undefined {
88
+ const raw = headers['authorization'] ?? headers['Authorization'];
89
+ const v = Array.isArray(raw) ? raw[0] : raw;
90
+ if (typeof v === 'string' && v.startsWith('Bearer ')) {
91
+ const t = v.slice(7).trim();
92
+ if (t) return t;
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ /**
98
+ * Identify (not authorize) the caller behind an MCP tool call (4.0 · P4).
99
+ *
100
+ * The HTTP MCP request has already cleared the bridge's auth wrapper by the
101
+ * time a tool runs, so this only needs to *name* the caller — never to
102
+ * re-check them. Pass the per-request headers from the MCP SDK's
103
+ * `extra.requestInfo`; stdio calls have no requestInfo and resolve to `local`
104
+ * (the daemon trusts its local stdio agent).
105
+ *
106
+ * - auth disabled, or no headers (stdio) → {@link LOCAL_PRINCIPAL}
107
+ * - custom authorize → {@link HOST_PRINCIPAL}
108
+ * - token mode → token principal from the Authorization header (LOCAL if absent)
109
+ */
110
+ export function identifyPrincipal(headers: HeaderBag | undefined, opts: AuthOptions): Principal {
111
+ if (!isAuthEnabled(opts)) return LOCAL_PRINCIPAL;
112
+ if (opts.authorize) return HOST_PRINCIPAL;
113
+ if (!headers) return LOCAL_PRINCIPAL;
114
+ const token = bearerFromHeaders(headers);
115
+ return token ? { id: tokenPrincipalId(token), kind: 'token' } : LOCAL_PRINCIPAL;
116
+ }