@harness-fe/mcp-server 3.4.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/bridge.d.ts +26 -5
- package/dist/bridge.js +43 -5
- package/dist/daemon.d.ts +7 -0
- package/dist/daemon.js +1 -0
- package/dist/identity.d.ts +90 -0
- package/dist/identity.js +123 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.js +91 -86
- package/dist/mcpHttp.js +7 -1
- package/dist/sessionRouter.d.ts +3 -0
- package/dist/store/JsonlStore.js +4 -0
- package/dist/store/types.d.ts +11 -0
- package/package.json +2 -2
- package/src/bridge.ts +57 -7
- package/src/daemon.ts +9 -0
- package/src/identity.test.ts +109 -0
- package/src/identity.ts +137 -0
- package/src/mcp.ts +114 -102
- package/src/mcpHttp.ts +7 -1
- package/src/sessionRouter.ts +3 -0
- package/src/store/JsonlStore.ts +4 -0
- package/src/store/identityTagging.test.ts +67 -0
- package/src/store/types.ts +11 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { IncomingMessage } from 'node:http';
|
|
3
|
+
import {
|
|
4
|
+
HOST_PRINCIPAL,
|
|
5
|
+
LOCAL_PRINCIPAL,
|
|
6
|
+
canSee,
|
|
7
|
+
identifyPrincipal,
|
|
8
|
+
resolvePrincipal,
|
|
9
|
+
tokenPrincipalId,
|
|
10
|
+
} from './identity.js';
|
|
11
|
+
|
|
12
|
+
function fakeReq(init: { headers?: Record<string, string>; url?: string } = {}): IncomingMessage {
|
|
13
|
+
return {
|
|
14
|
+
headers: init.headers ?? {},
|
|
15
|
+
url: init.url ?? '/',
|
|
16
|
+
} as unknown as IncomingMessage;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('identity: tokenPrincipalId', () => {
|
|
20
|
+
it('is stable + prefixed + hashed (never the raw token)', () => {
|
|
21
|
+
const id = tokenPrincipalId('super-secret');
|
|
22
|
+
expect(id).toMatch(/^token:[0-9a-f]{12}$/);
|
|
23
|
+
expect(id).toBe(tokenPrincipalId('super-secret'));
|
|
24
|
+
expect(id).not.toContain('super-secret');
|
|
25
|
+
});
|
|
26
|
+
it('different tokens → different ids', () => {
|
|
27
|
+
expect(tokenPrincipalId('a')).not.toBe(tokenPrincipalId('b'));
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('identity: resolvePrincipal', () => {
|
|
32
|
+
it('loopback (auth disabled) → LOCAL_PRINCIPAL', () => {
|
|
33
|
+
expect(resolvePrincipal(fakeReq(), {})).toBe(LOCAL_PRINCIPAL);
|
|
34
|
+
expect(resolvePrincipal(fakeReq(), { token: '' })).toBe(LOCAL_PRINCIPAL);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('token mode: matching token → token principal', () => {
|
|
38
|
+
const req = fakeReq({ headers: { authorization: 'Bearer s3cr3t' } });
|
|
39
|
+
const p = resolvePrincipal(req, { token: 's3cr3t' });
|
|
40
|
+
expect(p).toEqual({ id: tokenPrincipalId('s3cr3t'), kind: 'token' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('token mode: missing/wrong token → null (mirrors deny)', () => {
|
|
44
|
+
expect(resolvePrincipal(fakeReq(), { token: 's3cr3t' })).toBeNull();
|
|
45
|
+
expect(
|
|
46
|
+
resolvePrincipal(fakeReq({ headers: { authorization: 'Bearer nope' } }), { token: 's3cr3t' }),
|
|
47
|
+
).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('custom authorize: accept → HOST_PRINCIPAL, reject → null', () => {
|
|
51
|
+
expect(resolvePrincipal(fakeReq(), { authorize: () => true })).toBe(HOST_PRINCIPAL);
|
|
52
|
+
expect(resolvePrincipal(fakeReq(), { authorize: () => false })).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('authorize wins over token (same precedence as isAuthorized)', () => {
|
|
56
|
+
const req = fakeReq({ headers: { authorization: 'Bearer wrong' } });
|
|
57
|
+
expect(resolvePrincipal(req, { token: 'right', authorize: () => true })).toBe(HOST_PRINCIPAL);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('identity: identifyPrincipal (P4 — identify, not authorize)', () => {
|
|
62
|
+
it('no auth → local', () => {
|
|
63
|
+
expect(identifyPrincipal({ authorization: 'Bearer x' }, {})).toBe(LOCAL_PRINCIPAL);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('stdio (no headers) → local even when a token is configured', () => {
|
|
67
|
+
expect(identifyPrincipal(undefined, { token: 's3cr3t' })).toBe(LOCAL_PRINCIPAL);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('token mode: names the caller from the Authorization header', () => {
|
|
71
|
+
const p = identifyPrincipal({ authorization: 'Bearer s3cr3t' }, { token: 's3cr3t' });
|
|
72
|
+
expect(p).toEqual({ id: tokenPrincipalId('s3cr3t'), kind: 'token' });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('token mode: handles array-valued headers', () => {
|
|
76
|
+
const p = identifyPrincipal({ authorization: ['Bearer s3cr3t'] }, { token: 's3cr3t' });
|
|
77
|
+
expect(p.id).toBe(tokenPrincipalId('s3cr3t'));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('token mode without a bearer header → local (already past auth wrapper)', () => {
|
|
81
|
+
expect(identifyPrincipal({}, { token: 's3cr3t' })).toBe(LOCAL_PRINCIPAL);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('authorize mode → host', () => {
|
|
85
|
+
expect(identifyPrincipal({ authorization: 'Bearer x' }, { authorize: () => true })).toBe(HOST_PRINCIPAL);
|
|
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
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
}
|
|
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
|
@@ -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 { canSee, 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().
|
|
@@ -94,7 +102,7 @@ export function createMcpServer(bridge: IBridge, options: McpServerOptions = {})
|
|
|
94
102
|
const leaderStore = (bridge as Bridge).store;
|
|
95
103
|
if (leaderStore != null) {
|
|
96
104
|
const memoryStore = bridge.getMemoryStore();
|
|
97
|
-
registerStoreTools(server, leaderStore, memoryStore, bridge);
|
|
105
|
+
registerStoreTools(server, leaderStore, memoryStore, bridge, options.auth);
|
|
98
106
|
} else if (bridge instanceof RemoteBridge) {
|
|
99
107
|
registerRemoteStoreTools(server, bridge);
|
|
100
108
|
}
|
|
@@ -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,106 @@ 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
|
-
|
|
736
|
-
if (false) {
|
|
737
|
-
const taskStatusEnum = z.enum(['pending', 'claimed', 'resolved', 'all']);
|
|
741
|
+
const taskStatusEnum = z.enum(['pending', 'claimed', 'resolved', 'all']);
|
|
738
742
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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 }, 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));
|
|
757
|
+
const summary = tasks.map((t) => ({
|
|
758
|
+
id: t.id,
|
|
759
|
+
status: t.status,
|
|
760
|
+
question: t.question,
|
|
761
|
+
selector: t.selector,
|
|
762
|
+
url: t.url,
|
|
763
|
+
tabId: t.tabId,
|
|
764
|
+
createdAt: t.createdAt,
|
|
765
|
+
claimedAt: t.claimedAt,
|
|
766
|
+
resolvedAt: t.resolvedAt,
|
|
767
|
+
note: t.note,
|
|
768
|
+
}));
|
|
769
|
+
return ok({ count: summary.length, tasks: summary });
|
|
770
|
+
},
|
|
771
|
+
);
|
|
766
772
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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);
|
|
773
|
+
server.registerTool(
|
|
774
|
+
COMMAND.TASKS_CLAIM,
|
|
775
|
+
{
|
|
776
|
+
description:
|
|
777
|
+
'Claim a task by id. Marks it claimed, returns full payload (selector + element outerHTML + rect).',
|
|
778
|
+
inputSchema: {
|
|
779
|
+
taskId: z.string(),
|
|
782
780
|
},
|
|
783
|
-
|
|
781
|
+
},
|
|
782
|
+
async ({ taskId }, extra) => {
|
|
783
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
784
|
+
const task = await bridge.claimTask(taskId, principal);
|
|
785
|
+
if (!task) {
|
|
786
|
+
throw new Error(`tasks.claim: no task with id "${taskId}"`);
|
|
787
|
+
}
|
|
788
|
+
return ok(task);
|
|
789
|
+
},
|
|
790
|
+
);
|
|
784
791
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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 });
|
|
792
|
+
server.registerTool(
|
|
793
|
+
COMMAND.TASKS_RESOLVE,
|
|
794
|
+
{
|
|
795
|
+
description:
|
|
796
|
+
'Mark a task as resolved with an optional note. Use after addressing the user request.',
|
|
797
|
+
inputSchema: {
|
|
798
|
+
taskId: z.string(),
|
|
799
|
+
note: z.string().optional(),
|
|
801
800
|
},
|
|
802
|
-
|
|
801
|
+
},
|
|
802
|
+
async ({ taskId, note }, extra) => {
|
|
803
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
804
|
+
const task = await bridge.resolveTask(taskId, note, principal);
|
|
805
|
+
if (!task) {
|
|
806
|
+
throw new Error(`tasks.resolve: no task with id "${taskId}"`);
|
|
807
|
+
}
|
|
808
|
+
return ok({ ok: true, task });
|
|
809
|
+
},
|
|
810
|
+
);
|
|
803
811
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
};
|
|
812
|
+
server.registerTool(
|
|
813
|
+
'tasks.get_attachment',
|
|
814
|
+
{
|
|
815
|
+
description:
|
|
816
|
+
'Return a task screenshot attachment as a vision-ready image block. ' +
|
|
817
|
+
'Call after tasks.claim when the task summary includes an attachment pointer. ' +
|
|
818
|
+
'Compatible with Claude vision and GPT-4V.',
|
|
819
|
+
inputSchema: {
|
|
820
|
+
taskId: z.string().describe('Task id (from tasks.pending or tasks.claim).'),
|
|
821
|
+
attachmentId: z.string().describe('Attachment id (from task.attachments[].id).'),
|
|
830
822
|
},
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
823
|
+
},
|
|
824
|
+
async ({ taskId, attachmentId }) => {
|
|
825
|
+
const base64 = await bridge.getTaskAttachmentData(taskId, attachmentId);
|
|
826
|
+
if (!base64) {
|
|
827
|
+
throw new Error(`tasks.get_attachment: attachment not found (taskId=${taskId}, attachmentId=${attachmentId})`);
|
|
828
|
+
}
|
|
829
|
+
return {
|
|
830
|
+
content: [
|
|
831
|
+
{
|
|
832
|
+
type: 'image' as const,
|
|
833
|
+
mimeType: 'image/png' as const,
|
|
834
|
+
data: base64,
|
|
835
|
+
},
|
|
836
|
+
],
|
|
837
|
+
};
|
|
838
|
+
},
|
|
839
|
+
);
|
|
834
840
|
}
|
|
835
841
|
|
|
836
842
|
// ─── Experimental tools (gated by HARNESS_FE_EXPERIMENTAL) ────────────────────
|
|
@@ -861,7 +867,7 @@ function registerExperimentalTools(server: McpServer, bridge: IBridge): void {
|
|
|
861
867
|
|
|
862
868
|
// ─── Store tools (session history, timeline, memory) ──────────────────────────
|
|
863
869
|
|
|
864
|
-
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 {
|
|
865
871
|
server.registerTool(
|
|
866
872
|
'session.list',
|
|
867
873
|
{
|
|
@@ -871,8 +877,11 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
|
|
|
871
877
|
limit: z.number().int().positive().default(10).optional(),
|
|
872
878
|
},
|
|
873
879
|
},
|
|
874
|
-
async ({ projectId, limit }) => {
|
|
875
|
-
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));
|
|
876
885
|
return ok(sessions);
|
|
877
886
|
},
|
|
878
887
|
);
|
|
@@ -959,11 +968,14 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
|
|
|
959
968
|
description: 'List all projects with their most recent session info.',
|
|
960
969
|
inputSchema: {},
|
|
961
970
|
},
|
|
962
|
-
async () => {
|
|
963
|
-
const
|
|
971
|
+
async (_args, extra) => {
|
|
972
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
973
|
+
const projects = store.listProjects().filter((p) => canSee(principal, p.createdBy));
|
|
964
974
|
const result = projects.map((p) => ({
|
|
965
975
|
...p,
|
|
966
|
-
recentSessions: store
|
|
976
|
+
recentSessions: store
|
|
977
|
+
.listSessions({ projectId: p.id, limit: 3 })
|
|
978
|
+
.filter((s) => canSee(principal, s.createdBy)),
|
|
967
979
|
}));
|
|
968
980
|
return ok(result);
|
|
969
981
|
},
|
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
|
-
|
|
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,
|
package/src/sessionRouter.ts
CHANGED
|
@@ -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. */
|
package/src/store/JsonlStore.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/store/types.ts
CHANGED
|
@@ -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
|
|