@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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server-CuNr9ZtJ.js","names":[],"sources":["../../src/server/tools.ts","../../src/bridge/errorMapper.ts","../../src/bridge/Context.ts","../../src/server/get-context.ts","../../src/server/pairingMessage.ts","../../src/server/bridgeCallHandler.ts","../../src/vendor/darwinium-instructions/__generated__/assets.ts","../../src/vendor/darwinium-instructions/labelList.ts","../../src/vendor/darwinium-instructions/promptBlocks.ts","../../src/server/instructions.ts","../../src/server/index.ts"],"sourcesContent":["import type { Tool } from '@modelcontextprotocol/sdk/types.js';\n\n/**\n * STATIC THREE-TOOL SURFACE (ADR-001).\n *\n * Adding a 4th tool requires:\n * 1. Update ADR-001 with rationale.\n * 2. Update smoke-test.ts assertion `assert.equal(tools.length, 3)` to the new count.\n * 3. Justify in PR description why the dynamic surface (which lives in `get_page_commands` returns)\n * cannot accommodate the new capability.\n *\n * Claude Desktop and Claude Code cache `tools/list` and do NOT honor `tools/list_changed`\n * (claude-code#7519, #13646). Adding tools without considering host cache behavior is unsafe.\n *\n * ANNOTATIONS ARE MANDATORY. Anthropic's directory review requires every tool to carry\n * a `title` plus the applicable `readOnlyHint` / `destructiveHint`; a tool without them\n * is rejected. `openWorldHint` is false throughout: this server has zero backend\n * connections and reaches nothing beyond the one browser tab it is paired with.\n */\nexport const TOOLS: Tool[] = [\n {\n name: 'get_page_commands',\n annotations: {\n title: 'List portal page commands',\n readOnlyHint: true,\n // Same page, same list — but the page can navigate between calls, so the\n // result is not stable over time and the tool is not idempotent.\n idempotentHint: false,\n openWorldHint: false,\n },\n description:\n '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.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'run_page_command',\n annotations: {\n title: 'Run a portal page command',\n // The dynamic surface behind this tool includes commands that edit policies,\n // workflows and labels. The annotation must describe the worst case the tool\n // can reach, not the common case.\n readOnlyHint: false,\n destructiveHint: true,\n idempotentHint: false,\n openWorldHint: false,\n },\n description:\n '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.',\n inputSchema: {\n type: 'object',\n properties: {\n name: { type: 'string', description: 'The page-command name as returned by get_page_commands.' },\n args: {\n type: 'object',\n description: \"Arguments to pass to the command (object; shape per the command's args schema).\",\n },\n expected_page_id: {\n type: 'string',\n description: 'Optional: reject if the live page id no longer matches this value.',\n },\n },\n required: ['name'],\n },\n },\n {\n name: 'get_context',\n annotations: {\n title: 'Get portal instructions and page context',\n readOnlyHint: true,\n idempotentHint: false,\n openWorldHint: false,\n },\n description:\n '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.',\n inputSchema: { type: 'object', properties: {} },\n },\n];\n","/**\n * Five-mode error response mapper for `tools/call` failure paths.\n *\n * The message strings are user-facing: short, action-first, no error codes,\n * no doctor references.\n */\nexport type ErrorMode = 'NO_TAB' | 'LOST_MID_CALL' | 'PAGE_NAVIGATED' | 'TOKEN_MISMATCH' | 'TAB_STALE';\n\nconst MESSAGES: Record<ErrorMode, (ctx?: { newPageId?: string }) => string> = {\n NO_TAB: () => 'Not connected. Click Connect in the Darwinium extension popup.',\n LOST_MID_CALL: () => 'Lost connection to portal mid-call. Retry; the extension auto-reconnects within a few seconds.',\n // PAGE_NAVIGATED is templated with the now-known pageId.\n PAGE_NAVIGATED: (ctx) =>\n `Page navigated. Current page is ${ctx?.newPageId ?? '<unknown>'}. Re-call get_page_commands and retry against the new page.`,\n // TOKEN_MISMATCH — host string only; the popup-side wording is in the extension package.\n TOKEN_MISMATCH: () => \"Extension token does not match the binary's token. Re-run install or rotate-token.\",\n // TAB_STALE: the extension was reloaded/auto-updated, so the old content script\n // in the connected tab can no longer reach the (new) SW. Background re-injection\n // (chrome.scripting.executeScript on chrome.runtime.onInstalled) is attempted\n // automatically; user-facing recovery is to refresh the tab.\n TAB_STALE: () =>\n 'Extension was reloaded; refresh the connected portal tab to restore the bridge. Re-injection is being attempted automatically.',\n};\n\nexport function mapError(\n mode: ErrorMode,\n ctx?: { newPageId?: string },\n): { content: Array<{ type: 'text'; text: string }>; isError: true } {\n return {\n content: [{ type: 'text' as const, text: MESSAGES[mode](ctx) }],\n isError: true,\n };\n}\n","/**\n * Bridge — owns the single `_ws` reference to the extension SW client (one at a\n * time), the per-request pending Map, and the error-mapper surface.\n *\n * The bridge holds NO page-derived state: page ids, command lists, and context\n * all come back per-request from the page (see bridgeCallHandler's live\n * `listCommands` probe for the `expected_page_id` check). The only state here\n * is connection plumbing — socket handle, in-flight request correlation.\n */\nimport type WebSocket from 'ws';\nimport { randomUUID } from 'node:crypto';\nimport { mapError, type ErrorMode } from './errorMapper.js';\n\ntype PendingEntry = {\n resolve: (v: unknown) => void;\n reject: (e: Error) => void;\n timer: NodeJS.Timeout;\n // Owning socket. When a replaced socket's close handler fires, only its own\n // pending entries should reject; the new socket's in-flight requests are\n // healthy and must NOT be cleared as LOST_MID_CALL.\n ws: WebSocket;\n};\n\nexport class Bridge {\n private _ws: WebSocket | undefined;\n private pending = new Map<string, PendingEntry>();\n private wsReadyResolvers: Array<() => void> = [];\n /**\n * Whether THIS process owns the 127.0.0.1:9224 listener.\n *\n * Only one portal-mcp can own the bridge, but several may run at once (two\n * MCP hosts, or one host restarting a server before the old process exits).\n * A non-owner still serves MCP over its own stdio — it just cannot reach the\n * browser, and says so instead of offering a pairing token that would not\n * help. Set false while another live instance holds the port; flipped back by\n * the takeover poll in ws/server.ts when that instance exits.\n */\n private _ownsPort = true;\n\n hasWs(): boolean {\n return this._ws?.readyState === 1; // WebSocket.OPEN\n }\n\n /** False when another live portal-mcp instance owns the WS port. */\n ownsPort(): boolean {\n return this._ownsPort;\n }\n\n setOwnsPort(owns: boolean): void {\n this._ownsPort = owns;\n }\n\n /** Wait up to timeoutMs for an extension WS to be attached. Resolves true on attach, false on timeout. */\n waitForWs(timeoutMs: number): Promise<boolean> {\n if (this.hasWs()) return Promise.resolve(true);\n return new Promise((resolve) => {\n const resolveOnce = () => {\n clearTimeout(timer);\n resolve(true);\n };\n const timer = setTimeout(() => {\n this.wsReadyResolvers = this.wsReadyResolvers.filter((r) => r !== resolveOnce);\n resolve(false);\n }, timeoutMs);\n this.wsReadyResolvers.push(resolveOnce);\n });\n }\n\n attach(ws: WebSocket): void {\n if (this._ws) {\n try {\n this._ws.close(1000, 'replaced');\n } catch {\n /* ignore close failure on replaced socket */\n }\n }\n this._ws = ws;\n ws.on('message', (raw) => this.onMessage(raw as Buffer | string));\n ws.on('close', () => {\n // Only reject pending entries OWNED by this socket. Without the\n // ws-tagged filter, a replaced socket's close async-fires AFTER the new\n // socket has registered its own pending entries, and rejectAllPending\n // would clear the new socket's healthy in-flight requests as\n // LOST_MID_CALL. With per-socket ownership, the replaced socket's close\n // only rejects its own (typically empty by then) entries.\n this.rejectPendingFor(ws, new Error('LOST_MID_CALL'));\n if (this._ws === ws) this._ws = undefined;\n });\n ws.on('error', () => {\n // The 'close' handler will fire; nothing to do here beyond avoiding an unhandled-error throw.\n });\n // Notify any initialize-handler waiters that called `waitForWs(...)` before attach().\n const resolvers = this.wsReadyResolvers;\n this.wsReadyResolvers = [];\n for (const r of resolvers) r();\n }\n\n send<T = unknown>(op: string, args: unknown, timeoutMs = 30_000): Promise<T> {\n if (!this.hasWs()) {\n return Promise.reject(new Error('NO_TAB'));\n }\n // Capture the owning socket NOW: a subsequent attach() that replaces\n // _ws must not orphan this entry — its close handler will rejectPendingFor(this ws).\n const owner = this._ws!;\n const id = randomUUID();\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => {\n this.pending.delete(id);\n reject(new Error('LOST_MID_CALL'));\n }, timeoutMs);\n this.pending.set(id, {\n resolve: resolve as (v: unknown) => void,\n reject,\n timer,\n ws: owner,\n });\n // If the WS is in CLOSING/CLOSED state, `ws.send` synchronously throws;\n // without this guard the pending entry would never resolve and the MCP\n // host would hang until its outer timeout. On synchronous throw, reject\n // and clean up the pending Map entry + timer so the caller gets a fast,\n // descriptive error instead of an indefinite wait.\n try {\n owner.send(JSON.stringify({ id, type: 'req', op, args }));\n } catch (err) {\n const pending = this.pending.get(id);\n if (pending) {\n clearTimeout(pending.timer);\n this.pending.delete(id);\n }\n reject(new Error(`Bridge.send failed: ${(err as Error).message}`));\n }\n });\n }\n\n /** Map an internal error mode to a user-facing structured tool response. */\n errorResponse(mode: ErrorMode, ctx?: { newPageId?: string }) {\n return mapError(mode, ctx);\n }\n\n private onMessage(raw: Buffer | string): void {\n try {\n const msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()) as Record<string, unknown>;\n if (msg.type === 'resp') {\n const id = msg.id as string;\n const p = this.pending.get(id);\n if (p) {\n clearTimeout(p.timer);\n this.pending.delete(id);\n if (msg.error) {\n p.reject(new Error(String(msg.error)));\n } else {\n p.resolve(msg.result);\n }\n }\n } else if (msg.type === 'pageIdChanged') {\n // Intentionally ignored — the bridge keeps no page-derived state. The\n // extension still emits these push frames (wire-protocol compat); page\n // identity is resolved per-request via a live listCommands probe in\n // bridgeCallHandler instead.\n } else if (msg.type === 'ping') {\n // Echo pong so the extension's keepalive sees a reply.\n try {\n this._ws?.send(JSON.stringify({ type: 'pong' }));\n } catch {\n /* ignore send failure on closing socket */\n }\n } else if (msg.type === 'pong') {\n // no-op — the binary doesn't currently track outbound pings.\n }\n } catch {\n /* malformed frame — ignore */\n }\n }\n\n private rejectAllPending(err: Error): void {\n for (const p of this.pending.values()) {\n clearTimeout(p.timer);\n p.reject(err);\n }\n this.pending.clear();\n }\n\n // Reject only pending entries owned by `owner`. Used by the close handler\n // so a replaced socket cannot wipe the new socket's in-flight work.\n private rejectPendingFor(owner: WebSocket, err: Error): void {\n for (const [id, p] of this.pending.entries()) {\n if (p.ws !== owner) continue;\n clearTimeout(p.timer);\n this.pending.delete(id);\n p.reject(err);\n }\n }\n}\n","/**\n * 50KB cap + field-drop policy for `get_context` tool responses.\n *\n * The drop paths follow the actual `ConditionerResult` shape\n * (darwinium-node-context/src/types.ts): `featuresExplained` is one key with\n * `globalJourneyMetadata` / `journeyMetadata` sub-arrays.\n *\n * Drop order:\n * featuresExplained.globalJourneyMetadata → featuresExplained.journeyMetadata →\n * signals → contexts → stepNames\n * Always retain: viper, page, route, _pageId, instructions.\n * Last-resort: pop viper[] elements until under cap (preserves valid JSON).\n * NEVER byte-truncate JSON.stringify output (would produce invalid JSON).\n */\n\nconst MAX_BYTES = 50_000;\n\n// Field-drop priority. One pass per attempt; iterate until under MAX_BYTES or\n// DROP_ORDER exhausted.\nconst DROP_ORDER: Array<string[]> = [\n ['featuresExplained', 'globalJourneyMetadata'], // Drop the global journey metadata first\n ['featuresExplained', 'journeyMetadata'], // Then the journey side of featuresExplained\n ['signals'], // Then signals\n ['contexts'], // Then contexts\n ['stepNames'], // Then stepNames\n];\n\n// RETAINED documents the always-keep set; not enforced as a runtime check.\n// Documented here so future maintainers know which fields the LLM relies on.\nconst RETAINED = ['viper', 'page', 'route', '_pageId', 'instructions'];\nvoid RETAINED; // referenced for documentation; suppressed unused-warning\n\nexport function capContextResponse(\n input: Record<string, unknown>,\n): Record<string, unknown> & { _truncated?: boolean; _truncatedFields?: string[] } {\n const work = JSON.parse(JSON.stringify(input)) as Record<string, unknown>;\n const truncatedFields: string[] = [];\n let attempt = 0;\n while (Buffer.byteLength(JSON.stringify(work), 'utf8') > MAX_BYTES) {\n if (attempt >= DROP_ORDER.length) {\n // We've dropped all droppable fields and we're still over.\n // Truncate viper as last resort: pop elements until under cap.\n // (Don't byte-truncate JSON.stringify output — would produce invalid JSON.)\n const viper = (work as { viper?: unknown }).viper;\n if (Array.isArray(viper)) {\n while (viper.length > 0 && Buffer.byteLength(JSON.stringify(work), 'utf8') > MAX_BYTES) {\n viper.pop();\n }\n truncatedFields.push('viper[]');\n }\n break;\n }\n const path = DROP_ORDER[attempt];\n deletePath(work, path);\n truncatedFields.push(path.join('.'));\n attempt++;\n }\n if (truncatedFields.length === 0) return work;\n return { ...work, _truncated: true, _truncatedFields: truncatedFields };\n}\n\nfunction deletePath(obj: unknown, path: string[]): void {\n let cur: unknown = obj;\n for (let i = 0; i < path.length - 1; i++) {\n if (cur == null || typeof cur !== 'object') return;\n cur = (cur as Record<string, unknown>)[path[i]];\n }\n if (cur && typeof cur === 'object') {\n delete (cur as Record<string, unknown>)[path[path.length - 1]];\n }\n}\n","/**\n * Pairing-message builders — surface the per-machine pairing token to the LLM so\n * the user can connect the extension by simply *asking Claude for the token*.\n *\n * The binary (`serve`) reads/generates the token at startup, but the interactive\n * 6-digit OOB pairing flow runs ONLY during `install` / `rotate-token` — a plain\n * Claude Desktop / Claude Code launch (which runs `serve`) has no other delivery\n * path. So the token is embedded in the MCP `initialize.instructions` and in the\n * \"not connected\" tool responses, where the host LLM can read it out of its own\n * context and hand it to the user on request.\n *\n * Security note: the token is a LOCAL secret that only authorizes a WebSocket on\n * 127.0.0.1:9224. It already lives in the binary's process and the user's own MCP\n * session context — surfacing it to that same user's LLM does not widen the trust\n * boundary. It must NOT be sent anywhere off-machine.\n */\n\n/**\n * Instruction block embedded in `initialize.instructions` when the extension is\n * not yet connected. Written so the LLM both (a) understands portal tools are\n * unavailable until pairing, and (b) can answer \"what's my token / how do I\n * connect?\" directly from context without any tool call.\n */\nexport function buildPairingInstructions(token: string): string {\n return [\n 'The Darwinium Portal browser extension is not connected to this MCP server yet.',\n 'Portal tools (get_page_commands, run_page_command, get_context) will not work until the user pairs the extension.',\n '',\n 'To connect, the user should:',\n ' 1. Open their Darwinium portal in Chrome (a *.darwinium.com or *.int.darwinium.io tab).',\n ' 2. Click the \"Darwinium Portal MCP\" extension icon in the toolbar.',\n ' 3. Paste the pairing token below into the popup and click \"Save & Connect\".',\n '',\n `Pairing token for this machine: ${token}`,\n '',\n 'When the user asks how to connect, how to pair, or what their pairing token is,',\n 'give them this token and these steps. The token is a local secret valid only on',\n '127.0.0.1 — it is safe to show to this user, but must never be sent anywhere else.',\n ].join('\\n');\n}\n\n/**\n * Shared body for the \"another instance owns the bridge\" state.\n *\n * Distinct from the pairing state on purpose: the extension may be perfectly\n * paired, but THIS process lost the race for 127.0.0.1:9224. Handing over a\n * pairing token here would send the user round a loop that cannot succeed —\n * the browser is already talking to the other instance.\n */\nconst PEER_INSTANCE_BODY = [\n 'Another copy of the Darwinium Portal bridge is already running on this machine',\n 'and owns the local connection to the browser (127.0.0.1:9224). Only one copy can',\n 'hold it at a time.',\n '',\n 'Portal tools will not work in THIS session until that copy exits. To fix it:',\n ' - Use the portal tools from the Claude session that is already connected, OR',\n ' - Quit the other Claude client (Claude Desktop, or Claude Code in a terminal)',\n ' and this session will take over the bridge automatically within a few seconds.',\n '',\n 'Do NOT tell the user to re-pair or paste a pairing token — the extension is not',\n 'the problem here, and pairing again will not resolve it.',\n];\n\n/** `initialize.instructions` variant for the peer-owns-the-bridge state. */\nexport function buildPeerInstanceInstructions(): string {\n return [\n 'The Darwinium Portal MCP server started, but could not claim the browser bridge.',\n '',\n ...PEER_INSTANCE_BODY,\n ].join('\\n');\n}\n\n/** Tool-result variant for the peer-owns-the-bridge state. */\nexport function buildPeerInstanceMessage(): string {\n return ['The Darwinium Portal bridge is not available to this session.', '', ...PEER_INSTANCE_BODY].join('\\n');\n}\n\n/**\n * Text returned in place of the NO_TAB error when a tool is called while the\n * extension is not connected. Same token + steps as the instructions block, kept\n * short for an inline tool result.\n */\nexport function buildPairingMessage(token: string): string {\n return [\n 'The Darwinium Portal browser extension is not connected yet.',\n '',\n 'To connect: open your Darwinium portal tab (*.darwinium.com or *.int.darwinium.io), click the',\n '\"Darwinium Portal MCP\" extension icon, paste this pairing token, and click',\n '\"Save & Connect\":',\n '',\n ` ${token}`,\n '',\n 'Then retry your request. (This token is a local pairing secret for 127.0.0.1 —',\n 'safe to share with the user, but not off-machine.)',\n ].join('\\n');\n}\n","/**\n * MCP `tools/call` handler for the static three-tool surface (ADR-001):\n * get_page_commands, run_page_command, get_context. Tool-name strings MUST\n * match tools.ts exactly. The binary keeps no page-id cache — expected_page_id\n * is checked via a live listCommands probe (see run_page_command below).\n */\nimport { z } from 'zod';\nimport type { Bridge } from '../bridge/Context.js';\nimport { capContextResponse } from './get-context.js';\nimport { buildPairingMessage, buildPeerInstanceMessage } from './pairingMessage.js';\n\ntype ToolCallReq = { params: { name: string; arguments?: Record<string, unknown> } };\n\n/**\n * Zod arg schema for `run_page_command`. A malicious or buggy MCP host could\n * send ill-typed `name`/`expected_page_id` values that mis-route the call or\n * trigger stack-trace-eliciting code paths on the page side, so the schema\n * validates the trust-boundary fields we control. Page-command payload\n * (`args.args`) stays `unknown` — page-side commands have their own arg\n * shapes; the binary's job is to forward, not to validate the entire matrix.\n * `passthrough()` keeps unknown sibling fields rather than stripping them.\n */\nconst RunPageCommandArgsSchema = z\n .object({\n name: z.string(),\n args: z.unknown().optional(),\n expected_page_id: z.string().optional(),\n })\n .passthrough();\n\n/**\n * Key a page command sets to return a picture rather than a description of one.\n * Must match IMAGE_ENVELOPE_KEY in aphex-frontend's common/mcp/screenshot.ts.\n */\nconst IMAGE_ENVELOPE_KEY = '__image';\n\n/** Base64 above this is refused: it would blow the host's response budget for no gain. */\nconst MAX_IMAGE_BASE64_BYTES = 4 * 1024 * 1024;\n\ntype ImagePayload = { mimeType: string; base64: string };\n\nconst readImageEnvelope = (result: unknown): ImagePayload | null => {\n if (!result || typeof result !== 'object') return null;\n const envelope = (result as Record<string, unknown>)[IMAGE_ENVELOPE_KEY];\n if (!envelope || typeof envelope !== 'object') return null;\n const { mimeType, base64 } = envelope as Record<string, unknown>;\n if (typeof mimeType !== 'string' || !mimeType.startsWith('image/')) return null;\n if (typeof base64 !== 'string' || base64 === '') return null;\n return { mimeType, base64 };\n};\n\n/**\n * Splits an image-carrying result into an MCP image block plus the remaining\n * fields as text. The base64 is never repeated in the text half — that is the\n * whole point: as JSON it is unreadable bulk, as an image block it is something\n * the model can look at.\n */\nconst buildImageResponse = (result: unknown, image: ImagePayload) => {\n const { [IMAGE_ENVELOPE_KEY]: envelope, ...rest } = result as Record<string, unknown>;\n const { base64: _omitted, ...meta } = (envelope ?? {}) as Record<string, unknown>;\n\n if (image.base64.length > MAX_IMAGE_BASE64_BYTES) {\n return {\n content: [\n {\n type: 'text',\n text: JSON.stringify(\n {\n ...rest,\n ...meta,\n error: `the capture is ${Math.round(image.base64.length / 1024)}KB, above the ${MAX_IMAGE_BASE64_BYTES / 1024 / 1024}MB limit`,\n hint: 'Re-run with a smaller maxWidth, format \"jpeg\", or a cardTitle to capture one card instead of the board.',\n },\n null,\n 2,\n ),\n },\n ],\n isError: true,\n };\n }\n\n return {\n content: [\n { type: 'text', text: JSON.stringify({ ...rest, ...meta }, null, 2) },\n { type: 'image', data: image.base64, mimeType: image.mimeType },\n ],\n };\n};\n\nexport function makeBridgeCallHandler(bridge: Bridge, token: string) {\n return async (req: ToolCallReq) => {\n const { name, arguments: rawArgs } = req.params;\n const args = (rawArgs ?? {}) as Record<string, unknown>;\n\n // Another live instance owns the bridge — pairing advice would be actively\n // misleading here, so say what is really wrong. Checked before hasWs()\n // because a non-owner can never have a socket.\n if (!bridge.ownsPort()) {\n return { content: [{ type: 'text', text: buildPeerInstanceMessage() }], isError: true };\n }\n\n // Not connected → return the pairing token + steps instead of a bare NO_TAB.\n // This is the \"ask Claude to connect\" path: any tool call while unpaired tells\n // the user exactly how to pair, with the real token to paste into the popup.\n if (!bridge.hasWs()) {\n return { content: [{ type: 'text', text: buildPairingMessage(token) }], isError: true };\n }\n\n try {\n switch (name) {\n case 'get_page_commands': {\n const result = await bridge.send('listCommands', {}, 10_000);\n return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };\n }\n\n case 'run_page_command': {\n // safeParse before any forwarding. On invalid args, return a\n // structured error response without invoking the bridge. The error\n // surfaces the failing field path + zod issue message so the MCP\n // host can correct its call.\n const parsed = RunPageCommandArgsSchema.safeParse(args);\n if (!parsed.success) {\n const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`).join('; ');\n return {\n content: [{ type: 'text', text: `Invalid run_page_command args: ${issues}` }],\n isError: true,\n };\n }\n const validated = parsed.data;\n\n // expected_page_id mismatch detection, stateless: resolve the\n // CURRENT page id with a live listCommands round-trip instead of a\n // server-side cache. The current page id is the first non-'global'\n // `_pageId` in the registry. If no per-page command is registered\n // (currentPageId null), proceed — the page response is authoritative.\n if (typeof validated.expected_page_id === 'string') {\n const commands = await bridge.send<Array<{ _pageId?: unknown }>>('listCommands', {}, 10_000);\n const currentPageId = Array.isArray(commands)\n ? (commands\n .map((c) => c._pageId)\n .find((pid): pid is string => typeof pid === 'string' && pid !== '' && pid !== 'global') ?? null)\n : null;\n if (currentPageId !== null && currentPageId !== validated.expected_page_id) {\n return bridge.errorResponse('PAGE_NAVIGATED', { newPageId: currentPageId });\n }\n }\n const result = await bridge.send('runCommand', { name: validated.name, args: validated.args }, 60_000);\n const image = readImageEnvelope(result);\n if (image) return buildImageResponse(result, image);\n return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };\n }\n\n case 'get_context': {\n const [contextResult, instructionsResult] = await Promise.all([\n bridge.send<unknown>('runCommand', { name: 'getCurrentNodeContext', args: {} }, 10_000),\n bridge.send<{ instructions: string }>('runCommand', { name: 'getDarwiniumInstructions', args: {} }, 10_000),\n ]);\n const contextObj = (contextResult ?? {}) as Record<string, unknown>;\n const capped = capContextResponse({\n ...contextObj,\n instructions: instructionsResult?.instructions ?? '',\n });\n return { content: [{ type: 'text', text: JSON.stringify(capped, null, 2) }] };\n }\n }\n throw new Error(`Unknown tool: ${name}`);\n } catch (err) {\n const msg = (err as Error).message;\n // Map known error modes to their structured responses.\n // PAGE_NAVIGATED is handled inline above (pre-call check); unreachable from this catch.\n if (msg === 'NO_TAB') return bridge.errorResponse('NO_TAB');\n if (msg === 'LOST_MID_CALL') return bridge.errorResponse('LOST_MID_CALL');\n // The SW's command-router emits 'TAB_STALE' on chrome.tabs.sendMessage\n // failures matching the extension-context-invalidation signature.\n // Bridge.onMessage rejects with new Error(String(msg.error)) so we\n // identity-match here just like the other modes.\n if (msg === 'TAB_STALE') return bridge.errorResponse('TAB_STALE');\n // Other errors (page command errors, schema errors) flow through as raw isError.\n return { content: [{ type: 'text', text: `Tool error: ${msg}` }], isError: true };\n }\n };\n}\n","// VENDORED from packages/darwinium-instructions/src/__generated__/assets.ts\n// DO NOT EDIT BY HAND. Regenerate with `node scripts/vendor-instructions.mjs`\n// after changing the source. `yarn build` rewrites this file and\n// `--check` fails CI on drift.\n\n// AUTO-GENERATED by scripts/buildAssets.mjs — do not edit by hand.\n// Regenerate via `yarn build:assets` after editing files under assets/.\nexport const SYNTAX_MD: string = \"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\";\nexport const FEATURES_TS: string = \"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\";\n","// VENDORED from packages/darwinium-instructions/src/labelList.ts\n// DO NOT EDIT BY HAND. Regenerate with `node scripts/vendor-instructions.mjs`\n// after changing the source. `yarn build` rewrites this file and\n// `--check` fails CI on drift.\n\n// Static label list, extracted from slack-nlp's system prompt. Future updates\n// flow through this file.\n//\n// This is the entire `* there's a function called checkLabel ...` prompt entry\n// — including the bracketed JSON-like string of possible label names — kept as\n// one template literal so the prompt content stays byte-identical.\n\nexport const STATIC_LABEL_LIST: string = `* 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\\\"]'\\`\\`\\``;\n","// VENDORED from packages/darwinium-instructions/src/promptBlocks.ts\n// DO NOT EDIT BY HAND. Regenerate with `node scripts/vendor-instructions.mjs`\n// after changing the source. `yarn build` rewrites this file and\n// `--check` fails CI on drift.\n\n// Static portion of the slack-nlp / aphex-frontend system prompt.\n//\n// Dynamic blocks (journeyFeaturesExplained, globalFeaturesExplained, contexts,\n// stepNames, signals) stay in slack-nlp's `buildDynamicQueryBlock`.\n//\n// Blocks are joined with the same `'\\n\\n---\\n\\n'` separator slack-nlp uses.\n\nimport { SYNTAX_MD, FEATURES_TS } from './__generated__/assets.js';\nimport { STATIC_LABEL_LIST } from './labelList.js';\n\nconst SEPARATOR = '\\n\\n---\\n\\n';\n\nexport function getStaticInstructions(): string {\n return [\n `*Instructions:*`,\n\n `* If a user appears to be asking for documentation, you should use the \\`get_documentation\\` tool. Just return that as a response.`,\n\n `<queries>`,\n\n `* 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.`,\n\n `* query syntax:\\n\\`\\`\\`\\n${SYNTAX_MD}\\n\\`\\`\\``,\n\n `* ignore ipdb and integrations.*; event disposition in outcome['CHAMPION'].decision_strategy.result`,\n\n `* don't use event_type in the query. You dont know what its possible values are`,\n\n `* 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\"`,\n\n `* // Comments prohibited; wildcards only with IN operator`,\n\n `* NEVER MAKE UP SIGNAL NAMES. Use only the signal names defined below `,\n\n `* ALWAYS TRY to use \\`get_attribute\\` to find inbuilt attributes before using features or signals `,\n\n `* ALWAYS use \\`get_attribute\\` to look up the correct attribute syntax before building queries. Do not guess attribute names.`,\n\n `* prefer device SIGNATURE over profiling.device.identifier whenever possible`,\n\n `* wildcards can be used. for example:\n \"192.168.0.1\" in profiling.tcp_connection['*'].ip_address\n \"ben@cool.com\" in identity['*'].email['PERSONAL'].email\n * 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.`,\n\n `* Model scores: outcome['CHAMPION'].models.score['<model>'] < -500`,\n\n `* Biometrics: null‐check before use (see docs)`,\n\n STATIC_LABEL_LIST,\n\n `* checkLabel must include at LEAST ONE attribute in the 2nd argument. Common options are device signature, email, username and login\n * checkLabel returns a Boolean. The result is always Boolean. If it is not a Boolean you are doing something wrong.\n * events are rarely labelled by customer. You should avoid RELYING on checkLabel to get results. ie. use it with OR to get more results`,\n\n `* signals in $.journeyMetadata are accessed using \\`has(outcome['CHAMPION'].models.signals, \"<signal_name>\")\\`\n * signals in $.globalJourneyMetadata are accessed using \\`has(profiling.device.signals, \"<signal_name>\")\\`\n * DO NOT GUESS SIGNAL NAMES. Use the signals in the arrays above to get the names.\n * has() only takes a single signal name, not an array`,\n\n `* 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.`,\n\n `* 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)`,\n\n `</queries>`,\n\n `<features>`,\n\n `* if generating features, output YAML in a format that strictly matches the following typescript schema:\\n\\`\\`\\`typescript\\n${FEATURES_TS}\\n\\`\\`\\``,\n\n `* 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`,\n\n `* if generating features code it must ALWAYS be backticked as \\`\\`\\`features\\n{{result}}\\n\\`\\`\\``,\n\n `* 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`,\n\n `</features>`,\n\n `<tool_calling>`,\n\n `* ALWAYS follow the tool call schema exactly as specified and make sure to provide all necessary parameters.`,\n\n `* 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\\`.`,\n\n `* 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.`,\n\n `* 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.`,\n\n `* When \\`run_page_command\\` for \\`getWorkspaceProblems\\` returns nothing, say so explicitly — \"no diagnostics reported by the language server\" — so the user knows you checked.`,\n\n `* 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.`,\n\n `* 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)`,\n\n `* 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. `,\n\n `* 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.`,\n\n `* when selecting columns on the table/grid using the \\`selectColumns\\` command, you should ALWAYS select \\`identifier\\` and \\`timestamp\\` columns.`,\n\n `* NEVER embed any screeenshots you capture in your response. Just provide your insights on the image and/or graph data.`,\n\n `</tool_calling>`,\n ].join(SEPARATOR);\n}\n","/**\n * `initialize.instructions` resolver — 5-second blocking window on the active tab's\n * `getDarwiniumInstructions` page command.\n *\n * Behavior matrix:\n * - WS not ready in `timeoutMs` → return the pairing instructions (steps + token).\n * - WS ready + page call OK → return `result.instructions ?? ''`.\n * - WS ready + page call THROWS → return a structured-error prefix string\n * (the bracket-tag prefix + explicit \"Do not act as if this is portal\n * guidance\" makes the error visible to the LLM without confusing it for actual\n * portal instructions).\n *\n * The 5-second host-blocking wait is intentional — MCP hosts tolerate long\n * initialize windows (Claude Desktop empirically tolerates 30+s).\n *\n * STATIC FALLBACK. When the tab cannot be reached, the static instruction block is\n * served instead of an error string. Without it, a freshly-installed but not-yet-paired\n * client has no Darwinium knowledge at all — it does not know the query syntax, the\n * feature schema or the label list — which is the first thing a new user hits. The\n * vendored copy is pure string constants with no filesystem or network access at\n * runtime (see scripts/vendor-instructions.mjs), so the cold-start concern that\n * originally kept it out no longer applies: nothing is read, only a string is built.\n *\n * The live `getDarwiniumInstructions` page command stays authoritative whenever the\n * tab IS reachable — it reflects the actual deployed portal version, which the\n * vendored snapshot cannot.\n */\nimport type { Bridge } from '../bridge/Context.js';\nimport { getStaticInstructions } from '../vendor/darwinium-instructions/promptBlocks.js';\nimport { buildPairingInstructions, buildPeerInstanceInstructions } from './pairingMessage.js';\n\n/**\n * Marks the static block as a snapshot rather than live portal state, so the model\n * does not report stale signal or label names as authoritative.\n */\nconst STATIC_FALLBACK_NOTE = [\n '',\n '---',\n '',\n 'NOTE: the Darwinium guidance above is a static snapshot bundled with this server,',\n 'not live state read from the portal. Query syntax and general guidance are reliable.',\n 'Signal names, feature names and label lists may be out of date — once the extension',\n 'is connected, call get_context to refresh them before relying on them.',\n].join('\\n');\n\n/**\n * Static instructions plus the snapshot caveat. Wrapped so a defect in the vendored\n * data can never take down `initialize` — degrading to the old error string is bad,\n * but failing the handshake outright would leave the client with no server at all.\n */\nfunction staticInstructionsBlock(reason: string): string {\n try {\n return getStaticInstructions() + STATIC_FALLBACK_NOTE;\n } catch (err) {\n return (\n `[portal-mcp ERROR initialize] Could not fetch Darwinium instructions: ${reason}, ` +\n `and the bundled fallback failed to load: ${(err as Error).message}. ` +\n `Call get_context to retrieve them on demand. Do not act as if this is portal guidance.`\n );\n }\n}\n\nexport async function resolveInitializeInstructions(\n bridge: Bridge,\n token: string,\n opts: { timeoutMs: number },\n): Promise<string> {\n // Another live instance owns the port, so no extension can ever reach THIS\n // process. Skip the blocking wait — it would burn the full timeout on every\n // launch — and say what is actually wrong instead of offering a token.\n if (!bridge.ownsPort()) return buildPeerInstanceInstructions();\n\n const wsReady = await bridge.waitForWs(opts.timeoutMs);\n // WS not ready = extension not paired yet. Instead of shipping an empty string\n // (which left the user with no way to discover the token), embed the pairing\n // steps + token so the LLM can hand it over when asked \"what's my token?\".\n // The static block follows, so the session is useful for query-writing help\n // even before the user has paired anything.\n if (!wsReady) {\n return `${buildPairingInstructions(token)}\\n\\n${staticInstructionsBlock('extension not connected')}`;\n }\n try {\n const result = await bridge.send<{ instructions: string }>(\n 'runCommand',\n { name: 'getDarwiniumInstructions', args: {} },\n 3000,\n );\n // An empty/absent payload is a reachable-but-unhelpful page (e.g. a portal\n // older than the command). Prefer the snapshot over nothing.\n return result.instructions || staticInstructionsBlock('page returned no instructions');\n } catch (err) {\n return staticInstructionsBlock((err as Error).message);\n }\n}\n","import { Server } from '@modelcontextprotocol/sdk/server/index.js';\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';\nimport {\n CallToolRequestSchema,\n InitializeRequestSchema,\n LATEST_PROTOCOL_VERSION,\n ListToolsRequestSchema,\n SUPPORTED_PROTOCOL_VERSIONS,\n} from '@modelcontextprotocol/sdk/types.js';\nimport { TOOLS } from './tools.js';\nimport { Bridge } from '../bridge/Context.js';\nimport { makeBridgeCallHandler } from './bridgeCallHandler.js';\nimport { resolveInitializeInstructions } from './instructions.js';\nimport { createWsServer, stopTakeoverPolling } from '../ws/server.js';\nimport { readToken } from '../token/store.js';\nimport { VERSION } from '../version.js';\n\nexport async function runServer(): Promise<void> {\n // Instructions intentionally undefined at construction; the InitializeRequestSchema\n // override below populates `instructions` dynamically from the active-tab\n // `getDarwiniumInstructions` (5s blocking window).\n const server = new Server(\n { name: 'darwinium-portal-mcp', version: VERSION },\n { capabilities: { tools: {} } }, // declare 'tools' capability ONLY — adding 'resources' would expand surface\n );\n\n // CRITICAL: declare ONLY the tools capability. Do NOT call setRequestHandler for\n // ListResourcesRequestSchema, ListPromptsRequestSchema, etc. — that auto-advertises\n // those capabilities and breaks the static-three-tool-surface guarantee (ADR-001).\n\n // Bridge owns the single _ws ref + pending Map — connection plumbing only,\n // no page-derived state. Constructed early so it can be passed to the call\n // handler, the InitializeRequestSchema handler, and the WS server.\n const bridge = new Bridge();\n\n // Read (or first-launch generate) the token up front — it's the single source\n // shared by the WS handshake, the initialize.instructions pairing block, and the\n // \"not connected\" tool responses so the user can connect by asking Claude for it.\n const expectedToken = readToken();\n\n server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));\n\n // Override InitializeRequestSchema for the 5s blocking window on\n // `getDarwiniumInstructions`. Mutating `server._instructions` post-construction\n // is unreliable; the request-handler override is the SDK's supported\n // dynamic-instructions path.\n server.setRequestHandler(InitializeRequestSchema, async (request) => {\n const requestedVersion = request.params.protocolVersion;\n const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)\n ? requestedVersion\n : LATEST_PROTOCOL_VERSION;\n const instructions = await resolveInitializeInstructions(bridge, expectedToken, { timeoutMs: 5000 });\n return {\n protocolVersion,\n capabilities: { tools: {} }, // mirror what we declared at construction\n serverInfo: { name: 'darwinium-portal-mcp', version: VERSION },\n ...(instructions && { instructions }),\n };\n });\n\n server.setRequestHandler(CallToolRequestSchema, makeBridgeCallHandler(bridge, expectedToken));\n\n // Resolve WS port ownership BEFORE connecting stdio. `initialize` branches on\n // bridge.ownsPort(), so if the host's initialize lands first it reads the\n // default (true) and hands out a pairing token even though another instance\n // owns the bridge — advice that cannot work. Ownership resolution is bounded\n // (a ~1.5s peer probe, or a ~5s reclaim poll) and MCP hosts tolerate far\n // longer startup windows, so ordering it first costs nothing.\n //\n // createWsServer never exits and never rejects on a port conflict — it\n // degrades to a bridge-less MCP server and polls to take over. The catch is\n // belt-and-braces: an unexpected throw must not stop stdio coming up, or the\n // host sees a dead server with no explanation.\n try {\n await createWsServer(bridge, expectedToken);\n } catch (err) {\n bridge.setOwnsPort(false);\n console.error(`portal-mcp: WS bridge unavailable: ${(err as Error).message}`);\n }\n\n // Bridge is attached on a successful post-upgrade hello-frame check.\n await server.connect(new StdioServerTransport());\n\n // Exit when the MCP host disconnects. Without this the process outlives its\n // host and keeps holding 127.0.0.1:9224 — and because only one instance can\n // own the bridge, that orphan starves every subsequent launch: the live\n // server sees a healthy peer, degrades, and never recovers. Tying the\n // lifecycle to stdio means a relaunched host reclaims the bridge within one\n // takeover poll, with no need to kill anything.\n //\n // stdin EOF is handled explicitly because StdioServerTransport only listens\n // for 'data' and 'error' — it never closes on end-of-input, so `onclose`\n // alone would not fire when the host simply closes the pipe.\n const shutdown = () => {\n stopTakeoverPolling();\n process.exit(0);\n };\n server.onclose = shutdown;\n process.stdin.on('end', shutdown);\n process.stdin.on('close', shutdown);\n}\n"],"mappings":"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"}
@@ -0,0 +1,78 @@
1
+ import { a as TOKEN_DIR, o as TOKEN_PATH } from "./killProcessOnPort-Bt5MA5Dv.js";
2
+ import * as fs from "node:fs";
3
+ import { randomBytes } from "node:crypto";
4
+
5
+ //#region src/token/store.ts
6
+ /**
7
+ * Token-file lifecycle for the binary↔extension WS handshake.
8
+ *
9
+ * The token lives at `<env-paths data dir>/token` (see src/token/paths.ts);
10
+ * legacy-location migration is handled by src/install/migrate.ts.
11
+ *
12
+ * First-launch generation prints the paste-into-popup hint to stderr; existing-
13
+ * token reads are SILENT — never re-print the token after first generation.
14
+ * Production install / rotate-token use OOB pairing and never echo the raw
15
+ * token — those callers pass `{silent:true}`. The dev-only `serve` path leaves
16
+ * `silent` unset, and its token-print line carries a `[SENSITIVE — clear
17
+ * scrollback after copy]` marker.
18
+ *
19
+ * Security: file mode 0o600 + dir mode 0o700; first-launch write uses
20
+ * `fs.openSync(..., 'wx', 0o600)` ('wx' = create exclusive — fails if exists),
21
+ * mitigating the TOCTOU race where mode is silently ignored on existing files.
22
+ * All output via `console.error` — stdout stays pure JSON-RPC (the smoke test
23
+ * asserts the first stdout line is JSON-RPC).
24
+ */
25
+ function readToken() {
26
+ ensureDir();
27
+ if (fs.existsSync(TOKEN_PATH)) return fs.readFileSync(TOKEN_PATH, "utf8").trim();
28
+ return generateAndWrite();
29
+ }
30
+ function ensureDir() {
31
+ fs.mkdirSync(TOKEN_DIR, {
32
+ recursive: true,
33
+ mode: 448
34
+ });
35
+ }
36
+ /**
37
+ * Generate a fresh 64-hex token, write it with TOCTOU-safe wx+0o600 mode, and
38
+ * (unless silent) print the dev-only paste-into-popup hint to stderr.
39
+ *
40
+ * `opts.silent === true` — production install / rotate-token. Suppresses the two
41
+ * paste-hint lines but keeps the audit-trail "generated new token at ..." line.
42
+ * The customer never sees the raw token; OOB pairing delivers it instead.
43
+ *
44
+ * `opts.silent !== true` — dev-mode `serve` standalone. Prints the paste hint,
45
+ * with the [SENSITIVE] marker on the raw-token line.
46
+ */
47
+ function generateAndWrite(opts) {
48
+ ensureDir();
49
+ const token = randomBytes(32).toString("hex");
50
+ const fd = fs.openSync(TOKEN_PATH, "wx", 384);
51
+ try {
52
+ fs.writeSync(fd, token);
53
+ } finally {
54
+ fs.closeSync(fd);
55
+ }
56
+ console.error(`portal-mcp: generated new token at ${TOKEN_PATH}`);
57
+ if (!opts?.silent) {
58
+ console.error(`portal-mcp: [SENSITIVE — clear scrollback after copy] token: ${token}`);
59
+ console.error(`portal-mcp: paste this into the Darwinium Portal MCP extension popup to pair`);
60
+ }
61
+ return token;
62
+ }
63
+ /**
64
+ * Force-regenerate the token: unlinks the existing file (if present) and writes
65
+ * a fresh one via `generateAndWrite`. Used by `rotate-token`.
66
+ *
67
+ * `opts.silent` is forwarded to `generateAndWrite`; production rotate-token
68
+ * passes `{silent:true}` so the new token is never echoed to stderr (OOB
69
+ * pairing delivers it).
70
+ */
71
+ function regenerate(opts) {
72
+ if (fs.existsSync(TOKEN_PATH)) fs.unlinkSync(TOKEN_PATH);
73
+ return generateAndWrite(opts);
74
+ }
75
+
76
+ //#endregion
77
+ export { readToken as n, regenerate as r, generateAndWrite as t };
78
+ //# sourceMappingURL=store-B3e4WCLw.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-B3e4WCLw.js","names":[],"sources":["../../src/token/store.ts"],"sourcesContent":["/**\n * Token-file lifecycle for the binary↔extension WS handshake.\n *\n * The token lives at `<env-paths data dir>/token` (see src/token/paths.ts);\n * legacy-location migration is handled by src/install/migrate.ts.\n *\n * First-launch generation prints the paste-into-popup hint to stderr; existing-\n * token reads are SILENT — never re-print the token after first generation.\n * Production install / rotate-token use OOB pairing and never echo the raw\n * token — those callers pass `{silent:true}`. The dev-only `serve` path leaves\n * `silent` unset, and its token-print line carries a `[SENSITIVE — clear\n * scrollback after copy]` marker.\n *\n * Security: file mode 0o600 + dir mode 0o700; first-launch write uses\n * `fs.openSync(..., 'wx', 0o600)` ('wx' = create exclusive — fails if exists),\n * mitigating the TOCTOU race where mode is silently ignored on existing files.\n * All output via `console.error` — stdout stays pure JSON-RPC (the smoke test\n * asserts the first stdout line is JSON-RPC).\n */\nimport * as fs from 'node:fs';\nimport { randomBytes } from 'node:crypto';\nimport { TOKEN_DIR, TOKEN_PATH } from './paths.js';\n\nexport function readToken(): string {\n ensureDir();\n if (fs.existsSync(TOKEN_PATH)) {\n // Existing-token path — SILENT; never re-print the token.\n return fs.readFileSync(TOKEN_PATH, 'utf8').trim();\n }\n // First-launch from `serve` standalone path: dev-mode stderr print (with\n // [SENSITIVE] marker). Production install/rotate-token paths use\n // generateAndWrite({silent:true}) directly, never reaching this branch.\n return generateAndWrite();\n}\n\nfunction ensureDir(): void {\n fs.mkdirSync(TOKEN_DIR, { recursive: true, mode: 0o700 });\n}\n\n/**\n * Generate a fresh 64-hex token, write it with TOCTOU-safe wx+0o600 mode, and\n * (unless silent) print the dev-only paste-into-popup hint to stderr.\n *\n * `opts.silent === true` — production install / rotate-token. Suppresses the two\n * paste-hint lines but keeps the audit-trail \"generated new token at ...\" line.\n * The customer never sees the raw token; OOB pairing delivers it instead.\n *\n * `opts.silent !== true` — dev-mode `serve` standalone. Prints the paste hint,\n * with the [SENSITIVE] marker on the raw-token line.\n */\nexport function generateAndWrite(opts?: { silent?: boolean }): string {\n ensureDir();\n const token = randomBytes(32).toString('hex');\n // 'wx' = create exclusive — fails if file already exists. Mitigates TOCTOU on perms\n // (`fs.writeFileSync(..., {mode})` silently ignores mode on existing files).\n const fd = fs.openSync(TOKEN_PATH, 'wx', 0o600);\n try {\n fs.writeSync(fd, token);\n } finally {\n fs.closeSync(fd);\n }\n // Audit-trail line — printed in BOTH silent and non-silent modes so\n // operators can observe token regeneration timing in logs.\n console.error(`portal-mcp: generated new token at ${TOKEN_PATH}`);\n if (!opts?.silent) {\n // Dev-mode branch — the [SENSITIVE] marker prefixes the raw-token line so\n // dev-machine scrollback carries an explicit \"clear after copy\" reminder.\n console.error(`portal-mcp: [SENSITIVE — clear scrollback after copy] token: ${token}`);\n console.error(`portal-mcp: paste this into the Darwinium Portal MCP extension popup to pair`);\n }\n return token;\n}\n\n/**\n * Force-regenerate the token: unlinks the existing file (if present) and writes\n * a fresh one via `generateAndWrite`. Used by `rotate-token`.\n *\n * `opts.silent` is forwarded to `generateAndWrite`; production rotate-token\n * passes `{silent:true}` so the new token is never echoed to stderr (OOB\n * pairing delivers it).\n */\nexport function regenerate(opts?: { silent?: boolean }): string {\n if (fs.existsSync(TOKEN_PATH)) {\n fs.unlinkSync(TOKEN_PATH);\n }\n return generateAndWrite(opts);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAuBA,SAAgB,YAAoB;AAClC,YAAW;AACX,KAAI,GAAG,WAAW,WAAW,CAE3B,QAAO,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;AAKnD,QAAO,kBAAkB;;AAG3B,SAAS,YAAkB;AACzB,IAAG,UAAU,WAAW;EAAE,WAAW;EAAM,MAAM;EAAO,CAAC;;;;;;;;;;;;;AAc3D,SAAgB,iBAAiB,MAAqC;AACpE,YAAW;CACX,MAAM,QAAQ,YAAY,GAAG,CAAC,SAAS,MAAM;CAG7C,MAAM,KAAK,GAAG,SAAS,YAAY,MAAM,IAAM;AAC/C,KAAI;AACF,KAAG,UAAU,IAAI,MAAM;WACf;AACR,KAAG,UAAU,GAAG;;AAIlB,SAAQ,MAAM,sCAAsC,aAAa;AACjE,KAAI,CAAC,MAAM,QAAQ;AAGjB,UAAQ,MAAM,gEAAgE,QAAQ;AACtF,UAAQ,MAAM,+EAA+E;;AAE/F,QAAO;;;;;;;;;;AAWT,SAAgB,WAAW,MAAqC;AAC9D,KAAI,GAAG,WAAW,WAAW,CAC3B,IAAG,WAAW,WAAW;AAE3B,QAAO,iBAAiB,KAAK"}
@@ -0,0 +1,164 @@
1
+ import * as os from "node:os";
2
+ import { spawnSync } from "node:child_process";
3
+
4
+ //#region src/install/winAcl.ts
5
+ /**
6
+ * Windows ACL helpers.
7
+ *
8
+ * Windows file mode bits don't restrict group/other access the way POSIX
9
+ * `0o600` does. To match POSIX semantics we shell out to `icacls`:
10
+ *
11
+ * icacls <path> /inheritance:r /grant:r "<user>:F"
12
+ *
13
+ * `/inheritance:r` removes inherited ACEs (otherwise group/other rights flow
14
+ * down from the parent dir); `/grant:r` replaces any existing ACEs for the
15
+ * named principal with the given mask; `<user>:F` grants Full Control to
16
+ * exactly the current user.
17
+ *
18
+ * The caller (`install.ts`) calls `lockAcl()` on BOTH the token file AND the
19
+ * parent dir so directory enumeration is also restricted.
20
+ *
21
+ * SECURITY — `spawnSync` with array-form argv prevents shell
22
+ * injection from domain-joined usernames containing backslashes (e.g.
23
+ * `DOMAIN\User`). The `cmd` returned in the result is the user-runnable shell
24
+ * form for the troubleshooting `Run \`<cmd>\` manually.` message — that string
25
+ * is built with proper double-quoting but is NOT executed.
26
+ *
27
+ * Non-Windows: every export is a no-op shape-correct stub. Doctor's
28
+ * `token.acl` check returns skipped on non-Windows; install/rotate's
29
+ * `lockAcl` calls short-circuit before the child_process import so this
30
+ * module is safe to lazy-import on macOS/Linux.
31
+ */
32
+ /**
33
+ * Lock `targetPath` so only the current user has Full Control.
34
+ *
35
+ * On non-Windows: returns `{ok:true, cmd:''}` — POSIX file mode 0600/0700
36
+ * (set elsewhere) already provides equivalent restriction.
37
+ */
38
+ function lockAcl(targetPath) {
39
+ if (process.platform !== "win32") return {
40
+ ok: true,
41
+ cmd: ""
42
+ };
43
+ const username = os.userInfo().username;
44
+ const cmd = `icacls "${targetPath}" /inheritance:r /grant:r "${username}:F"`;
45
+ const res = spawnSync("icacls", [
46
+ targetPath,
47
+ "/inheritance:r",
48
+ "/grant:r",
49
+ `${username}:F`
50
+ ], {
51
+ stdio: "pipe",
52
+ encoding: "utf8"
53
+ });
54
+ if (res.status !== 0) return {
55
+ ok: false,
56
+ error: res.stderr?.toString().trim() || `exit ${res.status}`,
57
+ cmd
58
+ };
59
+ return {
60
+ ok: true,
61
+ cmd
62
+ };
63
+ }
64
+ /**
65
+ * Inspect ACL state of `targetPath`. Doctor's `token.acl` check passes when
66
+ * `inheritanceBroken === true` (i.e., `lockAcl` has been applied).
67
+ *
68
+ * Non-Windows: returns `{inheritanceBroken: true, raw: 'skipped'}`. The
69
+ * caller surfaces this as a doctor `null` (skipped) result.
70
+ */
71
+ function readAclState(targetPath) {
72
+ if (process.platform !== "win32") return {
73
+ inheritanceBroken: true,
74
+ raw: "skipped"
75
+ };
76
+ const raw = spawnSync("icacls", [targetPath], {
77
+ stdio: "pipe",
78
+ encoding: "utf8"
79
+ }).stdout?.toString() ?? "";
80
+ return {
81
+ inheritanceBroken: !raw.split("\n").some((l) => /\(I\)/.test(l)),
82
+ raw
83
+ };
84
+ }
85
+
86
+ //#endregion
87
+ //#region src/install/term.ts
88
+ /**
89
+ * Terminal output helpers for the install/doctor/rotate-token CLI subcommands.
90
+ *
91
+ * - All output goes to STDERR (stdout stays clean for MCP JSON-RPC). The ONE
92
+ * explicit exception is `doctor --json`, which writes its single JSON object
93
+ * to stdout via `process.stdout.write` directly (NOT via these helpers).
94
+ * - Glyph alphabet: `✓` for ok, `⚠` for warn, `✗` for fail. Color is added on
95
+ * TTY only (and never when `NO_COLOR` is set). Color is NEVER the
96
+ * load-bearing signal — every line carries a glyph in addition to color.
97
+ * - Every helper carries the `portal-mcp: ` prefix so screen readers get an
98
+ * unambiguous source on every line.
99
+ *
100
+ * No external dependencies (no chalk / ora / boxen).
101
+ */
102
+ const ANSI = {
103
+ RESET: "\x1B[0m",
104
+ BOLD: "\x1B[1m",
105
+ DIM: "\x1B[2m",
106
+ GREEN: "\x1B[32m",
107
+ YELLOW: "\x1B[33m",
108
+ RED: "\x1B[31m"
109
+ };
110
+ /** True if stderr is a TTY AND the customer hasn't opted out via `NO_COLOR`. */
111
+ function shouldColor() {
112
+ return process.stderr.isTTY === true && !process.env.NO_COLOR;
113
+ }
114
+ /** Plain `portal-mcp: <msg>` line — no glyph, no color. */
115
+ function info(msg) {
116
+ console.error(`portal-mcp: ${msg}`);
117
+ }
118
+ /** Success line: `portal-mcp: ✓ <msg>`. Glyph green on TTY+!NO_COLOR. */
119
+ function ok(msg) {
120
+ const g = shouldColor() ? `${ANSI.GREEN}✓${ANSI.RESET}` : "✓";
121
+ console.error(`portal-mcp: ${g} ${msg}`);
122
+ }
123
+ /** Warning line: `portal-mcp: ⚠ <msg>`. Glyph yellow on TTY+!NO_COLOR. */
124
+ function warn(msg) {
125
+ const w = shouldColor() ? `${ANSI.YELLOW}⚠${ANSI.RESET}` : "⚠";
126
+ console.error(`portal-mcp: ${w} ${msg}`);
127
+ }
128
+ /** Failure line: `portal-mcp: ✗ <msg>`. Glyph red on TTY+!NO_COLOR. */
129
+ function fail(msg) {
130
+ const f = shouldColor() ? `${ANSI.RED}✗${ANSI.RESET}` : "✗";
131
+ console.error(`portal-mcp: ${f} ${msg}`);
132
+ }
133
+ /** Dim helper — used for "skipped" doctor rows; falls back to plain on no-color. */
134
+ function dim(msg) {
135
+ return shouldColor() ? `${ANSI.DIM}${msg}${ANSI.RESET}` : msg;
136
+ }
137
+ /** Bold helper — reserved for the doctor "Summary:" heading. */
138
+ function bold(msg) {
139
+ return shouldColor() ? `${ANSI.BOLD}${msg}${ANSI.RESET}` : msg;
140
+ }
141
+ /**
142
+ * Render `lines` inside a 64-column Unicode box (60 inner content columns).
143
+ * Box characters: `┌ ┐ └ ┘ ─ │`. Indented 2 spaces from line start.
144
+ *
145
+ * Each input line is padded (right) to 60 columns so the box's right border
146
+ * aligns. Lines longer than 60 columns are truncated (callers should keep
147
+ * lines short — the "Pairing code: XXXXXX" content fits comfortably).
148
+ */
149
+ function box(lines) {
150
+ const INNER = 60;
151
+ const INDENT = " ";
152
+ const top = `${INDENT}┌${"─".repeat(INNER)}┐`;
153
+ const bottom = `${INDENT}└${"─".repeat(INNER)}┘`;
154
+ console.error(top);
155
+ for (const line of lines) {
156
+ const padded = line.length >= INNER ? line.slice(0, INNER) : line + " ".repeat(INNER - line.length);
157
+ console.error(`${INDENT}│${padded}│`);
158
+ }
159
+ console.error(bottom);
160
+ }
161
+
162
+ //#endregion
163
+ export { info as a, lockAcl as c, fail as i, readAclState as l, box as n, ok as o, dim as r, warn as s, bold as t };
164
+ //# sourceMappingURL=term-Bg7icwx7.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"term-Bg7icwx7.js","names":[],"sources":["../../src/install/winAcl.ts","../../src/install/term.ts"],"sourcesContent":["/**\n * Windows ACL helpers.\n *\n * Windows file mode bits don't restrict group/other access the way POSIX\n * `0o600` does. To match POSIX semantics we shell out to `icacls`:\n *\n * icacls <path> /inheritance:r /grant:r \"<user>:F\"\n *\n * `/inheritance:r` removes inherited ACEs (otherwise group/other rights flow\n * down from the parent dir); `/grant:r` replaces any existing ACEs for the\n * named principal with the given mask; `<user>:F` grants Full Control to\n * exactly the current user.\n *\n * The caller (`install.ts`) calls `lockAcl()` on BOTH the token file AND the\n * parent dir so directory enumeration is also restricted.\n *\n * SECURITY — `spawnSync` with array-form argv prevents shell\n * injection from domain-joined usernames containing backslashes (e.g.\n * `DOMAIN\\User`). The `cmd` returned in the result is the user-runnable shell\n * form for the troubleshooting `Run \\`<cmd>\\` manually.` message — that string\n * is built with proper double-quoting but is NOT executed.\n *\n * Non-Windows: every export is a no-op shape-correct stub. Doctor's\n * `token.acl` check returns skipped on non-Windows; install/rotate's\n * `lockAcl` calls short-circuit before the child_process import so this\n * module is safe to lazy-import on macOS/Linux.\n */\nimport { spawnSync } from 'node:child_process';\nimport * as os from 'node:os';\n\n/** Outcome of `lockAcl(path)`. */\nexport interface LockAclResult {\n /** True if ACL was locked (or platform is non-Windows — nothing to do). */\n ok: boolean;\n /** Stderr from icacls or `exit <N>` description if `ok === false`. */\n error?: string;\n /** User-runnable shell form for the troubleshooting message. Empty on no-op. */\n cmd: string;\n}\n\n/**\n * Lock `targetPath` so only the current user has Full Control.\n *\n * On non-Windows: returns `{ok:true, cmd:''}` — POSIX file mode 0600/0700\n * (set elsewhere) already provides equivalent restriction.\n */\nexport function lockAcl(targetPath: string): LockAclResult {\n if (process.platform !== 'win32') {\n return { ok: true, cmd: '' };\n }\n const username = os.userInfo().username;\n // User-runnable troubleshooting form — built with double-quoting because the\n // message will be copy-pasted into a Windows shell. Note the explicit\n // double-quotes around the username — domain-joined usernames may contain\n // backslashes which need quoting in cmd.exe.\n const cmd = `icacls \"${targetPath}\" /inheritance:r /grant:r \"${username}:F\"`;\n const res = spawnSync('icacls', [targetPath, '/inheritance:r', '/grant:r', `${username}:F`], {\n stdio: 'pipe',\n encoding: 'utf8',\n });\n if (res.status !== 0) {\n return {\n ok: false,\n error: res.stderr?.toString().trim() || `exit ${res.status}`,\n cmd,\n };\n }\n return { ok: true, cmd };\n}\n\n/** Outcome of `readAclState(path)` — used by the doctor `token.acl` check. */\nexport interface ReadAclStateResult {\n /** True if no `(I)` (inherited) ACE was found in icacls output. */\n inheritanceBroken: boolean;\n /** Raw icacls stdout for diagnostic display in doctor `--json`. */\n raw: string;\n}\n\n/**\n * Inspect ACL state of `targetPath`. Doctor's `token.acl` check passes when\n * `inheritanceBroken === true` (i.e., `lockAcl` has been applied).\n *\n * Non-Windows: returns `{inheritanceBroken: true, raw: 'skipped'}`. The\n * caller surfaces this as a doctor `null` (skipped) result.\n */\nexport function readAclState(targetPath: string): ReadAclStateResult {\n if (process.platform !== 'win32') {\n return { inheritanceBroken: true, raw: 'skipped' };\n }\n const res = spawnSync('icacls', [targetPath], {\n stdio: 'pipe',\n encoding: 'utf8',\n });\n const raw = res.stdout?.toString() ?? '';\n // `(I)` flag in any line means at least one ACE is inherited from the parent.\n // A locked file should have NO line containing `(I)`.\n const inheritanceBroken = !raw.split('\\n').some((l) => /\\(I\\)/.test(l));\n return { inheritanceBroken, raw };\n}\n","/**\n * Terminal output helpers for the install/doctor/rotate-token CLI subcommands.\n *\n * - All output goes to STDERR (stdout stays clean for MCP JSON-RPC). The ONE\n * explicit exception is `doctor --json`, which writes its single JSON object\n * to stdout via `process.stdout.write` directly (NOT via these helpers).\n * - Glyph alphabet: `✓` for ok, `⚠` for warn, `✗` for fail. Color is added on\n * TTY only (and never when `NO_COLOR` is set). Color is NEVER the\n * load-bearing signal — every line carries a glyph in addition to color.\n * - Every helper carries the `portal-mcp: ` prefix so screen readers get an\n * unambiguous source on every line.\n *\n * No external dependencies (no chalk / ora / boxen).\n */\n\nconst ANSI = {\n RESET: '\\x1b[0m',\n BOLD: '\\x1b[1m',\n DIM: '\\x1b[2m',\n GREEN: '\\x1b[32m',\n YELLOW: '\\x1b[33m',\n RED: '\\x1b[31m',\n};\n\n/** True if stderr is a TTY AND the customer hasn't opted out via `NO_COLOR`. */\nfunction shouldColor(): boolean {\n return process.stderr.isTTY === true && !process.env.NO_COLOR;\n}\n\n/** Plain `portal-mcp: <msg>` line — no glyph, no color. */\nexport function info(msg: string): void {\n console.error(`portal-mcp: ${msg}`);\n}\n\n/** Success line: `portal-mcp: ✓ <msg>`. Glyph green on TTY+!NO_COLOR. */\nexport function ok(msg: string): void {\n const g = shouldColor() ? `${ANSI.GREEN}✓${ANSI.RESET}` : '✓';\n console.error(`portal-mcp: ${g} ${msg}`);\n}\n\n/** Warning line: `portal-mcp: ⚠ <msg>`. Glyph yellow on TTY+!NO_COLOR. */\nexport function warn(msg: string): void {\n const w = shouldColor() ? `${ANSI.YELLOW}⚠${ANSI.RESET}` : '⚠';\n console.error(`portal-mcp: ${w} ${msg}`);\n}\n\n/** Failure line: `portal-mcp: ✗ <msg>`. Glyph red on TTY+!NO_COLOR. */\nexport function fail(msg: string): void {\n const f = shouldColor() ? `${ANSI.RED}✗${ANSI.RESET}` : '✗';\n console.error(`portal-mcp: ${f} ${msg}`);\n}\n\n/** Dim helper — used for \"skipped\" doctor rows; falls back to plain on no-color. */\nexport function dim(msg: string): string {\n return shouldColor() ? `${ANSI.DIM}${msg}${ANSI.RESET}` : msg;\n}\n\n/** Bold helper — reserved for the doctor \"Summary:\" heading. */\nexport function bold(msg: string): string {\n return shouldColor() ? `${ANSI.BOLD}${msg}${ANSI.RESET}` : msg;\n}\n\n/**\n * Render `lines` inside a 64-column Unicode box (60 inner content columns).\n * Box characters: `┌ ┐ └ ┘ ─ │`. Indented 2 spaces from line start.\n *\n * Each input line is padded (right) to 60 columns so the box's right border\n * aligns. Lines longer than 60 columns are truncated (callers should keep\n * lines short — the \"Pairing code: XXXXXX\" content fits comfortably).\n */\nexport function box(lines: string[]): void {\n const INNER = 60;\n const INDENT = ' ';\n const top = `${INDENT}┌${'─'.repeat(INNER)}┐`;\n const bottom = `${INDENT}└${'─'.repeat(INNER)}┘`;\n console.error(top);\n for (const line of lines) {\n const padded = line.length >= INNER ? line.slice(0, INNER) : line + ' '.repeat(INNER - line.length);\n console.error(`${INDENT}│${padded}│`);\n }\n console.error(bottom);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8CA,SAAgB,QAAQ,YAAmC;AACzD,KAAI,QAAQ,aAAa,QACvB,QAAO;EAAE,IAAI;EAAM,KAAK;EAAI;CAE9B,MAAM,WAAW,GAAG,UAAU,CAAC;CAK/B,MAAM,MAAM,WAAW,WAAW,6BAA6B,SAAS;CACxE,MAAM,MAAM,UAAU,UAAU;EAAC;EAAY;EAAkB;EAAY,GAAG,SAAS;EAAI,EAAE;EAC3F,OAAO;EACP,UAAU;EACX,CAAC;AACF,KAAI,IAAI,WAAW,EACjB,QAAO;EACL,IAAI;EACJ,OAAO,IAAI,QAAQ,UAAU,CAAC,MAAM,IAAI,QAAQ,IAAI;EACpD;EACD;AAEH,QAAO;EAAE,IAAI;EAAM;EAAK;;;;;;;;;AAkB1B,SAAgB,aAAa,YAAwC;AACnE,KAAI,QAAQ,aAAa,QACvB,QAAO;EAAE,mBAAmB;EAAM,KAAK;EAAW;CAMpD,MAAM,MAJM,UAAU,UAAU,CAAC,WAAW,EAAE;EAC5C,OAAO;EACP,UAAU;EACX,CACc,CAAC,QAAQ,UAAU,IAAI;AAItC,QAAO;EAAE,oBADkB,IAAI,MAAM,KAAK,CAAC,MAAM,MAAM,QAAQ,KAAK,EAAE,CAAC;EAC3C;EAAK;;;;;;;;;;;;;;;;;;;AClFnC,MAAM,OAAO;CACX,OAAO;CACP,MAAM;CACN,KAAK;CACL,OAAO;CACP,QAAQ;CACR,KAAK;CACN;;AAGD,SAAS,cAAuB;AAC9B,QAAO,QAAQ,OAAO,UAAU,QAAQ,CAAC,QAAQ,IAAI;;;AAIvD,SAAgB,KAAK,KAAmB;AACtC,SAAQ,MAAM,eAAe,MAAM;;;AAIrC,SAAgB,GAAG,KAAmB;CACpC,MAAM,IAAI,aAAa,GAAG,GAAG,KAAK,MAAM,GAAG,KAAK,UAAU;AAC1D,SAAQ,MAAM,eAAe,EAAE,GAAG,MAAM;;;AAI1C,SAAgB,KAAK,KAAmB;CACtC,MAAM,IAAI,aAAa,GAAG,GAAG,KAAK,OAAO,GAAG,KAAK,UAAU;AAC3D,SAAQ,MAAM,eAAe,EAAE,GAAG,MAAM;;;AAI1C,SAAgB,KAAK,KAAmB;CACtC,MAAM,IAAI,aAAa,GAAG,GAAG,KAAK,IAAI,GAAG,KAAK,UAAU;AACxD,SAAQ,MAAM,eAAe,EAAE,GAAG,MAAM;;;AAI1C,SAAgB,IAAI,KAAqB;AACvC,QAAO,aAAa,GAAG,GAAG,KAAK,MAAM,MAAM,KAAK,UAAU;;;AAI5D,SAAgB,KAAK,KAAqB;AACxC,QAAO,aAAa,GAAG,GAAG,KAAK,OAAO,MAAM,KAAK,UAAU;;;;;;;;;;AAW7D,SAAgB,IAAI,OAAuB;CACzC,MAAM,QAAQ;CACd,MAAM,SAAS;CACf,MAAM,MAAM,GAAG,OAAO,GAAG,IAAI,OAAO,MAAM,CAAC;CAC3C,MAAM,SAAS,GAAG,OAAO,GAAG,IAAI,OAAO,MAAM,CAAC;AAC9C,SAAQ,MAAM,IAAI;AAClB,MAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,SAAS,KAAK,UAAU,QAAQ,KAAK,MAAM,GAAG,MAAM,GAAG,OAAO,IAAI,OAAO,QAAQ,KAAK,OAAO;AACnG,UAAQ,MAAM,GAAG,OAAO,GAAG,OAAO,GAAG;;AAEvC,SAAQ,MAAM,OAAO"}
@@ -0,0 +1,100 @@
1
+ # ADR-001: Static MCP Tool Surface
2
+
3
+ **Status:** Accepted
4
+ **Date:** 2026-05-01
5
+ **Deciders:** Darwinium engineering
6
+
7
+ ## Context
8
+
9
+ The MCP TypeScript SDK supports `notifications/tools/list_changed` for dynamically
10
+ updating the tool surface advertised to MCP hosts. However, the two MCP hosts that
11
+ Darwinium customers will use (Claude Desktop and Claude Code) cache `tools/list`
12
+ responses across navigations and do not reliably honor `tools/list_changed`:
13
+
14
+ - claude-code#7519 — host caches tool list across navigations
15
+ - claude-code#13646 — list_changed notifications not refreshing the cached list
16
+ - claude-code#3095 — MCP server cache not refreshing after rebuild
17
+
18
+ Reference implementations confirm the same pattern:
19
+
20
+ - BrowserMCP (`github.com/BrowserMCP/mcp`) ships with a fixed three-tool surface
21
+ (`browser_navigate`, `browser_click`, `browser_screenshot`) and routes dynamism
22
+ through tool returns rather than schema mutation.
23
+ - Microsoft Playwright MCP (`github.com/microsoft/playwright-mcp`) — same shape,
24
+ the original tool inspiration.
25
+
26
+ Furthermore, Darwinium's existing in-portal `ChatModal` (slack-nlp-backed) ALREADY
27
+ uses a static-tool-surface pattern (`getPageCommands` ≈ `tools/list`,
28
+ `runPageCommand` ≈ `tools/call`) — proving the pattern works in production with the
29
+ Darwinium portal's page-command registry.
30
+
31
+ ## Decision
32
+
33
+ The portal-mcp binary advertises **EXACTLY THREE** MCP tools, forever:
34
+
35
+ 1. `get_page_commands` — returns the active tab's currently-registered page commands
36
+ (name, description, args schema, _pageId).
37
+ 2. `run_page_command` — invokes a named page command with args; accepts an optional
38
+ `expected_page_id` to reject stale calls (Phase 2).
39
+ 3. `get_context` — returns combined static instructions + current page Viper context
40
+ for the active tab (Phase 2).
41
+
42
+ Page commands are routed *through* `get_page_commands` (returns the dynamic list)
43
+ and `run_page_command` (invokes by name). The dynamic surface lives in the page,
44
+ not in the MCP `tools/list` schema.
45
+
46
+ ## Consequences
47
+
48
+ **Positive:**
49
+ - No "restart your MCP host" UX when pages register/unregister commands.
50
+ - One stable surface across all portal releases — customers don't need to re-pair
51
+ after a portal deploy.
52
+ - Mirrors the in-portal slack-nlp `get_page_commands`-first pattern that customers
53
+ already rely on (`runMyNlpService.ts:231` tells the LLM to "ALWAYS call
54
+ `get_page_commands` first").
55
+
56
+ **Negative:**
57
+ - One extra round-trip per session for the LLM to discover page commands.
58
+ Mitigated by `initialize.instructions` (Phase 2) instructing the LLM to call
59
+ `get_page_commands` first.
60
+
61
+ **Phase 1 security note (acknowledged, not a violation):**
62
+ The Phase 1 binary accepts any WebSocket connection on `127.0.0.1:9224` (no token
63
+ gate yet — that's BRIDGE-02 / Phase 2). Acceptable for Phase 1 because:
64
+ - Bind is loopback-only (`127.0.0.1`) — not LAN-reachable.
65
+ - Phase 1's only consumers are local processes (manual `wscat` test client; the
66
+ extension SW WS client is Phase 2).
67
+ - Token handshake is the explicit Phase 2 deliverable.
68
+
69
+ **Symbol-keyed registry hardening note:**
70
+ The page-side `window[Symbol.for('darwinium.pageCommands')]` registry (Plan 04)
71
+ closes the **named-global hijack** vector (third-party scripts can no longer do
72
+ `window.getAvailableCommands = badFn`) but does NOT close the **symbol-known
73
+ hijack** vector — a script that knows the literal `Symbol.for('darwinium.pageCommands')`
74
+ can still overwrite. This is documented in PageContextProvider source comments and
75
+ in the Phase 1 RESEARCH.md (Finding 6). The symbol-keyed pattern raises the bar; it
76
+ is not a substitute for CSP discipline.
77
+
78
+ ## Enforcement
79
+
80
+ 1. **Smoke test gate (primary).** `dwn_aphex/packages/portal-mcp/scripts/smoke-test.ts`
81
+ asserts `tools/list` response has exactly 3 entries with names
82
+ `get_page_commands`, `run_page_command`, `get_context`. Runs as part of
83
+ `yarn workspace @darwinium/portal-mcp test`. This is the REL-01 CI gate; deterministic,
84
+ no false positives.
85
+ 2. **PR template question (soft gate).** When adding a 4th tool, the PR description
86
+ must reference this ADR. (Repo does not currently have a PR template; this is
87
+ aspirational — implement when CI infrastructure is added.)
88
+ 3. **No GitHub Actions workflow** (per CONTEXT.md `<canonical_refs>` Open Decision #2):
89
+ the repo has no `.github/workflows/` directory. The smoke test inside
90
+ `yarn workspace @darwinium/portal-mcp test` is the deterministic gate. A future CI
91
+ workflow only needs to invoke `yarn workspace @darwinium/portal-mcp test`.
92
+
93
+ ## References
94
+
95
+ - https://modelcontextprotocol.io/specification/2025-11-25/server/tools
96
+ - https://github.com/anthropics/claude-code/issues/7519
97
+ - https://github.com/anthropics/claude-code/issues/13646
98
+ - https://github.com/anthropics/claude-code/issues/3095
99
+ - https://github.com/BrowserMCP/mcp (reference impl using the same pattern)
100
+ - PROJECT.md "Static MCP tool surface, exactly 3 tools forever" (load-bearing decision)
File without changes
Binary file