@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/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 +74 -0
- package/dist/identity.js +101 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.js +76 -79
- 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 +86 -0
- package/src/identity.ts +116 -0
- package/src/mcp.ts +99 -95
- 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
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
|
-
|
|
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 }) => {
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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 });
|
|
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
|
-
|
|
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
|
-
};
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|