@harness-fe/mcp-server 4.0.0-next.1 → 4.0.0-next.2

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.
@@ -71,4 +71,20 @@ type HeaderBag = Record<string, string | string[] | undefined>;
71
71
  * - token mode → token principal from the Authorization header (LOCAL if absent)
72
72
  */
73
73
  export declare function identifyPrincipal(headers: HeaderBag | undefined, opts: AuthOptions): Principal;
74
+ /**
75
+ * Tenant-isolation visibility check (4.0 · P3). Decides whether `principal`
76
+ * may see a record tagged with `createdBy`.
77
+ *
78
+ * - `local` (loopback / stdio solo / no-auth) → sees everything. This keeps
79
+ * solo dev's behaviour completely unchanged.
80
+ * - unowned data (`createdBy` null/undefined — legacy rows from before P1, or
81
+ * records the daemon never tagged) → visible to everyone (backward compat).
82
+ * - otherwise → visible only to the principal that created it.
83
+ *
84
+ * Note: in the current single-token / loopback reality the data creator
85
+ * (plugin / runtime client) and the querying agent share one principal, so
86
+ * this is exact. A full `project → agent` binding (creator ≠ consumer, once
87
+ * P6 splits write/read scopes) is deferred to P6.
88
+ */
89
+ export declare function canSee(principal: Principal, createdBy: string | null | undefined): boolean;
74
90
  export {};
package/dist/identity.js CHANGED
@@ -99,3 +99,25 @@ export function identifyPrincipal(headers, opts) {
99
99
  const token = bearerFromHeaders(headers);
100
100
  return token ? { id: tokenPrincipalId(token), kind: 'token' } : LOCAL_PRINCIPAL;
101
101
  }
102
+ /**
103
+ * Tenant-isolation visibility check (4.0 · P3). Decides whether `principal`
104
+ * may see a record tagged with `createdBy`.
105
+ *
106
+ * - `local` (loopback / stdio solo / no-auth) → sees everything. This keeps
107
+ * solo dev's behaviour completely unchanged.
108
+ * - unowned data (`createdBy` null/undefined — legacy rows from before P1, or
109
+ * records the daemon never tagged) → visible to everyone (backward compat).
110
+ * - otherwise → visible only to the principal that created it.
111
+ *
112
+ * Note: in the current single-token / loopback reality the data creator
113
+ * (plugin / runtime client) and the querying agent share one principal, so
114
+ * this is exact. A full `project → agent` binding (creator ≠ consumer, once
115
+ * P6 splits write/read scopes) is deferred to P6.
116
+ */
117
+ export function canSee(principal, createdBy) {
118
+ if (principal.kind === 'local')
119
+ return true;
120
+ if (createdBy == null)
121
+ return true;
122
+ return createdBy === principal.id;
123
+ }
package/dist/mcp.js CHANGED
@@ -9,7 +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
+ import { canSee, identifyPrincipal } from './identity.js';
13
13
  import { RemoteBridge } from './remoteBridge.js';
14
14
  import { buildVisitorTimeline } from './visitorTimeline.js';
15
15
  import { createReplayExport } from './replayCreate.js';
@@ -59,7 +59,7 @@ export function createMcpServer(bridge, options = {}) {
59
59
  const leaderStore = bridge.store;
60
60
  if (leaderStore != null) {
61
61
  const memoryStore = bridge.getMemoryStore();
62
- registerStoreTools(server, leaderStore, memoryStore, bridge);
62
+ registerStoreTools(server, leaderStore, memoryStore, bridge, options.auth);
63
63
  }
64
64
  else if (bridge instanceof RemoteBridge) {
65
65
  registerRemoteStoreTools(server, bridge);
@@ -460,8 +460,10 @@ function registerTools(server, bridge, auth) {
460
460
  status: taskStatusEnum.optional(),
461
461
  limit: z.number().int().positive().optional(),
462
462
  },
463
- }, async ({ status, limit }) => {
464
- const tasks = await bridge.listTasks({ status: status ?? 'pending', limit });
463
+ }, async ({ status, limit }, extra) => {
464
+ const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
465
+ const tasks = (await bridge.listTasks({ status: status ?? 'pending', limit }))
466
+ .filter((t) => canSee(principal, t.createdBy));
465
467
  const summary = tasks.map((t) => ({
466
468
  id: t.id,
467
469
  status: t.status,
@@ -547,15 +549,18 @@ function registerExperimentalTools(server, bridge) {
547
549
  void bridge;
548
550
  }
549
551
  // ─── Store tools (session history, timeline, memory) ──────────────────────────
550
- function registerStoreTools(server, store, memoryStore, bridge) {
552
+ function registerStoreTools(server, store, memoryStore, bridge, auth) {
551
553
  server.registerTool('session.list', {
552
554
  description: 'List recent sessions for a project. Returns session IDs, start times, and status.',
553
555
  inputSchema: {
554
556
  projectId: z.string().describe('Project ID (package.json name)'),
555
557
  limit: z.number().int().positive().default(10).optional(),
556
558
  },
557
- }, async ({ projectId, limit }) => {
558
- const sessions = store.listSessions({ projectId, limit: limit ?? 10 });
559
+ }, async ({ projectId, limit }, extra) => {
560
+ const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
561
+ const sessions = store
562
+ .listSessions({ projectId, limit: limit ?? 10 })
563
+ .filter((s) => canSee(principal, s.createdBy));
559
564
  return ok(sessions);
560
565
  });
561
566
  server.registerTool('session.summary', {
@@ -624,11 +629,14 @@ function registerStoreTools(server, store, memoryStore, bridge) {
624
629
  server.registerTool('project.sessions', {
625
630
  description: 'List all projects with their most recent session info.',
626
631
  inputSchema: {},
627
- }, async () => {
628
- const projects = store.listProjects();
632
+ }, async (_args, extra) => {
633
+ const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
634
+ const projects = store.listProjects().filter((p) => canSee(principal, p.createdBy));
629
635
  const result = projects.map((p) => ({
630
636
  ...p,
631
- recentSessions: store.listSessions({ projectId: p.id, limit: 3 }),
637
+ recentSessions: store
638
+ .listSessions({ projectId: p.id, limit: 3 })
639
+ .filter((s) => canSee(principal, s.createdBy)),
632
640
  }));
633
641
  return ok(result);
634
642
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-fe/mcp-server",
3
- "version": "4.0.0-next.1",
3
+ "version": "4.0.0-next.2",
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",
@@ -3,6 +3,7 @@ import type { IncomingMessage } from 'node:http';
3
3
  import {
4
4
  HOST_PRINCIPAL,
5
5
  LOCAL_PRINCIPAL,
6
+ canSee,
6
7
  identifyPrincipal,
7
8
  resolvePrincipal,
8
9
  tokenPrincipalId,
@@ -84,3 +85,25 @@ describe('identity: identifyPrincipal (P4 — identify, not authorize)', () => {
84
85
  expect(identifyPrincipal({ authorization: 'Bearer x' }, { authorize: () => true })).toBe(HOST_PRINCIPAL);
85
86
  });
86
87
  });
88
+
89
+ describe('identity: canSee (P3 tenant visibility)', () => {
90
+ const tokenA = { id: 'token:aaa', kind: 'token' as const };
91
+ const tokenB = { id: 'token:bbb', kind: 'token' as const };
92
+
93
+ it('local sees everything (zero behaviour change for solo dev)', () => {
94
+ expect(canSee(LOCAL_PRINCIPAL, 'token:aaa')).toBe(true);
95
+ expect(canSee(LOCAL_PRINCIPAL, undefined)).toBe(true);
96
+ expect(canSee(LOCAL_PRINCIPAL, null)).toBe(true);
97
+ });
98
+
99
+ it('unowned data (no createdBy) is visible to everyone', () => {
100
+ expect(canSee(tokenA, undefined)).toBe(true);
101
+ expect(canSee(tokenA, null)).toBe(true);
102
+ });
103
+
104
+ it('named principal sees only its own owned data', () => {
105
+ expect(canSee(tokenA, 'token:aaa')).toBe(true);
106
+ expect(canSee(tokenA, 'token:bbb')).toBe(false);
107
+ expect(canSee(tokenB, 'token:aaa')).toBe(false);
108
+ });
109
+ });
package/src/identity.ts CHANGED
@@ -114,3 +114,24 @@ export function identifyPrincipal(headers: HeaderBag | undefined, opts: AuthOpti
114
114
  const token = bearerFromHeaders(headers);
115
115
  return token ? { id: tokenPrincipalId(token), kind: 'token' } : LOCAL_PRINCIPAL;
116
116
  }
117
+
118
+ /**
119
+ * Tenant-isolation visibility check (4.0 · P3). Decides whether `principal`
120
+ * may see a record tagged with `createdBy`.
121
+ *
122
+ * - `local` (loopback / stdio solo / no-auth) → sees everything. This keeps
123
+ * solo dev's behaviour completely unchanged.
124
+ * - unowned data (`createdBy` null/undefined — legacy rows from before P1, or
125
+ * records the daemon never tagged) → visible to everyone (backward compat).
126
+ * - otherwise → visible only to the principal that created it.
127
+ *
128
+ * Note: in the current single-token / loopback reality the data creator
129
+ * (plugin / runtime client) and the querying agent share one principal, so
130
+ * this is exact. A full `project → agent` binding (creator ≠ consumer, once
131
+ * P6 splits write/read scopes) is deferred to P6.
132
+ */
133
+ export function canSee(principal: Principal, createdBy: string | null | undefined): boolean {
134
+ if (principal.kind === 'local') return true;
135
+ if (createdBy == null) return true;
136
+ return createdBy === principal.id;
137
+ }
package/src/mcp.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  import type { IBridge } from './bridge.js';
28
28
  import type { Bridge } from './bridge.js';
29
29
  import type { AuthOptions } from './auth.js';
30
- import { identifyPrincipal } from './identity.js';
30
+ import { canSee, identifyPrincipal } from './identity.js';
31
31
  import { RemoteBridge } from './remoteBridge.js';
32
32
  import type { IStore, IMemoryStore } from './store/index.js';
33
33
  import { buildVisitorTimeline } from './visitorTimeline.js';
@@ -102,7 +102,7 @@ export function createMcpServer(bridge: IBridge, options: McpServerOptions = {})
102
102
  const leaderStore = (bridge as Bridge).store;
103
103
  if (leaderStore != null) {
104
104
  const memoryStore = bridge.getMemoryStore();
105
- registerStoreTools(server, leaderStore, memoryStore, bridge);
105
+ registerStoreTools(server, leaderStore, memoryStore, bridge, options.auth);
106
106
  } else if (bridge instanceof RemoteBridge) {
107
107
  registerRemoteStoreTools(server, bridge);
108
108
  }
@@ -750,8 +750,10 @@ function registerTools(server: McpServer, bridge: IBridge, auth?: AuthOptions):
750
750
  limit: z.number().int().positive().optional(),
751
751
  },
752
752
  },
753
- async ({ status, limit }) => {
754
- const tasks = await bridge.listTasks({ status: status ?? 'pending', limit });
753
+ async ({ status, limit }, extra) => {
754
+ const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
755
+ const tasks = (await bridge.listTasks({ status: status ?? 'pending', limit }))
756
+ .filter((t) => canSee(principal, t.createdBy));
755
757
  const summary = tasks.map((t) => ({
756
758
  id: t.id,
757
759
  status: t.status,
@@ -865,7 +867,7 @@ function registerExperimentalTools(server: McpServer, bridge: IBridge): void {
865
867
 
866
868
  // ─── Store tools (session history, timeline, memory) ──────────────────────────
867
869
 
868
- function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemoryStore, bridge: IBridge): void {
870
+ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemoryStore, bridge: IBridge, auth?: AuthOptions): void {
869
871
  server.registerTool(
870
872
  'session.list',
871
873
  {
@@ -875,8 +877,11 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
875
877
  limit: z.number().int().positive().default(10).optional(),
876
878
  },
877
879
  },
878
- async ({ projectId, limit }) => {
879
- const sessions = store.listSessions({ projectId, limit: limit ?? 10 });
880
+ async ({ projectId, limit }, extra) => {
881
+ const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
882
+ const sessions = store
883
+ .listSessions({ projectId, limit: limit ?? 10 })
884
+ .filter((s) => canSee(principal, s.createdBy));
880
885
  return ok(sessions);
881
886
  },
882
887
  );
@@ -963,11 +968,14 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
963
968
  description: 'List all projects with their most recent session info.',
964
969
  inputSchema: {},
965
970
  },
966
- async () => {
967
- const projects = store.listProjects();
971
+ async (_args, extra) => {
972
+ const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
973
+ const projects = store.listProjects().filter((p) => canSee(principal, p.createdBy));
968
974
  const result = projects.map((p) => ({
969
975
  ...p,
970
- recentSessions: store.listSessions({ projectId: p.id, limit: 3 }),
976
+ recentSessions: store
977
+ .listSessions({ projectId: p.id, limit: 3 })
978
+ .filter((s) => canSee(principal, s.createdBy)),
971
979
  }));
972
980
  return ok(result);
973
981
  },