@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/src/mcp.ts CHANGED
@@ -26,6 +26,8 @@ import {
26
26
  } from '@harness-fe/protocol';
27
27
  import type { IBridge } from './bridge.js';
28
28
  import type { Bridge } from './bridge.js';
29
+ import type { AuthOptions } from './auth.js';
30
+ import { identifyPrincipal } from './identity.js';
29
31
  import { RemoteBridge } from './remoteBridge.js';
30
32
  import type { IStore, IMemoryStore } from './store/index.js';
31
33
  import { buildVisitorTimeline } from './visitorTimeline.js';
@@ -45,6 +47,12 @@ export interface McpServerOptions {
45
47
  * var is set to a non-empty value at server-construction time.
46
48
  */
47
49
  experimentalEnvVar?: string;
50
+ /**
51
+ * Daemon auth options, used to identify the per-call principal from MCP
52
+ * request headers (4.0 · P4). Omit for stdio / no-auth — calls resolve to
53
+ * the local principal.
54
+ */
55
+ auth?: AuthOptions;
48
56
  }
49
57
 
50
58
  /**
@@ -81,7 +89,7 @@ export function createMcpServer(bridge: IBridge, options: McpServerOptions = {})
81
89
  version: PROTOCOL_VERSION,
82
90
  });
83
91
 
84
- registerTools(server, bridge);
92
+ registerTools(server, bridge, options.auth);
85
93
 
86
94
  // Experimental tools are on by default. They only get gated when the host
87
95
  // supplies an env-var name to key off; see experimentalEnabled().
@@ -134,7 +142,7 @@ function err(message: string): {
134
142
  }
135
143
 
136
144
 
137
- function registerTools(server: McpServer, bridge: IBridge): void {
145
+ function registerTools(server: McpServer, bridge: IBridge, auth?: AuthOptions): void {
138
146
  server.registerTool(
139
147
  COMMAND.PAGE_CLICK,
140
148
  {
@@ -729,108 +737,104 @@ function registerTools(server: McpServer, bridge: IBridge): void {
729
737
  );
730
738
 
731
739
  // ─── tasks.* tools (user annotations submitted from page) ─────────────
732
- // TODO: temporarily hidden — re-enable when the report feature is ready.
733
- // To re-enable: remove the `if (false)` wrapper below and restore the block.
734
740
 
735
- /* eslint-disable no-constant-condition */
736
- if (false) {
737
- const taskStatusEnum = z.enum(['pending', 'claimed', 'resolved', 'all']);
741
+ const taskStatusEnum = z.enum(['pending', 'claimed', 'resolved', 'all']);
738
742
 
739
- server.registerTool(
740
- COMMAND.TASKS_PENDING,
741
- {
742
- description:
743
- 'List user-submitted annotation tasks. Default `status="pending"`. Returns id/question/selector/url — call tasks.claim to fetch full element payload.',
744
- inputSchema: {
745
- status: taskStatusEnum.optional(),
746
- limit: z.number().int().positive().optional(),
747
- },
748
- },
749
- async ({ status, limit }) => {
750
- const tasks = await bridge.listTasks({ status: status ?? 'pending', limit });
751
- const summary = tasks.map((t) => ({
752
- id: t.id,
753
- status: t.status,
754
- question: t.question,
755
- selector: t.selector,
756
- url: t.url,
757
- tabId: t.tabId,
758
- createdAt: t.createdAt,
759
- claimedAt: t.claimedAt,
760
- resolvedAt: t.resolvedAt,
761
- note: t.note,
762
- }));
763
- return ok({ count: summary.length, tasks: summary });
743
+ server.registerTool(
744
+ COMMAND.TASKS_PENDING,
745
+ {
746
+ description:
747
+ 'List user-submitted annotation tasks. Default `status="pending"`. Returns id/question/selector/url — call tasks.claim to fetch full element payload.',
748
+ inputSchema: {
749
+ status: taskStatusEnum.optional(),
750
+ limit: z.number().int().positive().optional(),
764
751
  },
765
- );
752
+ },
753
+ async ({ status, limit }) => {
754
+ const tasks = await bridge.listTasks({ status: status ?? 'pending', limit });
755
+ const summary = tasks.map((t) => ({
756
+ id: t.id,
757
+ status: t.status,
758
+ question: t.question,
759
+ selector: t.selector,
760
+ url: t.url,
761
+ tabId: t.tabId,
762
+ createdAt: t.createdAt,
763
+ claimedAt: t.claimedAt,
764
+ resolvedAt: t.resolvedAt,
765
+ note: t.note,
766
+ }));
767
+ return ok({ count: summary.length, tasks: summary });
768
+ },
769
+ );
766
770
 
767
- server.registerTool(
768
- COMMAND.TASKS_CLAIM,
769
- {
770
- description:
771
- 'Claim a task by id. Marks it claimed, returns full payload (selector + element outerHTML + rect).',
772
- inputSchema: {
773
- taskId: z.string(),
774
- },
775
- },
776
- async ({ taskId }) => {
777
- const task = await bridge.claimTask(taskId);
778
- if (!task) {
779
- throw new Error(`tasks.claim: no task with id "${taskId}"`);
780
- }
781
- return ok(task);
771
+ server.registerTool(
772
+ COMMAND.TASKS_CLAIM,
773
+ {
774
+ description:
775
+ 'Claim a task by id. Marks it claimed, returns full payload (selector + element outerHTML + rect).',
776
+ inputSchema: {
777
+ taskId: z.string(),
782
778
  },
783
- );
779
+ },
780
+ async ({ taskId }, extra) => {
781
+ const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
782
+ const task = await bridge.claimTask(taskId, principal);
783
+ if (!task) {
784
+ throw new Error(`tasks.claim: no task with id "${taskId}"`);
785
+ }
786
+ return ok(task);
787
+ },
788
+ );
784
789
 
785
- server.registerTool(
786
- COMMAND.TASKS_RESOLVE,
787
- {
788
- description:
789
- 'Mark a task as resolved with an optional note. Use after addressing the user request.',
790
- inputSchema: {
791
- taskId: z.string(),
792
- note: z.string().optional(),
793
- },
794
- },
795
- async ({ taskId, note }) => {
796
- const task = await bridge.resolveTask(taskId, note);
797
- if (!task) {
798
- throw new Error(`tasks.resolve: no task with id "${taskId}"`);
799
- }
800
- return ok({ ok: true, task });
790
+ server.registerTool(
791
+ COMMAND.TASKS_RESOLVE,
792
+ {
793
+ description:
794
+ 'Mark a task as resolved with an optional note. Use after addressing the user request.',
795
+ inputSchema: {
796
+ taskId: z.string(),
797
+ note: z.string().optional(),
801
798
  },
802
- );
799
+ },
800
+ async ({ taskId, note }, extra) => {
801
+ const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
802
+ const task = await bridge.resolveTask(taskId, note, principal);
803
+ if (!task) {
804
+ throw new Error(`tasks.resolve: no task with id "${taskId}"`);
805
+ }
806
+ return ok({ ok: true, task });
807
+ },
808
+ );
803
809
 
804
- server.registerTool(
805
- 'tasks.get_attachment',
806
- {
807
- description:
808
- 'Return a task screenshot attachment as a vision-ready image block. ' +
809
- 'Call after tasks.claim when the task summary includes an attachment pointer. ' +
810
- 'Compatible with Claude vision and GPT-4V.',
811
- inputSchema: {
812
- taskId: z.string().describe('Task id (from tasks.pending or tasks.claim).'),
813
- attachmentId: z.string().describe('Attachment id (from task.attachments[].id).'),
814
- },
815
- },
816
- async ({ taskId, attachmentId }) => {
817
- const base64 = await bridge.getTaskAttachmentData(taskId, attachmentId);
818
- if (!base64) {
819
- throw new Error(`tasks.get_attachment: attachment not found (taskId=${taskId}, attachmentId=${attachmentId})`);
820
- }
821
- return {
822
- content: [
823
- {
824
- type: 'image' as const,
825
- mimeType: 'image/png' as const,
826
- data: base64,
827
- },
828
- ],
829
- };
810
+ server.registerTool(
811
+ 'tasks.get_attachment',
812
+ {
813
+ description:
814
+ 'Return a task screenshot attachment as a vision-ready image block. ' +
815
+ 'Call after tasks.claim when the task summary includes an attachment pointer. ' +
816
+ 'Compatible with Claude vision and GPT-4V.',
817
+ inputSchema: {
818
+ taskId: z.string().describe('Task id (from tasks.pending or tasks.claim).'),
819
+ attachmentId: z.string().describe('Attachment id (from task.attachments[].id).'),
830
820
  },
831
- );
832
- }
833
- /* eslint-enable no-constant-condition */
821
+ },
822
+ async ({ taskId, attachmentId }) => {
823
+ const base64 = await bridge.getTaskAttachmentData(taskId, attachmentId);
824
+ if (!base64) {
825
+ throw new Error(`tasks.get_attachment: attachment not found (taskId=${taskId}, attachmentId=${attachmentId})`);
826
+ }
827
+ return {
828
+ content: [
829
+ {
830
+ type: 'image' as const,
831
+ mimeType: 'image/png' as const,
832
+ data: base64,
833
+ },
834
+ ],
835
+ };
836
+ },
837
+ );
834
838
  }
835
839
 
836
840
  // ─── Experimental tools (gated by HARNESS_FE_EXPERIMENTAL) ────────────────────
package/src/mcpHttp.ts CHANGED
@@ -61,7 +61,13 @@ export async function startMcpHttpServer(
61
61
  ? undefined
62
62
  : opts.eventStore ?? new MemoryEventStore();
63
63
 
64
- const server = createMcpServer(bridge, { experimentalEnvVar: opts.experimentalEnvVar });
64
+ // Pass the daemon's auth so MCP tools can identify the per-call principal
65
+ // from request headers (4.0 · P4). stdio (startMcpStdioServer) omits this,
66
+ // so stdio calls resolve to the local principal.
67
+ const server = createMcpServer(bridge, {
68
+ experimentalEnvVar: opts.experimentalEnvVar,
69
+ auth: (bridge as Bridge).getAuthOptions(),
70
+ });
65
71
  const transport = new StreamableHTTPServerTransport({
66
72
  sessionIdGenerator: stateful ? () => randomUUID() : undefined,
67
73
  eventStore,
@@ -12,11 +12,14 @@ import type {
12
12
  PeerRole,
13
13
  TabInfo,
14
14
  } from '@harness-fe/protocol';
15
+ import type { Principal } from './identity.js';
15
16
 
16
17
  export interface PeerSession {
17
18
  role: PeerRole;
18
19
  projectId: string;
19
20
  tabId?: string;
21
+ /** Caller identity behind this connection (4.0 · P1). Defaults to `local`. */
22
+ principal?: Principal;
20
23
  /** Runtime-client only: identifies the page load (sessionId) this connection belongs to. */
21
24
  sessionId?: string;
22
25
  /** Runtime-client only: stable per-browser visitor identifier. */
@@ -483,6 +483,8 @@ export class JsonlStore implements IStore {
483
483
  ...existing,
484
484
  ...meta,
485
485
  id: sessionId,
486
+ // Write-once: first principal to open the session owns it.
487
+ createdBy: existing?.createdBy ?? meta.createdBy,
486
488
  };
487
489
  // Reset participants — we'll rebuild via dedup loop below
488
490
  merged.participants = [];
@@ -650,6 +652,8 @@ export class JsonlStore implements IStore {
650
652
  id: projectId,
651
653
  createdAt: existing?.createdAt ?? Date.now(),
652
654
  lastActiveAt: Date.now(),
655
+ // Write-once: the first principal to create the project owns it.
656
+ createdBy: existing?.createdBy ?? patch.createdBy,
653
657
  };
654
658
  enforceExtensionBudget(merged, `project ${projectId}`);
655
659
  writeJson(metaPath, merged);
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Caller-identity tagging (4.0 · P1): `createdBy` is write-once on project /
3
+ * session metadata — the first principal to create the record owns it, and
4
+ * later upserts (which never carry an identity in normal flow) must not
5
+ * silently re-attribute ownership.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
8
+ import { mkdtempSync, rmSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { randomUUID } from 'node:crypto';
12
+ import { JsonlStore } from './JsonlStore.js';
13
+
14
+ describe('store: createdBy tagging (P1)', () => {
15
+ let dir: string;
16
+ let store: JsonlStore;
17
+
18
+ beforeEach(() => {
19
+ dir = mkdtempSync(join(tmpdir(), 'harness-identity-test-'));
20
+ store = new JsonlStore(dir);
21
+ });
22
+ afterEach(() => {
23
+ store.close();
24
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
25
+ });
26
+
27
+ it('project: records createdBy on creation', () => {
28
+ const p = store.upsertProject('proj-a', { createdBy: 'token:abc' });
29
+ expect(p.createdBy).toBe('token:abc');
30
+ expect(store.getProject('proj-a')?.createdBy).toBe('token:abc');
31
+ });
32
+
33
+ it('project: createdBy is write-once (later upsert without it is preserved)', () => {
34
+ store.upsertProject('proj-a', { createdBy: 'local' });
35
+ const after = store.upsertProject('proj-a', { displayName: 'renamed' });
36
+ expect(after.createdBy).toBe('local');
37
+ expect(after.displayName).toBe('renamed');
38
+ });
39
+
40
+ it('project: a later upsert cannot re-attribute ownership', () => {
41
+ store.upsertProject('proj-a', { createdBy: 'local' });
42
+ const after = store.upsertProject('proj-a', { createdBy: 'token:evil' });
43
+ expect(after.createdBy).toBe('local');
44
+ });
45
+
46
+ it('project: createdBy stays undefined when never supplied (back-compat)', () => {
47
+ const p = store.upsertProject('proj-legacy', { displayName: 'x' });
48
+ expect(p.createdBy).toBeUndefined();
49
+ });
50
+
51
+ it('session: records and locks createdBy', () => {
52
+ const sid = randomUUID();
53
+ store.upsertSession(sid, {
54
+ tabId: 'tab-1',
55
+ startedAt: Date.now(),
56
+ participants: [{ projectId: 'proj-a', joinedAt: Date.now() }],
57
+ createdBy: 'local',
58
+ });
59
+ store.upsertSession(sid, {
60
+ tabId: 'tab-1',
61
+ startedAt: Date.now(),
62
+ participants: [{ projectId: 'proj-a', joinedAt: Date.now() }],
63
+ createdBy: 'token:other',
64
+ });
65
+ expect(store.getSession(sid)?.createdBy).toBe('local');
66
+ });
67
+ });
@@ -104,6 +104,12 @@ export interface ProjectMeta {
104
104
  parentProjectId?: string;
105
105
  displayName?: string;
106
106
  tags?: string[];
107
+ /**
108
+ * Caller-identity tag (4.0 · P1): principal id that first created this
109
+ * project. Write-once (locked on creation like `createdAt`); informational
110
+ * in P1, the basis for `project → agent` routing/isolation in P3.
111
+ */
112
+ createdBy?: string;
107
113
  metadata?: Record<string, unknown>;
108
114
  }
109
115
 
@@ -173,6 +179,11 @@ export interface SessionMeta {
173
179
  storageKeys?: { local?: number; session?: number; cookie?: number };
174
180
  storageTruncated?: boolean;
175
181
  };
182
+ /**
183
+ * Caller-identity tag (4.0 · P1): principal id of the connection that
184
+ * opened this session. Write-once. Informational in P1.
185
+ */
186
+ createdBy?: string;
176
187
  metadata?: Record<string, unknown>;
177
188
  }
178
189