@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.
- package/dist/identity.d.ts +16 -0
- package/dist/identity.js +22 -0
- package/dist/mcp.js +18 -10
- package/package.json +1 -1
- package/src/identity.test.ts +23 -0
- package/src/identity.ts +21 -0
- package/src/mcp.ts +18 -10
package/dist/identity.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
package/src/identity.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
},
|