@darwinium/portal-mcp 0.0.1 → 0.0.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/LICENSE +202 -0
- package/NOTICE +7 -0
- package/README.md +237 -7
- package/TESTING.md +478 -0
- package/dist/bin/bundlePaths-CSftU0OK.js +59 -0
- package/dist/bin/bundlePaths-CSftU0OK.js.map +1 -0
- package/dist/bin/doctor-D7BbIXN2.js +411 -0
- package/dist/bin/doctor-D7BbIXN2.js.map +1 -0
- package/dist/bin/install-BhzwhhI-.js +264 -0
- package/dist/bin/install-BhzwhhI-.js.map +1 -0
- package/dist/bin/killProcessOnPort-Bt5MA5Dv.js +136 -0
- package/dist/bin/killProcessOnPort-Bt5MA5Dv.js.map +1 -0
- package/dist/bin/oobPair-ybkkqrSh.js +250 -0
- package/dist/bin/oobPair-ybkkqrSh.js.map +1 -0
- package/dist/bin/portal-mcp.js +38 -0
- package/dist/bin/portal-mcp.js.map +1 -0
- package/dist/bin/rotate-Dzf8pwua.js +64 -0
- package/dist/bin/rotate-Dzf8pwua.js.map +1 -0
- package/dist/bin/server-Beyo9MT8.js +224 -0
- package/dist/bin/server-Beyo9MT8.js.map +1 -0
- package/dist/bin/server-CuNr9ZtJ.js +702 -0
- package/dist/bin/server-CuNr9ZtJ.js.map +1 -0
- package/dist/bin/store-B3e4WCLw.js +78 -0
- package/dist/bin/store-B3e4WCLw.js.map +1 -0
- package/dist/bin/term-Bg7icwx7.js +164 -0
- package/dist/bin/term-Bg7icwx7.js.map +1 -0
- package/docs/adr/ADR-001-static-mcp-tool-surface.md +100 -0
- package/extension-bundle/.gitkeep +0 -0
- package/extension-bundle/extension.zip +0 -0
- package/package.json +54 -12
- package/bin/portal-mcp.js +0 -19
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import { t as VERSION } from "./portal-mcp.js";
|
|
2
|
+
import { r as stopTakeoverPolling, t as createWsServer } from "./server-Beyo9MT8.js";
|
|
3
|
+
import { n as readToken } from "./store-B3e4WCLw.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { CallToolRequestSchema, InitializeRequestSchema, LATEST_PROTOCOL_VERSION, ListToolsRequestSchema, SUPPORTED_PROTOCOL_VERSIONS } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
|
|
10
|
+
//#region src/server/tools.ts
|
|
11
|
+
/**
|
|
12
|
+
* STATIC THREE-TOOL SURFACE (ADR-001).
|
|
13
|
+
*
|
|
14
|
+
* Adding a 4th tool requires:
|
|
15
|
+
* 1. Update ADR-001 with rationale.
|
|
16
|
+
* 2. Update smoke-test.ts assertion `assert.equal(tools.length, 3)` to the new count.
|
|
17
|
+
* 3. Justify in PR description why the dynamic surface (which lives in `get_page_commands` returns)
|
|
18
|
+
* cannot accommodate the new capability.
|
|
19
|
+
*
|
|
20
|
+
* Claude Desktop and Claude Code cache `tools/list` and do NOT honor `tools/list_changed`
|
|
21
|
+
* (claude-code#7519, #13646). Adding tools without considering host cache behavior is unsafe.
|
|
22
|
+
*
|
|
23
|
+
* ANNOTATIONS ARE MANDATORY. Anthropic's directory review requires every tool to carry
|
|
24
|
+
* a `title` plus the applicable `readOnlyHint` / `destructiveHint`; a tool without them
|
|
25
|
+
* is rejected. `openWorldHint` is false throughout: this server has zero backend
|
|
26
|
+
* connections and reaches nothing beyond the one browser tab it is paired with.
|
|
27
|
+
*/
|
|
28
|
+
const TOOLS = [
|
|
29
|
+
{
|
|
30
|
+
name: "get_page_commands",
|
|
31
|
+
annotations: {
|
|
32
|
+
title: "List portal page commands",
|
|
33
|
+
readOnlyHint: true,
|
|
34
|
+
idempotentHint: false,
|
|
35
|
+
openWorldHint: false
|
|
36
|
+
},
|
|
37
|
+
description: "Lists page commands available on the active Darwinium portal tab. Returns an array of { name, description, args, _pageId }. Call this FIRST in any session to discover what the active tab can do. The list is dynamic — it changes per page; always call this before invoking run_page_command.",
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: {}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "run_page_command",
|
|
45
|
+
annotations: {
|
|
46
|
+
title: "Run a portal page command",
|
|
47
|
+
readOnlyHint: false,
|
|
48
|
+
destructiveHint: true,
|
|
49
|
+
idempotentHint: false,
|
|
50
|
+
openWorldHint: false
|
|
51
|
+
},
|
|
52
|
+
description: "Invokes a named page command on the active Darwinium portal tab with the given arguments. If `expected_page_id` is supplied, the binary checks the live page id first and returns a navigation-error on mismatch so the caller can retry against the new page. Use the exact `name` returned by get_page_commands.",
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: {
|
|
56
|
+
name: {
|
|
57
|
+
type: "string",
|
|
58
|
+
description: "The page-command name as returned by get_page_commands."
|
|
59
|
+
},
|
|
60
|
+
args: {
|
|
61
|
+
type: "object",
|
|
62
|
+
description: "Arguments to pass to the command (object; shape per the command's args schema)."
|
|
63
|
+
},
|
|
64
|
+
expected_page_id: {
|
|
65
|
+
type: "string",
|
|
66
|
+
description: "Optional: reject if the live page id no longer matches this value."
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
required: ["name"]
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "get_context",
|
|
74
|
+
annotations: {
|
|
75
|
+
title: "Get portal instructions and page context",
|
|
76
|
+
readOnlyHint: true,
|
|
77
|
+
idempotentHint: false,
|
|
78
|
+
openWorldHint: false
|
|
79
|
+
},
|
|
80
|
+
description: "Returns combined static Darwinium instructions + current page Viper context for the active tab. Capped server-side at 50KB; if the payload would exceed the cap, lower-priority fields are dropped and a `_truncated` flag is set on the response.",
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: "object",
|
|
83
|
+
properties: {}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/bridge/errorMapper.ts
|
|
90
|
+
const MESSAGES = {
|
|
91
|
+
NO_TAB: () => "Not connected. Click Connect in the Darwinium extension popup.",
|
|
92
|
+
LOST_MID_CALL: () => "Lost connection to portal mid-call. Retry; the extension auto-reconnects within a few seconds.",
|
|
93
|
+
PAGE_NAVIGATED: (ctx) => `Page navigated. Current page is ${ctx?.newPageId ?? "<unknown>"}. Re-call get_page_commands and retry against the new page.`,
|
|
94
|
+
TOKEN_MISMATCH: () => "Extension token does not match the binary's token. Re-run install or rotate-token.",
|
|
95
|
+
TAB_STALE: () => "Extension was reloaded; refresh the connected portal tab to restore the bridge. Re-injection is being attempted automatically."
|
|
96
|
+
};
|
|
97
|
+
function mapError(mode, ctx) {
|
|
98
|
+
return {
|
|
99
|
+
content: [{
|
|
100
|
+
type: "text",
|
|
101
|
+
text: MESSAGES[mode](ctx)
|
|
102
|
+
}],
|
|
103
|
+
isError: true
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/bridge/Context.ts
|
|
109
|
+
var Bridge = class {
|
|
110
|
+
_ws;
|
|
111
|
+
pending = /* @__PURE__ */ new Map();
|
|
112
|
+
wsReadyResolvers = [];
|
|
113
|
+
/**
|
|
114
|
+
* Whether THIS process owns the 127.0.0.1:9224 listener.
|
|
115
|
+
*
|
|
116
|
+
* Only one portal-mcp can own the bridge, but several may run at once (two
|
|
117
|
+
* MCP hosts, or one host restarting a server before the old process exits).
|
|
118
|
+
* A non-owner still serves MCP over its own stdio — it just cannot reach the
|
|
119
|
+
* browser, and says so instead of offering a pairing token that would not
|
|
120
|
+
* help. Set false while another live instance holds the port; flipped back by
|
|
121
|
+
* the takeover poll in ws/server.ts when that instance exits.
|
|
122
|
+
*/
|
|
123
|
+
_ownsPort = true;
|
|
124
|
+
hasWs() {
|
|
125
|
+
return this._ws?.readyState === 1;
|
|
126
|
+
}
|
|
127
|
+
/** False when another live portal-mcp instance owns the WS port. */
|
|
128
|
+
ownsPort() {
|
|
129
|
+
return this._ownsPort;
|
|
130
|
+
}
|
|
131
|
+
setOwnsPort(owns) {
|
|
132
|
+
this._ownsPort = owns;
|
|
133
|
+
}
|
|
134
|
+
/** Wait up to timeoutMs for an extension WS to be attached. Resolves true on attach, false on timeout. */
|
|
135
|
+
waitForWs(timeoutMs) {
|
|
136
|
+
if (this.hasWs()) return Promise.resolve(true);
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
const resolveOnce = () => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
resolve(true);
|
|
141
|
+
};
|
|
142
|
+
const timer = setTimeout(() => {
|
|
143
|
+
this.wsReadyResolvers = this.wsReadyResolvers.filter((r) => r !== resolveOnce);
|
|
144
|
+
resolve(false);
|
|
145
|
+
}, timeoutMs);
|
|
146
|
+
this.wsReadyResolvers.push(resolveOnce);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
attach(ws) {
|
|
150
|
+
if (this._ws) try {
|
|
151
|
+
this._ws.close(1e3, "replaced");
|
|
152
|
+
} catch {}
|
|
153
|
+
this._ws = ws;
|
|
154
|
+
ws.on("message", (raw) => this.onMessage(raw));
|
|
155
|
+
ws.on("close", () => {
|
|
156
|
+
this.rejectPendingFor(ws, /* @__PURE__ */ new Error("LOST_MID_CALL"));
|
|
157
|
+
if (this._ws === ws) this._ws = void 0;
|
|
158
|
+
});
|
|
159
|
+
ws.on("error", () => {});
|
|
160
|
+
const resolvers = this.wsReadyResolvers;
|
|
161
|
+
this.wsReadyResolvers = [];
|
|
162
|
+
for (const r of resolvers) r();
|
|
163
|
+
}
|
|
164
|
+
send(op, args, timeoutMs = 3e4) {
|
|
165
|
+
if (!this.hasWs()) return Promise.reject(/* @__PURE__ */ new Error("NO_TAB"));
|
|
166
|
+
const owner = this._ws;
|
|
167
|
+
const id = randomUUID();
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const timer = setTimeout(() => {
|
|
170
|
+
this.pending.delete(id);
|
|
171
|
+
reject(/* @__PURE__ */ new Error("LOST_MID_CALL"));
|
|
172
|
+
}, timeoutMs);
|
|
173
|
+
this.pending.set(id, {
|
|
174
|
+
resolve,
|
|
175
|
+
reject,
|
|
176
|
+
timer,
|
|
177
|
+
ws: owner
|
|
178
|
+
});
|
|
179
|
+
try {
|
|
180
|
+
owner.send(JSON.stringify({
|
|
181
|
+
id,
|
|
182
|
+
type: "req",
|
|
183
|
+
op,
|
|
184
|
+
args
|
|
185
|
+
}));
|
|
186
|
+
} catch (err) {
|
|
187
|
+
const pending = this.pending.get(id);
|
|
188
|
+
if (pending) {
|
|
189
|
+
clearTimeout(pending.timer);
|
|
190
|
+
this.pending.delete(id);
|
|
191
|
+
}
|
|
192
|
+
reject(/* @__PURE__ */ new Error(`Bridge.send failed: ${err.message}`));
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/** Map an internal error mode to a user-facing structured tool response. */
|
|
197
|
+
errorResponse(mode, ctx) {
|
|
198
|
+
return mapError(mode, ctx);
|
|
199
|
+
}
|
|
200
|
+
onMessage(raw) {
|
|
201
|
+
try {
|
|
202
|
+
const msg = JSON.parse(typeof raw === "string" ? raw : raw.toString());
|
|
203
|
+
if (msg.type === "resp") {
|
|
204
|
+
const id = msg.id;
|
|
205
|
+
const p = this.pending.get(id);
|
|
206
|
+
if (p) {
|
|
207
|
+
clearTimeout(p.timer);
|
|
208
|
+
this.pending.delete(id);
|
|
209
|
+
if (msg.error) p.reject(new Error(String(msg.error)));
|
|
210
|
+
else p.resolve(msg.result);
|
|
211
|
+
}
|
|
212
|
+
} else if (msg.type === "pageIdChanged") {} else if (msg.type === "ping") try {
|
|
213
|
+
this._ws?.send(JSON.stringify({ type: "pong" }));
|
|
214
|
+
} catch {}
|
|
215
|
+
else if (msg.type === "pong") {}
|
|
216
|
+
} catch {}
|
|
217
|
+
}
|
|
218
|
+
rejectAllPending(err) {
|
|
219
|
+
for (const p of this.pending.values()) {
|
|
220
|
+
clearTimeout(p.timer);
|
|
221
|
+
p.reject(err);
|
|
222
|
+
}
|
|
223
|
+
this.pending.clear();
|
|
224
|
+
}
|
|
225
|
+
rejectPendingFor(owner, err) {
|
|
226
|
+
for (const [id, p] of this.pending.entries()) {
|
|
227
|
+
if (p.ws !== owner) continue;
|
|
228
|
+
clearTimeout(p.timer);
|
|
229
|
+
this.pending.delete(id);
|
|
230
|
+
p.reject(err);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region src/server/get-context.ts
|
|
237
|
+
/**
|
|
238
|
+
* 50KB cap + field-drop policy for `get_context` tool responses.
|
|
239
|
+
*
|
|
240
|
+
* The drop paths follow the actual `ConditionerResult` shape
|
|
241
|
+
* (darwinium-node-context/src/types.ts): `featuresExplained` is one key with
|
|
242
|
+
* `globalJourneyMetadata` / `journeyMetadata` sub-arrays.
|
|
243
|
+
*
|
|
244
|
+
* Drop order:
|
|
245
|
+
* featuresExplained.globalJourneyMetadata → featuresExplained.journeyMetadata →
|
|
246
|
+
* signals → contexts → stepNames
|
|
247
|
+
* Always retain: viper, page, route, _pageId, instructions.
|
|
248
|
+
* Last-resort: pop viper[] elements until under cap (preserves valid JSON).
|
|
249
|
+
* NEVER byte-truncate JSON.stringify output (would produce invalid JSON).
|
|
250
|
+
*/
|
|
251
|
+
const MAX_BYTES = 5e4;
|
|
252
|
+
const DROP_ORDER = [
|
|
253
|
+
["featuresExplained", "globalJourneyMetadata"],
|
|
254
|
+
["featuresExplained", "journeyMetadata"],
|
|
255
|
+
["signals"],
|
|
256
|
+
["contexts"],
|
|
257
|
+
["stepNames"]
|
|
258
|
+
];
|
|
259
|
+
function capContextResponse(input) {
|
|
260
|
+
const work = JSON.parse(JSON.stringify(input));
|
|
261
|
+
const truncatedFields = [];
|
|
262
|
+
let attempt = 0;
|
|
263
|
+
while (Buffer.byteLength(JSON.stringify(work), "utf8") > MAX_BYTES) {
|
|
264
|
+
if (attempt >= DROP_ORDER.length) {
|
|
265
|
+
const viper = work.viper;
|
|
266
|
+
if (Array.isArray(viper)) {
|
|
267
|
+
while (viper.length > 0 && Buffer.byteLength(JSON.stringify(work), "utf8") > MAX_BYTES) viper.pop();
|
|
268
|
+
truncatedFields.push("viper[]");
|
|
269
|
+
}
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
const path = DROP_ORDER[attempt];
|
|
273
|
+
deletePath(work, path);
|
|
274
|
+
truncatedFields.push(path.join("."));
|
|
275
|
+
attempt++;
|
|
276
|
+
}
|
|
277
|
+
if (truncatedFields.length === 0) return work;
|
|
278
|
+
return {
|
|
279
|
+
...work,
|
|
280
|
+
_truncated: true,
|
|
281
|
+
_truncatedFields: truncatedFields
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function deletePath(obj, path) {
|
|
285
|
+
let cur = obj;
|
|
286
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
287
|
+
if (cur == null || typeof cur !== "object") return;
|
|
288
|
+
cur = cur[path[i]];
|
|
289
|
+
}
|
|
290
|
+
if (cur && typeof cur === "object") delete cur[path[path.length - 1]];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
//#endregion
|
|
294
|
+
//#region src/server/pairingMessage.ts
|
|
295
|
+
/**
|
|
296
|
+
* Pairing-message builders — surface the per-machine pairing token to the LLM so
|
|
297
|
+
* the user can connect the extension by simply *asking Claude for the token*.
|
|
298
|
+
*
|
|
299
|
+
* The binary (`serve`) reads/generates the token at startup, but the interactive
|
|
300
|
+
* 6-digit OOB pairing flow runs ONLY during `install` / `rotate-token` — a plain
|
|
301
|
+
* Claude Desktop / Claude Code launch (which runs `serve`) has no other delivery
|
|
302
|
+
* path. So the token is embedded in the MCP `initialize.instructions` and in the
|
|
303
|
+
* "not connected" tool responses, where the host LLM can read it out of its own
|
|
304
|
+
* context and hand it to the user on request.
|
|
305
|
+
*
|
|
306
|
+
* Security note: the token is a LOCAL secret that only authorizes a WebSocket on
|
|
307
|
+
* 127.0.0.1:9224. It already lives in the binary's process and the user's own MCP
|
|
308
|
+
* session context — surfacing it to that same user's LLM does not widen the trust
|
|
309
|
+
* boundary. It must NOT be sent anywhere off-machine.
|
|
310
|
+
*/
|
|
311
|
+
/**
|
|
312
|
+
* Instruction block embedded in `initialize.instructions` when the extension is
|
|
313
|
+
* not yet connected. Written so the LLM both (a) understands portal tools are
|
|
314
|
+
* unavailable until pairing, and (b) can answer "what's my token / how do I
|
|
315
|
+
* connect?" directly from context without any tool call.
|
|
316
|
+
*/
|
|
317
|
+
function buildPairingInstructions(token) {
|
|
318
|
+
return [
|
|
319
|
+
"The Darwinium Portal browser extension is not connected to this MCP server yet.",
|
|
320
|
+
"Portal tools (get_page_commands, run_page_command, get_context) will not work until the user pairs the extension.",
|
|
321
|
+
"",
|
|
322
|
+
"To connect, the user should:",
|
|
323
|
+
" 1. Open their Darwinium portal in Chrome (a *.darwinium.com or *.int.darwinium.io tab).",
|
|
324
|
+
" 2. Click the \"Darwinium Portal MCP\" extension icon in the toolbar.",
|
|
325
|
+
" 3. Paste the pairing token below into the popup and click \"Save & Connect\".",
|
|
326
|
+
"",
|
|
327
|
+
`Pairing token for this machine: ${token}`,
|
|
328
|
+
"",
|
|
329
|
+
"When the user asks how to connect, how to pair, or what their pairing token is,",
|
|
330
|
+
"give them this token and these steps. The token is a local secret valid only on",
|
|
331
|
+
"127.0.0.1 — it is safe to show to this user, but must never be sent anywhere else."
|
|
332
|
+
].join("\n");
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Shared body for the "another instance owns the bridge" state.
|
|
336
|
+
*
|
|
337
|
+
* Distinct from the pairing state on purpose: the extension may be perfectly
|
|
338
|
+
* paired, but THIS process lost the race for 127.0.0.1:9224. Handing over a
|
|
339
|
+
* pairing token here would send the user round a loop that cannot succeed —
|
|
340
|
+
* the browser is already talking to the other instance.
|
|
341
|
+
*/
|
|
342
|
+
const PEER_INSTANCE_BODY = [
|
|
343
|
+
"Another copy of the Darwinium Portal bridge is already running on this machine",
|
|
344
|
+
"and owns the local connection to the browser (127.0.0.1:9224). Only one copy can",
|
|
345
|
+
"hold it at a time.",
|
|
346
|
+
"",
|
|
347
|
+
"Portal tools will not work in THIS session until that copy exits. To fix it:",
|
|
348
|
+
" - Use the portal tools from the Claude session that is already connected, OR",
|
|
349
|
+
" - Quit the other Claude client (Claude Desktop, or Claude Code in a terminal)",
|
|
350
|
+
" and this session will take over the bridge automatically within a few seconds.",
|
|
351
|
+
"",
|
|
352
|
+
"Do NOT tell the user to re-pair or paste a pairing token — the extension is not",
|
|
353
|
+
"the problem here, and pairing again will not resolve it."
|
|
354
|
+
];
|
|
355
|
+
/** `initialize.instructions` variant for the peer-owns-the-bridge state. */
|
|
356
|
+
function buildPeerInstanceInstructions() {
|
|
357
|
+
return [
|
|
358
|
+
"The Darwinium Portal MCP server started, but could not claim the browser bridge.",
|
|
359
|
+
"",
|
|
360
|
+
...PEER_INSTANCE_BODY
|
|
361
|
+
].join("\n");
|
|
362
|
+
}
|
|
363
|
+
/** Tool-result variant for the peer-owns-the-bridge state. */
|
|
364
|
+
function buildPeerInstanceMessage() {
|
|
365
|
+
return [
|
|
366
|
+
"The Darwinium Portal bridge is not available to this session.",
|
|
367
|
+
"",
|
|
368
|
+
...PEER_INSTANCE_BODY
|
|
369
|
+
].join("\n");
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Text returned in place of the NO_TAB error when a tool is called while the
|
|
373
|
+
* extension is not connected. Same token + steps as the instructions block, kept
|
|
374
|
+
* short for an inline tool result.
|
|
375
|
+
*/
|
|
376
|
+
function buildPairingMessage(token) {
|
|
377
|
+
return [
|
|
378
|
+
"The Darwinium Portal browser extension is not connected yet.",
|
|
379
|
+
"",
|
|
380
|
+
"To connect: open your Darwinium portal tab (*.darwinium.com or *.int.darwinium.io), click the",
|
|
381
|
+
"\"Darwinium Portal MCP\" extension icon, paste this pairing token, and click",
|
|
382
|
+
"\"Save & Connect\":",
|
|
383
|
+
"",
|
|
384
|
+
` ${token}`,
|
|
385
|
+
"",
|
|
386
|
+
"Then retry your request. (This token is a local pairing secret for 127.0.0.1 —",
|
|
387
|
+
"safe to share with the user, but not off-machine.)"
|
|
388
|
+
].join("\n");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
//#endregion
|
|
392
|
+
//#region src/server/bridgeCallHandler.ts
|
|
393
|
+
/**
|
|
394
|
+
* MCP `tools/call` handler for the static three-tool surface (ADR-001):
|
|
395
|
+
* get_page_commands, run_page_command, get_context. Tool-name strings MUST
|
|
396
|
+
* match tools.ts exactly. The binary keeps no page-id cache — expected_page_id
|
|
397
|
+
* is checked via a live listCommands probe (see run_page_command below).
|
|
398
|
+
*/
|
|
399
|
+
/**
|
|
400
|
+
* Zod arg schema for `run_page_command`. A malicious or buggy MCP host could
|
|
401
|
+
* send ill-typed `name`/`expected_page_id` values that mis-route the call or
|
|
402
|
+
* trigger stack-trace-eliciting code paths on the page side, so the schema
|
|
403
|
+
* validates the trust-boundary fields we control. Page-command payload
|
|
404
|
+
* (`args.args`) stays `unknown` — page-side commands have their own arg
|
|
405
|
+
* shapes; the binary's job is to forward, not to validate the entire matrix.
|
|
406
|
+
* `passthrough()` keeps unknown sibling fields rather than stripping them.
|
|
407
|
+
*/
|
|
408
|
+
const RunPageCommandArgsSchema = z.object({
|
|
409
|
+
name: z.string(),
|
|
410
|
+
args: z.unknown().optional(),
|
|
411
|
+
expected_page_id: z.string().optional()
|
|
412
|
+
}).passthrough();
|
|
413
|
+
/**
|
|
414
|
+
* Key a page command sets to return a picture rather than a description of one.
|
|
415
|
+
* Must match IMAGE_ENVELOPE_KEY in aphex-frontend's common/mcp/screenshot.ts.
|
|
416
|
+
*/
|
|
417
|
+
const IMAGE_ENVELOPE_KEY = "__image";
|
|
418
|
+
/** Base64 above this is refused: it would blow the host's response budget for no gain. */
|
|
419
|
+
const MAX_IMAGE_BASE64_BYTES = 4 * 1024 * 1024;
|
|
420
|
+
const readImageEnvelope = (result) => {
|
|
421
|
+
if (!result || typeof result !== "object") return null;
|
|
422
|
+
const envelope = result[IMAGE_ENVELOPE_KEY];
|
|
423
|
+
if (!envelope || typeof envelope !== "object") return null;
|
|
424
|
+
const { mimeType, base64 } = envelope;
|
|
425
|
+
if (typeof mimeType !== "string" || !mimeType.startsWith("image/")) return null;
|
|
426
|
+
if (typeof base64 !== "string" || base64 === "") return null;
|
|
427
|
+
return {
|
|
428
|
+
mimeType,
|
|
429
|
+
base64
|
|
430
|
+
};
|
|
431
|
+
};
|
|
432
|
+
/**
|
|
433
|
+
* Splits an image-carrying result into an MCP image block plus the remaining
|
|
434
|
+
* fields as text. The base64 is never repeated in the text half — that is the
|
|
435
|
+
* whole point: as JSON it is unreadable bulk, as an image block it is something
|
|
436
|
+
* the model can look at.
|
|
437
|
+
*/
|
|
438
|
+
const buildImageResponse = (result, image) => {
|
|
439
|
+
const { [IMAGE_ENVELOPE_KEY]: envelope, ...rest } = result;
|
|
440
|
+
const { base64: _omitted, ...meta } = envelope ?? {};
|
|
441
|
+
if (image.base64.length > MAX_IMAGE_BASE64_BYTES) return {
|
|
442
|
+
content: [{
|
|
443
|
+
type: "text",
|
|
444
|
+
text: JSON.stringify({
|
|
445
|
+
...rest,
|
|
446
|
+
...meta,
|
|
447
|
+
error: `the capture is ${Math.round(image.base64.length / 1024)}KB, above the ${MAX_IMAGE_BASE64_BYTES / 1024 / 1024}MB limit`,
|
|
448
|
+
hint: "Re-run with a smaller maxWidth, format \"jpeg\", or a cardTitle to capture one card instead of the board."
|
|
449
|
+
}, null, 2)
|
|
450
|
+
}],
|
|
451
|
+
isError: true
|
|
452
|
+
};
|
|
453
|
+
return { content: [{
|
|
454
|
+
type: "text",
|
|
455
|
+
text: JSON.stringify({
|
|
456
|
+
...rest,
|
|
457
|
+
...meta
|
|
458
|
+
}, null, 2)
|
|
459
|
+
}, {
|
|
460
|
+
type: "image",
|
|
461
|
+
data: image.base64,
|
|
462
|
+
mimeType: image.mimeType
|
|
463
|
+
}] };
|
|
464
|
+
};
|
|
465
|
+
function makeBridgeCallHandler(bridge, token) {
|
|
466
|
+
return async (req) => {
|
|
467
|
+
const { name, arguments: rawArgs } = req.params;
|
|
468
|
+
const args = rawArgs ?? {};
|
|
469
|
+
if (!bridge.ownsPort()) return {
|
|
470
|
+
content: [{
|
|
471
|
+
type: "text",
|
|
472
|
+
text: buildPeerInstanceMessage()
|
|
473
|
+
}],
|
|
474
|
+
isError: true
|
|
475
|
+
};
|
|
476
|
+
if (!bridge.hasWs()) return {
|
|
477
|
+
content: [{
|
|
478
|
+
type: "text",
|
|
479
|
+
text: buildPairingMessage(token)
|
|
480
|
+
}],
|
|
481
|
+
isError: true
|
|
482
|
+
};
|
|
483
|
+
try {
|
|
484
|
+
switch (name) {
|
|
485
|
+
case "get_page_commands": {
|
|
486
|
+
const result = await bridge.send("listCommands", {}, 1e4);
|
|
487
|
+
return { content: [{
|
|
488
|
+
type: "text",
|
|
489
|
+
text: JSON.stringify(result, null, 2)
|
|
490
|
+
}] };
|
|
491
|
+
}
|
|
492
|
+
case "run_page_command": {
|
|
493
|
+
const parsed = RunPageCommandArgsSchema.safeParse(args);
|
|
494
|
+
if (!parsed.success) return {
|
|
495
|
+
content: [{
|
|
496
|
+
type: "text",
|
|
497
|
+
text: `Invalid run_page_command args: ${parsed.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ")}`
|
|
498
|
+
}],
|
|
499
|
+
isError: true
|
|
500
|
+
};
|
|
501
|
+
const validated = parsed.data;
|
|
502
|
+
if (typeof validated.expected_page_id === "string") {
|
|
503
|
+
const commands = await bridge.send("listCommands", {}, 1e4);
|
|
504
|
+
const currentPageId = Array.isArray(commands) ? commands.map((c) => c._pageId).find((pid) => typeof pid === "string" && pid !== "" && pid !== "global") ?? null : null;
|
|
505
|
+
if (currentPageId !== null && currentPageId !== validated.expected_page_id) return bridge.errorResponse("PAGE_NAVIGATED", { newPageId: currentPageId });
|
|
506
|
+
}
|
|
507
|
+
const result = await bridge.send("runCommand", {
|
|
508
|
+
name: validated.name,
|
|
509
|
+
args: validated.args
|
|
510
|
+
}, 6e4);
|
|
511
|
+
const image = readImageEnvelope(result);
|
|
512
|
+
if (image) return buildImageResponse(result, image);
|
|
513
|
+
return { content: [{
|
|
514
|
+
type: "text",
|
|
515
|
+
text: JSON.stringify(result, null, 2)
|
|
516
|
+
}] };
|
|
517
|
+
}
|
|
518
|
+
case "get_context": {
|
|
519
|
+
const [contextResult, instructionsResult] = await Promise.all([bridge.send("runCommand", {
|
|
520
|
+
name: "getCurrentNodeContext",
|
|
521
|
+
args: {}
|
|
522
|
+
}, 1e4), bridge.send("runCommand", {
|
|
523
|
+
name: "getDarwiniumInstructions",
|
|
524
|
+
args: {}
|
|
525
|
+
}, 1e4)]);
|
|
526
|
+
const capped = capContextResponse({
|
|
527
|
+
...contextResult ?? {},
|
|
528
|
+
instructions: instructionsResult?.instructions ?? ""
|
|
529
|
+
});
|
|
530
|
+
return { content: [{
|
|
531
|
+
type: "text",
|
|
532
|
+
text: JSON.stringify(capped, null, 2)
|
|
533
|
+
}] };
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
537
|
+
} catch (err) {
|
|
538
|
+
const msg = err.message;
|
|
539
|
+
if (msg === "NO_TAB") return bridge.errorResponse("NO_TAB");
|
|
540
|
+
if (msg === "LOST_MID_CALL") return bridge.errorResponse("LOST_MID_CALL");
|
|
541
|
+
if (msg === "TAB_STALE") return bridge.errorResponse("TAB_STALE");
|
|
542
|
+
return {
|
|
543
|
+
content: [{
|
|
544
|
+
type: "text",
|
|
545
|
+
text: `Tool error: ${msg}`
|
|
546
|
+
}],
|
|
547
|
+
isError: true
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
//#endregion
|
|
554
|
+
//#region src/vendor/darwinium-instructions/__generated__/assets.ts
|
|
555
|
+
const SYNTAX_MD = "Darwinium has it's Query Syntax used for:\n\n- Searching Data in Investigations\n- Defining signals in rules\n\nThe query syntax was designed to be _portable_ - meaning that in most occasions it can be interchanged between a query in the **Investigations view** and a condition in the **rules editor**.\n\n<h2> Quick Start </h2>\n```sql\n// Simple search\nevent_type = 'misc_other'\n\n// Most attribute have dots in their name, based on their attribute group\nprofiling.javascript.os= \"Mac OS X\"\nprofiling.javascript.browser= \"Firefox\"\n\n// Some attributes have CONTEXTs, accessed with \\[' ']\nidentity\\['ACCOUNT'].email\\['PERSONAL'].email = '[test@test.com](mailto:test@test.com)'\noutcome\\['CHAMPION'].decision_strategy.result = 'reject'\n\n// Can combine logic; AND, OR, (brackets).\n// Logic can be UPPER or lower. Attributes are lower only.\nevent_type = 'misc_other' OR step_name = 'Login'\n\n// IN can be used to check many values in a list.\n// Single or double quotes work for strings.\nevent_type IN ('account_login_success', \"account_login_failed\")\n\n// Logical negative is done with NOT or ! character\nstep_url != \"/login\"\nstep_url NOT IN (\"/login\", \"/register\")\n\n// Empty attribute values are checked with null keyword\nstep_url = null\nstep_url != null\n// Empty string attributes can also be searched with\nstep_url = ''\n\n// Mathematical operators work on numerical attributes\npayment.amount >= 10\n\n// Maps eg. feature\\:value dicts can be accessed with attr\\['keyvalue']\n// Note: This is true of attribute CONTEXTs too.\noutcome\\['CHAMPION'].features.general\\['cnt_dvcid_for_eml_inc'] != null\noutcome\\['CHAMPION'].features.general\\['cnt_dvcid_for_eml_inc'] > 2\n\n// Presence in an Array eg. signals can be checked with has function\n// Functions are camelCase\nhas(outcome\\['CHAMPION'].models.signals, \"Device used first time\")\n!has(outcome\\['CHAMPION'].models.signals, \"Device used first time\")\n\n```\n\n<h2> Contexts </h2>\nYou may notice in [Attribute Reference](/v1/docs/attribute-reference) that a number of attributes have *contexts*. For example:\n```\n\n```\nidentity['ACCOUNT'].email['PERSONAL'].email\n```\n\n````\nin this case, `identity[]` has the following possible values:\n* `ACCOUNT`\n* `BENEFICIARY`\n* `BILLING`\n* `ORIGINATOR`\n..and `.email[]` has the following possible values:\n* `PERSONAL`\n* `WORK`\nThese *contexts* help to namespace attribute values such that you can store information on multiple accounts, for example - a Primary account (`identity['ACCOUNT']`) and a beneficiary (`identity['BENEFICIARY']`) at the same time, in the same event. This is inherently useful for developing rules around these specific contexts, as is the case with multi-party payments, for example.\n\n<h2>Query Operators</h2>\nAll operators are case insensitive\n| Operator | Behavior | Example |\n| --- | --- | --- |\n| `<`, `>`, `<=`, `>=` | Mathematical comparison on numeric data, returning boolean | `time_since_last_step > 10000` |\n| `=` , `!=` | Equality and Inequality operators, returning boolean. Inequality _includes_ empty data.| ```previous_step != 'HomePage'``` <br> (will return events where step is named something else INCLUDING where empty)|\n| `+` (add), `-` (minus), `*` (multiply), `/` (divide), `%` (modulo) | Mathematical operators on numeric data, returning numeric. | `time_since_last_step > 10000 * 60` |\n| `(` , `)` | Brackets either for expression evaluation priority or to define lists of values | `(transaction.amount+1)/100 > 1`|\n| `TRUE`, `FALSE` | Boolean literals | |\n| `NULL` | Expression for checking NULL. <br> <span style=\"color:red\">Note: Should instead use empty string `\"\"` for absent data in string types.</span> | |\n| `AND`, `OR` | Logical operators to combine boolean expressions. Returns boolean. | `step_method = 'post' AND event_type = 'PreAuthentication'` |\n|`IN`, `NOT` | Check if attribute value is in or not in a list of possibilities. Concise way of multiple equality checks linked with or. _Can also be used to match a value with wildcard contexts_. Returns boolean. | <ul> <li>`step_name not in ('ChangeDetails', 'ChangeDetailsPage')`</li><li>`'foo@bar.com' in identity['*'].email['*'].email`</li></ul> |\n| `AS` | Casts a value of one type to another type | <ul><li>`'true' as boolean`</li><li> `3.14 as string = \"3.14\"`</li><li>`'3.14' as number > 3` </li><li>`timestamp as int` </li><li>`1726055925860 as datetime` </li></ul> |\n\n<h2>Logic & String Functions</h2>\n| Operator | Behavior | Example |\n| --- | --- | --- |\n| `has(arrayAttribute, value)` | Checks if value is present in the arrayAttribute. Returns boolean; true if value is present, false otherwise. | `has(previous_steps, 'ChangeDetails')` |\n| `if(condition, valueIfTrue, valueIfFalse)` | Inline If. Condition is any boolean expression. Returns type given in valueIfTrue or valueIfFalse.| `if(previous_step = 'LoginAttempt', 1, 0)` |\n| `regexMatch(attribute, regexString)` | Check if an attribute matches a regex pattern. Returns boolean. Note - this will not work in the query editor for PII attributes (but will in rules and features) | `regexMatch(auction.title, '.*fluff'` |\n| `concat(...values)` | Concatenates multiple strings into a single string | `concat(\"hello\", \"world\") = \"helloworld\"` |\n\n<h2> Date Functions </h2>\n| Operator | Behavior | Example |\n| --- | --- | --- |\n| `now()` | Returns a date containing the current timestamp in milliseconds since epoch, January 1, 1970 | `timestamp < now()` |\n| `day(dateAttribute)` | Returns the day of month | `day(timestamp) = 6`|\n| `month(dateAttribute)`| Returns the month of a year for the input date | `month(timestamp) = 2`|\n| `year(dateAttribute)`| Returns the year of an input date or timestamp | `year(timestamp) = 2024` |\n\n<h2> Math Functions </h2>\n| Operator | Behavior | Example |\n| --- | --- | --- |\n| `random()` | Returns a random number between 0 and 1 | |\n| `floor(x)` | Takes as input a real number x, and gives as output the greatest integer less than or equal to `x` | `floor(2.35) = 2` |\n| `ceil(x)` | computes the smallest integer that is greater than or equal to `x` | `ceil(2.35) = 3` |\n| `log(x)` | Log natural of `x` | `log(1) = 0` |\n| `log2(x)` | log2 of `x` | `log2(16) = 4` |\n| `log10(x)` | log10 of `x` | `log10(100) = 2` |\n| `abs(x)` | absolute value of `x` | <ul><li> `abs(-1) = 1` </li><li> `abs(1) = 1`</li></ul> |\n| `sqrt(x)` | square root of `x` | `sqrt(4) = 2` |\n| `cos(x)` | cosine of `x` | |\n| `sin(x)` | sine of `x` | |\n| `tan(x)` | tangent of `x`| |\n| `acos(x)` | arc-cosine of `x`| |\n| `asin(x)` | arc-sine of `x` | |\n| `atan(x)` | arc-tan of `x` | |\n| `cosh(x)` | hyperbolic cosine of `x` | |\n| `sinh(x)` | hyperbolic sine of `x`| |\n| `tanh(x)` | hyperbolic tangent of `x`| |\n| `acosh(x)` | arc-hyperbolic cosine of `x` | |\n| `asinh(x)` | arc-hyperbolic sine of `x`| |\n| `atanh(x)` | arc-hyperbolic tangent of `x`| |\n\n<h2> Geographic Functions </h2>\n| Operator | Behavior | Example |\n| --- | --- | --- |\n| `distance(coordinate1, coordinate2, \"miles\"\\|\"kilometers\")` | Calculates the distance between 2 coordinate pairs in miles or kilometers | `distance([profiling.tcp_connection['PRIMARY'].ipdb.latitude, profiling.tcp_connection['PRIMARY'].ipdb.longitude] , [profiling.tcp_connection['DNS'].ipdb.latitude, profiling.tcp_connection['DNS'].ipdb.longitude] , \"kilometers\") > 1000` |\n\n<h2> Data Type Considerations </h2>\n| Data Type | Description |\n| --- | --- |\n| String Literal | Contain within quotes eg. `journey_name = 'marketplace'`. Absent data can be searched with: `= \"\"` |\n| Maps | Use square brackets and quotes to access values for certain keys eg. `custom.general_purpose['valid'] = 'yes'` |\n| Arrays | <ul> <li> use an index to reference the exact value. e.g `previous_steps[0] = 'MyLogin'`</li><li> use `has` to check for the presence of a value in an array. eg `has(previous_steps, 'MyLogin')`\n````\n";
|
|
556
|
+
const FEATURES_TS = "export type InUnits = 'kilometers' | 'miles';\n\nexport const ALL_EVENT_TYPES = [\n 'account_address_change',\n 'account_creation',\n 'account_deactivate',\n 'account_details_change',\n 'account_forgot_user_id',\n 'account_login',\n 'account_login_failed',\n 'account_login_success',\n 'account_logout',\n 'account_password_change',\n 'account_password_reset',\n 'account_settings',\n 'add_to_cart',\n 'api_request',\n 'auction_bid',\n 'auction_delete_listing',\n 'auction_listing',\n 'auth_challenge',\n 'auth_challenge_pass',\n 'balance',\n 'billing_add_payment_method',\n 'billing_manage_payment_method',\n 'billing_remove_payment_method',\n 'bind_policy',\n 'browse_site',\n 'checkout',\n 'contact_details_change_address',\n 'contact_details_change_email',\n 'contact_details_change_other',\n 'contact_details_change_phone',\n 'contact_us',\n 'deposit',\n 'digital_download',\n 'digital_stream',\n 'home_page',\n 'image_upload',\n 'insurance_claim',\n 'insurance_get_quote',\n 'insurance_new_policy',\n 'insurance_policy_ammend',\n 'intervention',\n 'loan_acceptance',\n 'message',\n 'misc_other',\n 'monitor',\n 'page_land',\n 'payment',\n 'pre_authentication',\n 'profiling',\n 'purchase',\n 'remove_from_cart',\n 'transaction_history',\n 'withdrawal',\n 'transfer',\n 'bot_check',\n];\n\ntype EventTypeTuple = typeof ALL_EVENT_TYPES;\nexport type EventType = EventTypeTuple[number];\n\nexport const HTTP_METHODS = ['GET', 'POST', 'PUT', 'OPTIONS', 'PATCH', 'DELETE'];\ntype HttpMehodTuple = typeof HTTP_METHODS;\nexport type HttpMethod = HttpMehodTuple[number];\n\nexport const URL_MATCH_TYPES = ['Prefix', 'Exact', 'ExactWithQuery', 'Glob'];\ntype UrlMatchTypeTuple = typeof URL_MATCH_TYPES;\nexport type UrlMatchType = UrlMatchTypeTuple[number];\n\nexport const ALL_TIMEZONE_OFFSETS = [\n 'UTC-12:00',\n 'UTC-11:00',\n 'UTC-10:00',\n 'UTC-09:30',\n 'UTC-09:00',\n 'UTC-08:00',\n 'UTC-07:00',\n 'UTC-06:00',\n 'UTC-05:00',\n 'UTC-04:00',\n 'UTC-03:30',\n 'UTC-03:00',\n 'UTC-02:00',\n 'UTC-01:00',\n 'UTC+00:00',\n 'UTC+01:00',\n 'UTC+02:00',\n 'UTC+03:00',\n 'UTC+03:30',\n 'UTC+04:00',\n 'UTC+04:30',\n 'UTC+05:00',\n 'UTC+05:30',\n 'UTC+05:45',\n 'UTC+06:00',\n 'UTC+06:30',\n 'UTC+07:00',\n 'UTC+08:00',\n 'UTC+08:45',\n 'UTC+09:00',\n 'UTC+09:30',\n 'UTC+10:00',\n 'UTC+10:30',\n 'UTC+11:00',\n 'UTC+12:00',\n 'UTC+12:45',\n 'UTC+13:00',\n 'UTC+14:00',\n];\n\ntype TimezoneTuple = typeof ALL_TIMEZONE_OFFSETS;\nexport type TimezoneOffset = TimezoneTuple[number];\n\n/* Directive for autocomplete : this only applies to identifiers or subject */\n/* drop down needs to NOT provide normal attribs */\nexport type Attribute = string;\nexport type NumericAttribute = string;\nexport type CoordinateAttribute = string;\n\nexport interface Features {\n features: Feature[];\n}\n\nexport interface Events {\n event_type: EventType[] | 'all' | 'same_type';\n include_current_event?: boolean | undefined;\n condition?: string | null;\n with_the_same: Attribute[];\n with_similarity?: number;\n}\n\nexport interface IntoFeatures {\n count?: Attribute | null;\n sum?: Attribute | null;\n minimum?: Attribute | null;\n maximum?: Attribute | null;\n average?: Attribute | null;\n range?: Attribute | null;\n mode?: Attribute | null;\n median?: Attribute | null;\n standard_deviation?: Attribute | null;\n average_deviation?: Attribute | null;\n variance?: Attribute | null;\n coefficient_of_variation?: Attribute | null;\n sample_variance?: Attribute | null;\n sample_standard_deviation?: Attribute | null;\n z_score?: Attribute | null;\n quantile?: Attribute | null;\n}\n\nexport type ExcludeDays = 'weekdays' | 'weekends';\n\nexport interface ApproximateDistinctCountOf {\n attributes: Attribute[]; //can be any attribute\n event_type: EventType[] | 'all' | 'same_type';\n}\n\nexport interface SpecificityOf extends ApproximateDistinctCountOf {\n normalize: boolean;\n}\n\nexport interface VelocityOf {\n // attributes: Attribute[] // any identiers or subjects\n for_events: Events;\n}\n\nexport interface DistinctCountOf {\n attributes: Attribute[]; // identifiers or subjects\n for_events: Events;\n}\n\nexport interface TimeSinceFirst {\n events: Events;\n}\n\nexport interface TimeSinceLast {\n events: Events;\n}\n\nexport interface DistanceFromFirst {\n events: Events;\n coordinate: string;\n in_units: InUnits;\n}\n\nexport interface DistanceFromLast {\n events: Events;\n coordinate: string;\n in_units: InUnits;\n}\n\nexport interface StatisticOfNumeric {\n attribute: NumericAttribute; // Must be a integer or real SUBJECT\n for_events: Events;\n into_features: IntoFeatures;\n}\n\nexport interface StatisticOfExpression {\n expression: string;\n for_events: Events;\n into_features: IntoFeatures;\n}\n\nexport interface ProbabilityOfTime {\n for_events: Events;\n bucket: TimeWindow;\n periodicity: TimeWindow;\n exclude?: ExcludeDays;\n}\n\nexport interface TimeBetween {\n events: Events;\n into_features: IntoFeatures;\n}\n\nexport interface DistanceBetween {\n events: Events;\n coordinate: CoordinateAttribute;\n in_units: InUnits;\n into_features: IntoFeatures;\n}\n\nexport interface DistanceFrom {\n events: Events;\n coordinate: CoordinateAttribute;\n in_units: InUnits;\n into_features: IntoFeatures;\n}\n\nexport interface DistanceBetweenPointsWithin {\n events: Events;\n coordinate: CoordinateAttribute;\n and: CoordinateAttribute;\n in_units: InUnits;\n into_features: IntoFeatures;\n}\n\nexport interface QuantileOfNumeric {\n attribute: NumericAttribute;\n}\n\nexport interface QuantileValue {\n attribute: NumericAttribute;\n quantile: number;\n}\n\nexport interface ApproximateTotalCount {\n attribute: NumericAttribute;\n}\n\nexport interface EnrichmentValue {\n attribute: Attribute;\n reference_key: string;\n}\n\nexport interface FindThe {\n velocity_of?: VelocityOf;\n distinct_count_of?: DistinctCountOf;\n time_since_first?: TimeSinceFirst;\n time_since_last?: TimeSinceLast;\n distance_from_first?: DistanceFromFirst;\n distance_from_last?: DistanceFromLast;\n statistic_of_numeric?: StatisticOfNumeric;\n time_between?: TimeBetween;\n distance_between?: DistanceBetween;\n distance_from?: DistanceFrom;\n distance_between_points_within?: DistanceBetweenPointsWithin;\n approx_distinct_count_of?: ApproximateDistinctCountOf;\n approx_total_count_of?: ApproximateTotalCount;\n specificity_of?: SpecificityOf;\n quantile_of_numeric?: QuantileOfNumeric;\n quantile_value?: QuantileValue;\n enrichment_value?: EnrichmentValue;\n result_of_expression?: string;\n statistic_of_expression?: StatisticOfExpression;\n probability_of_time?: ProbabilityOfTime;\n}\n\nexport type TimeWindow =\n | 'all'\n | {\n mins?: number;\n hours?: number;\n days?: number;\n weeks?: number;\n months?: number;\n events?: number;\n since_last_event?: 'all' | EventType[];\n since_beginning_of_calendar_month?: TimezoneOffset;\n since_beginning_of_calendar_day?: TimezoneOffset;\n };\n\nexport type Starting =\n | 'immediately'\n | {\n mins_in_the_past?: number;\n hours_in_the_past?: number;\n days_in_the_past?: number;\n months_in_the_past?: number;\n events_in_the_past?: number;\n };\n\nexport type WithScope =\n | 'global'\n | 'same_organization'\n | 'same_primary_industry'\n | 'same_primary_and_secondary_industry'\n | 'same_node_instance';\nexport interface Feature {\n name: string;\n default_value?: number;\n find_the: FindThe;\n time_window?: TimeWindow;\n starting?: Starting;\n with_scope?: WithScope;\n calculate_quantile?: boolean;\n}\n";
|
|
557
|
+
|
|
558
|
+
//#endregion
|
|
559
|
+
//#region src/vendor/darwinium-instructions/labelList.ts
|
|
560
|
+
const STATIC_LABEL_LIST = `* there's a function called checkLabel to check for labels on Darwinium. it takes the form \`checkLabel(\"label name\", [attributes, relevant, here], [contexts, go, here])\` . For now, make contexts irrelevant. Here is some possible label names:\n\n\`\`\`'[\"second_party_fraud\",\"speed_tester\",\"watch_list\",\"open_proxy\",\"mail_attack\",\"purchase_scam\",\"spam_outbound\",\"advance_fee_scam\",\"monitor_list\",\"open_socks_proxy\",\"challenged\",\"click_fraud\",\"fake_review\",\"spam_inbound\",\"self_excluded\",\"malware\",\"incorrect_format\",\"bonus_abuse\",\"challenge_timeout\",\"operator_review_passed\",\"site_monitor\",\"anon_vpn_service\",\"romance_scam\",\"additional_payload\",\"chargebacks_payment_fraud\",\"claims_fraud\",\"identity_theft\",\"onion_router\",\"voip_fraud\",\"challenge_passed\",\"feed_fetcher\",\"schema_validated\",\"false_positive\",\"fake_listing\",\"data_breach\",\"block_list\",\"impersonation_scam\",\"identity_fraud_stolen_credentials\",\"link_checker\",\"marketing_crawler\",\"cybercrime\",\"phishing_content\",\"uncategorized_crawler\",\"scripted_attack_fraud\",\"ghost_broker_fraud\",\"bitcoin_node\",\"problem_gambler\",\"account_takeover_social_engineering\",\"web_attack\",\"login_failed\",\"account_lock\",\"screenshot_creator\",\"schema_violation\",\"self_excluded_linked_identifier\",\"cgi_proxy\",\"fake_crawler\",\"click_and_collect_fraud\",\"false_negative\",\"marked_for_operator_review\",\"first_party_fraud\",\"bot_abuse\",\"vip_list\",\"web_scraper\",\"trusted_identity\",\"login_passed\",\"promo_abuse\",\"sim_swap_fraud\",\"tor_exit_node\",\"ssh_attack\",\"other_app_scam\",\"challenge_failed\",\"account_takeover_change_of_details\",\"redirect_scam\",\"search_engine_crawler\",\"tools_crawler\",\"virus_scanner_crawler\",\"allow_list\",\"anon_proxy\",\"reseller_abuse\",\"money_mule_money_laundering\",\"synthetic_identity_fraud\",\"investment_scam\",\"cookie_copy\",\"account_takeover\",\"toxic_content_abuse\",\"collusive_payment_fraud\",\"benefit_theft_fraud\",\"subscription_fraud\",\"vulnerability_scanner\",\"legacy_connection\",\"legacy_device\"]'\`\`\``;
|
|
561
|
+
|
|
562
|
+
//#endregion
|
|
563
|
+
//#region src/vendor/darwinium-instructions/promptBlocks.ts
|
|
564
|
+
const SEPARATOR = "\n\n---\n\n";
|
|
565
|
+
function getStaticInstructions() {
|
|
566
|
+
return [
|
|
567
|
+
`*Instructions:*`,
|
|
568
|
+
`* If a user appears to be asking for documentation, you should use the \`get_documentation\` tool. Just return that as a response.`,
|
|
569
|
+
`<queries>`,
|
|
570
|
+
`* To find attributes for use in queries, use the \`get_attribute\` tool with a search term (e.g., "ip address", "device", "email", "geolocation", "browser", "user agent"). This will return matching attributes with their proper syntax, data types, descriptions, and example values.`,
|
|
571
|
+
`* query syntax:\n\`\`\`\n${SYNTAX_MD}\n\`\`\``,
|
|
572
|
+
`* ignore ipdb and integrations.*; event disposition in outcome['CHAMPION'].decision_strategy.result`,
|
|
573
|
+
`* don't use event_type in the query. You dont know what its possible values are`,
|
|
574
|
+
`* NEVER Make up feature names. Use the feature definitions and signals to get names. If none exist, suggest code for them in YAML. Prefix this with "I couldnt find any features that to what you want"`,
|
|
575
|
+
`* // Comments prohibited; wildcards only with IN operator`,
|
|
576
|
+
`* NEVER MAKE UP SIGNAL NAMES. Use only the signal names defined below `,
|
|
577
|
+
`* ALWAYS TRY to use \`get_attribute\` to find inbuilt attributes before using features or signals `,
|
|
578
|
+
`* ALWAYS use \`get_attribute\` to look up the correct attribute syntax before building queries. Do not guess attribute names.`,
|
|
579
|
+
`* prefer device SIGNATURE over profiling.device.identifier whenever possible`,
|
|
580
|
+
`* wildcards can be used. for example:
|
|
581
|
+
"192.168.0.1" in profiling.tcp_connection['*'].ip_address
|
|
582
|
+
"ben@cool.com" in identity['*'].email['PERSONAL'].email
|
|
583
|
+
* wildcards CAN ONLY BE used in conjunction with the 'IN' operator (see above). Where non-wildcard attributes are used, the "=" operator can be used instead.`,
|
|
584
|
+
`* Model scores: outcome['CHAMPION'].models.score['<model>'] < -500`,
|
|
585
|
+
`* Biometrics: null‐check before use (see docs)`,
|
|
586
|
+
STATIC_LABEL_LIST,
|
|
587
|
+
`* checkLabel must include at LEAST ONE attribute in the 2nd argument. Common options are device signature, email, username and login
|
|
588
|
+
* checkLabel returns a Boolean. The result is always Boolean. If it is not a Boolean you are doing something wrong.
|
|
589
|
+
* events are rarely labelled by customer. You should avoid RELYING on checkLabel to get results. ie. use it with OR to get more results`,
|
|
590
|
+
`* signals in $.journeyMetadata are accessed using \`has(outcome['CHAMPION'].models.signals, "<signal_name>")\`
|
|
591
|
+
* signals in $.globalJourneyMetadata are accessed using \`has(profiling.device.signals, "<signal_name>")\`
|
|
592
|
+
* DO NOT GUESS SIGNAL NAMES. Use the signals in the arrays above to get the names.
|
|
593
|
+
* has() only takes a single signal name, not an array`,
|
|
594
|
+
`* you should ALWAYS use tools provided to validate queries and get counts. You may want to break your query down into smaller tokens you use in multiple tool calls so you can get a sense of the counts for smaller parts of an overall query. A query that returns a count > 0 is a good query.`,
|
|
595
|
+
`* Output: wrap query in markdown code block (backticked as \`\`\`astifier\n {{result}} \n \`\`\`, SINGLE LINE ONLY), then one‐sentence explanation. Include the count of events matching the query (from the validate_query tool. This is for the last 30 days. make that clear to the user)`,
|
|
596
|
+
`</queries>`,
|
|
597
|
+
`<features>`,
|
|
598
|
+
`* if generating features, output YAML in a format that strictly matches the following typescript schema:\n\`\`\`typescript\n${FEATURES_TS}\n\`\`\``,
|
|
599
|
+
`* if you use a tool to create features you MUST validate any \`condition\` or \`result_of_expression\` that is returned. If it is not valid, you must fix it before returning the feature`,
|
|
600
|
+
`* if generating features code it must ALWAYS be backticked as \`\`\`features\n{{result}}\n\`\`\``,
|
|
601
|
+
`* there is a dedicated tool for generating features called \`generate_features\`. You should use this tool and validate any inline queries if they are present`,
|
|
602
|
+
`</features>`,
|
|
603
|
+
`<tool_calling>`,
|
|
604
|
+
`* ALWAYS follow the tool call schema exactly as specified and make sure to provide all necessary parameters.`,
|
|
605
|
+
`* there is a tool called \`get_page_commands\` that returns the tools available on the current page. You should ALWAYS call this tool first to see what tools are available. If workflow/policy editing is in play, the page may expose a page command named \`getWorkspaceProblems\` — treat it the same way you treat \`validate_query\` for queries: a required sanity check, but invoke it via \`run_page_command\`.`,
|
|
606
|
+
`* If the user mentions errors, validation failures, red squiggles, "it's broken", "why won't it compile", or is iterating on a policy / workflow / Journey file, you MUST call \`get_page_commands\` and then, if \`getWorkspaceProblems\` is available, call \`run_page_command\` with \`{ commandName: "getWorkspaceProblems", args: {} }\` before answering. Do not guess at errors — the Journey language server is authoritative and its diagnostics are reachable via that page command.`,
|
|
607
|
+
`* After any tool-driven edit to workspace files (e.g. feature/policy changes), re-run the page command via \`run_page_command\` with \`{ commandName: "getWorkspaceProblems", args: {} }\` to confirm the workspace is clean before telling the user the change is done. If diagnostics are present, summarize them (file, line, message) and propose a fix rather than claiming success.`,
|
|
608
|
+
`* When \`run_page_command\` for \`getWorkspaceProblems\` returns nothing, say so explicitly — "no diagnostics reported by the language server" — so the user knows you checked.`,
|
|
609
|
+
`* Prefer \`run_page_command\` with \`{ commandName: "getWorkspaceProblems", args: {} }\` for a workspace-wide sweep; pass \`{ commandName: "getWorkspaceProblems", args: { uri: "<relative path>" } }\` only when the user has scoped the question to one file.`,
|
|
610
|
+
`* if a user asks you to analyze a graph, you should ALWAYS use the \`listGraphsByName\` command first to see what graphs are available. The user may not have provided the name of the graph properly, and the graph names are case sensitive (which they will not be aware of)`,
|
|
611
|
+
`* If graphs are available, you should check whether a \`getGraphScreenshotByName\` command is available. Screenshots often disclose interesting insights on the data and help you understand what is being rendered. `,
|
|
612
|
+
`* if graphs are available you should run BOTH \`getGraphScreenshotByName\` and \`getGraphDataByName\` to get the data and the screenshot. You should then analyze the screenshot and data to provide insights to the user.`,
|
|
613
|
+
`* when selecting columns on the table/grid using the \`selectColumns\` command, you should ALWAYS select \`identifier\` and \`timestamp\` columns.`,
|
|
614
|
+
`* NEVER embed any screeenshots you capture in your response. Just provide your insights on the image and/or graph data.`,
|
|
615
|
+
`</tool_calling>`
|
|
616
|
+
].join(SEPARATOR);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
//#endregion
|
|
620
|
+
//#region src/server/instructions.ts
|
|
621
|
+
/**
|
|
622
|
+
* Marks the static block as a snapshot rather than live portal state, so the model
|
|
623
|
+
* does not report stale signal or label names as authoritative.
|
|
624
|
+
*/
|
|
625
|
+
const STATIC_FALLBACK_NOTE = [
|
|
626
|
+
"",
|
|
627
|
+
"---",
|
|
628
|
+
"",
|
|
629
|
+
"NOTE: the Darwinium guidance above is a static snapshot bundled with this server,",
|
|
630
|
+
"not live state read from the portal. Query syntax and general guidance are reliable.",
|
|
631
|
+
"Signal names, feature names and label lists may be out of date — once the extension",
|
|
632
|
+
"is connected, call get_context to refresh them before relying on them."
|
|
633
|
+
].join("\n");
|
|
634
|
+
/**
|
|
635
|
+
* Static instructions plus the snapshot caveat. Wrapped so a defect in the vendored
|
|
636
|
+
* data can never take down `initialize` — degrading to the old error string is bad,
|
|
637
|
+
* but failing the handshake outright would leave the client with no server at all.
|
|
638
|
+
*/
|
|
639
|
+
function staticInstructionsBlock(reason) {
|
|
640
|
+
try {
|
|
641
|
+
return getStaticInstructions() + STATIC_FALLBACK_NOTE;
|
|
642
|
+
} catch (err) {
|
|
643
|
+
return `[portal-mcp ERROR initialize] Could not fetch Darwinium instructions: ${reason}, and the bundled fallback failed to load: ${err.message}. Call get_context to retrieve them on demand. Do not act as if this is portal guidance.`;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
async function resolveInitializeInstructions(bridge, token, opts) {
|
|
647
|
+
if (!bridge.ownsPort()) return buildPeerInstanceInstructions();
|
|
648
|
+
if (!await bridge.waitForWs(opts.timeoutMs)) return `${buildPairingInstructions(token)}\n\n${staticInstructionsBlock("extension not connected")}`;
|
|
649
|
+
try {
|
|
650
|
+
return (await bridge.send("runCommand", {
|
|
651
|
+
name: "getDarwiniumInstructions",
|
|
652
|
+
args: {}
|
|
653
|
+
}, 3e3)).instructions || staticInstructionsBlock("page returned no instructions");
|
|
654
|
+
} catch (err) {
|
|
655
|
+
return staticInstructionsBlock(err.message);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
//#endregion
|
|
660
|
+
//#region src/server/index.ts
|
|
661
|
+
async function runServer() {
|
|
662
|
+
const server = new Server({
|
|
663
|
+
name: "darwinium-portal-mcp",
|
|
664
|
+
version: VERSION
|
|
665
|
+
}, { capabilities: { tools: {} } });
|
|
666
|
+
const bridge = new Bridge();
|
|
667
|
+
const expectedToken = readToken();
|
|
668
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
669
|
+
server.setRequestHandler(InitializeRequestSchema, async (request) => {
|
|
670
|
+
const requestedVersion = request.params.protocolVersion;
|
|
671
|
+
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion) ? requestedVersion : LATEST_PROTOCOL_VERSION;
|
|
672
|
+
const instructions = await resolveInitializeInstructions(bridge, expectedToken, { timeoutMs: 5e3 });
|
|
673
|
+
return {
|
|
674
|
+
protocolVersion,
|
|
675
|
+
capabilities: { tools: {} },
|
|
676
|
+
serverInfo: {
|
|
677
|
+
name: "darwinium-portal-mcp",
|
|
678
|
+
version: VERSION
|
|
679
|
+
},
|
|
680
|
+
...instructions && { instructions }
|
|
681
|
+
};
|
|
682
|
+
});
|
|
683
|
+
server.setRequestHandler(CallToolRequestSchema, makeBridgeCallHandler(bridge, expectedToken));
|
|
684
|
+
try {
|
|
685
|
+
await createWsServer(bridge, expectedToken);
|
|
686
|
+
} catch (err) {
|
|
687
|
+
bridge.setOwnsPort(false);
|
|
688
|
+
console.error(`portal-mcp: WS bridge unavailable: ${err.message}`);
|
|
689
|
+
}
|
|
690
|
+
await server.connect(new StdioServerTransport());
|
|
691
|
+
const shutdown = () => {
|
|
692
|
+
stopTakeoverPolling();
|
|
693
|
+
process.exit(0);
|
|
694
|
+
};
|
|
695
|
+
server.onclose = shutdown;
|
|
696
|
+
process.stdin.on("end", shutdown);
|
|
697
|
+
process.stdin.on("close", shutdown);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
//#endregion
|
|
701
|
+
export { runServer };
|
|
702
|
+
//# sourceMappingURL=server-CuNr9ZtJ.js.map
|