diff --git a/.gitignore b/.gitignore index a66eec216..cf9cc6a9d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ nul # Wildcard patterns for component plugins /src/essence/*Private-Components* /src/essence/*Plugin-Components* +!/src/essence/MMGIS-Plugin-Components/ /src/pre/tools.js /src/pre/components.js @@ -54,8 +55,6 @@ sessions .terraform/ .terraform.lock.hcl - -.mcp.json .serena .claude/* !.claude/skills/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..d60d9619b --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "mmgis": { + "command": "node", + "args": ["mcp/dist/index.js"], + "env": { + "MMGIS_URL": "http://localhost:8888", + "MMGIS_TOKEN": "${MMGIS_TOKEN}" + } + } + } +} diff --git a/AGENTS.md b/AGENTS.md index 360ab55e4..b637082d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,8 @@ MMGIS/ │ └── Ancillary/ # UI components and helpers ├── configure/ # Admin configuration interface │ └── build/ # Configuration UI +├── mcp/ # MCP server: agents drive MMGIS + generate dashboards +├── chat/ # Standalone chat UI: OpenAI-driven MMGIS control via the MCP server ├── docs/ # Documentation (Jekyll site) ├── public/ # Static assets ├── Missions/ # Mission data storage diff --git a/API/Backend/Config/routes/configs.js b/API/Backend/Config/routes/configs.js index ec7fbc36f..d259e1955 100644 --- a/API/Backend/Config/routes/configs.js +++ b/API/Backend/Config/routes/configs.js @@ -14,6 +14,7 @@ const Config = require("../models/config"); const config_template = require("../../../templates/config_template"); const userModel = require("../../Users/models/user"); const User = userModel.User; +const { isSuperAdminRequest } = require("../../Utils/permissions"); // Sanitize user input to prevent XSS in error messages function sanitizeInput(input) { @@ -381,7 +382,8 @@ function add(req, res, next, cb) { if (fullAccess) router.post("/add", function (req, res, next) { - if (req.session.permission !== "111") { + const isSuperAdmin = isSuperAdminRequest(req); + if (!isSuperAdmin) { res.send({ status: "failure", message: "Only SuperAdmins can add new missions.", diff --git a/API/Backend/Users/routes/users.js b/API/Backend/Users/routes/users.js index 60be2a0a4..b706715e4 100644 --- a/API/Backend/Users/routes/users.js +++ b/API/Backend/Users/routes/users.js @@ -11,6 +11,7 @@ const buf = crypto.randomBytes(128); const logger = require("../../../logger"); const userModel = require("../models/user"); const User = userModel.User; +const { isSuperAdminRequest } = require("../../Utils/permissions"); function isStrongPassword(password) { const minLength = 8; @@ -81,12 +82,12 @@ router.post("/first_signup", function (req, res, next) { router.post("/signup", function (req, res, next) { if ( (process.env.AUTH === "local" && - req.session.permission !== "111" && + !isSuperAdminRequest(req) && !( process.env.AUTH_LOCAL_ALLOW_SIGNUP === true || process.env.AUTH_LOCAL_ALLOW_SIGNUP === "true" )) || - (process.env.AUTH === "off" && req.session.permission !== "111") + (process.env.AUTH === "off" && !isSuperAdminRequest(req)) ) { res.send({ status: "failure", diff --git a/API/Backend/Users/setup.js b/API/Backend/Users/setup.js index 6dbd2904d..c6d95d3a9 100644 --- a/API/Backend/Users/setup.js +++ b/API/Backend/Users/setup.js @@ -5,7 +5,12 @@ const userModel = require("./models/user"); let setup = { //Once the app initializes onceInit: (s) => { - s.app.use(s.ROOT_PATH + "/api/users", s.checkHeadersCodeInjection, router); + s.app.use( + s.ROOT_PATH + "/api/users", + s.checkHeadersCodeInjection, + s.annotateLongTermToken, + router + ); }, //Once the server starts onceStarted: (s) => {}, diff --git a/API/Backend/Utils/permissions.js b/API/Backend/Utils/permissions.js new file mode 100644 index 000000000..5d7ead76b --- /dev/null +++ b/API/Backend/Utils/permissions.js @@ -0,0 +1,7 @@ +function isSuperAdminRequest(req) { + return ( + (req.session && req.session.permission === "111") || + (req.isLongTermToken === true && req.tokenUserPermission === "111") + ); +} +module.exports = { isSuperAdminRequest }; diff --git a/chat/.env.example b/chat/.env.example new file mode 100644 index 000000000..e50f60aa2 --- /dev/null +++ b/chat/.env.example @@ -0,0 +1,22 @@ +# Your OpenAI API key (required). Never sent to the browser. +OPENAI_API_KEY=sk-... +OPENAI_MODEL=gpt-4o +CHAT_PORT=8895 + +# How to launch the MMGIS MCP server (relative paths resolve against chat/) +MCP_COMMAND=node +MCP_ARGS=../mcp/dist/index.js + +# Passed through to the MCP server process. +# MMGIS_URL is YOUR running MMGIS instance (whatever PORT its .env uses; 8888 by default). +# MMGIS_TOKEN is an MMGIS API token you generate yourself: log into MMGIS as an +# admin, open the Configure page, and use the "API Tokens" tab. It is not a +# shared secret — each person mints their own, and it inherits that account's +# permissions (creating missions needs a SuperAdmin token). +MMGIS_URL=http://localhost:8888 +MMGIS_TOKEN= +MAPBOX_TOKEN= + +# Where the dashboard UI is served, if different from MMGIS_URL +# (in dev, webpack serves the UI on PORT+1 and its redirect drops query params) +MMGIS_DASHBOARD_URL=http://localhost:8889 diff --git a/chat/.gitignore b/chat/.gitignore new file mode 100644 index 000000000..713d5006d --- /dev/null +++ b/chat/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/chat/README.md b/chat/README.md new file mode 100644 index 000000000..dffb5695f --- /dev/null +++ b/chat/README.md @@ -0,0 +1,73 @@ +# MMGIS Chat UI + +A standalone chat app for driving MMGIS with your own OpenAI key: describe a +dashboard, watch the MCP tools fire, open the result, and steer the live map — +all from a browser chat. + +## Quickstart + +1. Build the MCP server once: `cd ../mcp && npm install && npm run build` +2. `cd chat && npm install` +3. `cp .env.example .env` and set: + - `OPENAI_API_KEY` — your own OpenAI key (stays server-side, never sent to the browser) + - `MMGIS_URL` — your running MMGIS instance, e.g. `http://localhost:8888` + - `MMGIS_TOKEN` — an MMGIS API token you generate in MMGIS's **Configure → + API Tokens** tab (each person mints their own; it inherits that account's + permissions, and creating missions needs a SuperAdmin token) +4. `npm start` → open http://localhost:8895 + +## What you can do + +- "Create an air quality dashboard over Atlanta" → watch `catalog_*` + + `dashboard_generate` fire; click "Open dashboard →". +- "Show me the config JSON for that dashboard" → the model calls + `dashboard_generate` with `returnConfig: true`; copy the JSON from the tool card. +- **JSON config** drawer → paste/edit config JSON, name it, "Create dashboard + from JSON" (runs `dashboard_create_from_config` through the agent, visibly). +- With a dashboard open in another tab: "fly the map to Huntsville" (`view_*` + tools drive that session over the MMGIS websocket). +- "Make the OSM layer 50% transparent", "rename the page to Flood Watch then reload the view", "upload this GeoJSON and add it as a layer", "delete the JSON Demo mission" → live config editing and confirmation workflows. + +## How it works + +Browser (static page, SSE) → `server.js` (Express; your key stays here) → +OpenAI function calling → MCP client over stdio → `../mcp/dist/index.js` → +MMGIS REST + websocket. Conversation state lives in your browser +(localStorage); the server is stateless. + +## Env vars + +| Var | Default | Purpose | +| --- | --- | --- | +| `OPENAI_API_KEY` | (required) | Server-side only | +| `OPENAI_MODEL` | `gpt-4o` | Chat model | +| `CHAT_PORT` | `8895` | UI port | +| `MCP_COMMAND` / `MCP_ARGS` | `node` / `../mcp/dist/index.js` | MCP server launch (paths relative to `chat/`) | +| `MMGIS_URL` | `http://localhost:8888` | Your running MMGIS instance | +| `MMGIS_TOKEN` | — | MMGIS API token (Configure → API Tokens) | +| `MAPBOX_TOKEN`, `STAC_CATALOGS`, ... | — | Passed through to the MCP server | + +## Known limitations + +- Cross-turn memory only replays user/assistant text — tool results are not + persisted between turns. If you need the model to recall a config from + earlier in the conversation, ask it to echo the config back rather than + relying on it to remember the raw tool output. +- Very large configs may not round-trip faithfully through the JSON drawer + (the model can hit output length limits when repeating them back). + +## Manual E2E checklist + +- [ ] `/api/health` shows the model and `MCP connected` with 28 tools +- [ ] Simple prompt streams a text reply +- [ ] Dashboard request shows tool cards and an "Open dashboard →" button that loads in MMGIS +- [ ] "show me the config JSON" returns the full config in a tool card +- [ ] JSON drawer creates a mission from pasted (edited) config +- [ ] `view_fly_to` request visibly moves an open dashboard's map +- [ ] Bad OpenAI key shows a red error bubble, conversation survives a retry +- [ ] layer_update from chat visibly changes an open dashboard WITHOUT reloading (e.g. opacity) +- [ ] mission_update_config + view_reload applies a basemap/page-name change +- [ ] geodataset_ingest (inline) → layer_add with geodatasets: renders the data +- [ ] mission_delete asks for confirmation in chat before acting +- [ ] user_create + user_set_permission work with the long-term token (exercises the flagged backend change) +- [ ] mission_clone (may fail if the MMGIS host lacks a `python` binary — record outcome) diff --git a/chat/lib/agentLoop.js b/chat/lib/agentLoop.js new file mode 100644 index 000000000..0d94ea3a8 --- /dev/null +++ b/chat/lib/agentLoop.js @@ -0,0 +1,85 @@ +export const SYSTEM_PROMPT = `You are an assistant that builds and drives MMGIS dashboards through tools. + +Workflow guidance: +- Before generating a dashboard, call dashboard_profile_schema (input shape + layer examples) and dashboard_tool_options (valid tool names). +- Find data layers with catalog_collections / catalog_search, and convert items with catalog_item_to_layer. +- Catalog dataset names are technical, not thematic: for "air quality" search no2, so2, pm, aerosol; for "wildfire" search fire, burn, thermal. Try 2-3 synonyms and scan the availableCollections list before concluding no data exists. +- When a request is thematic ("flooding", "fires") and several distinct datasets match, DO NOT silently pick one. Present a short numbered list of the best candidates — plain-language title, whether it is event-specific (small area, days) or ongoing/global (use temporalExtent/spatialExtent), and dates — then ask the user to choose before building. Skip asking only when the request is already specific or exactly one reasonable match exists. Always state in your final summary exactly which datasets you used. +- Mission names must avoid punctuation (letters, numbers, spaces, underscores are safe). +- After dashboard_generate or dashboard_create_from_config succeeds, ALWAYS give the user the mission URL. +- When the user wants to see or edit the raw config, call dashboard_generate with returnConfig: true and show the JSON. +- When the user provides config JSON, install it with dashboard_create_from_config. For an EXISTING mission, apply provided JSON as a mission_update_config patch instead (merge semantics: absent keys survive, null deletes); dashboard_create_from_config is for new missions. +- Use view_* tools to drive a browser session that has the mission open (view_get_state first if unsure). +- Tool errors include a "hint" — follow it to self-correct. If you cannot recover, tell the user the error and hint plainly. +- Editing existing dashboards: prefer layer_add/layer_update/layer_remove and tool_toggle. For anything else use mission_update_config with a JSON merge-patch (null deletes a key; arrays replace). Edits apply live — layer edits AND config patches — so there's no need to call view_reload after every edit; it remains available as a manual fallback if a session doesn't pick up the change. +- DESTRUCTIVE tools (mission_delete, geodataset_delete, user_create, user_set_permission) return needsConfirmation first. Show the user exactly what will happen, get their explicit yes, then retry with confirm: true. Never set confirm on your own. +- Geodata: ingest GeoJSON with geodataset_ingest (inline for small data, url for hosted files), then add a layer with type "vector" and url "geodatasets:". +- User management: new users start as Viewer (001); promote with user_set_permission (110 Admin / 001 Viewer; SuperAdmin cannot be granted). Never repeat passwords back. +- If earlier messages in this conversation claimed data was unavailable, do not trust that claim — catalogs and tools improve; re-search fresh each time the user asks. +- Be concise. Never invent tool results.` + +export async function runAgentLoop({ messages, openai, bridge, model, onEvent, maxIterations = 15 }) { + const tools = await bridge.getOpenAiTools() + const convo = [{ role: 'system', content: SYSTEM_PROMPT }, ...messages] + + for (let i = 0; i < maxIterations; i++) { + const { text, toolCalls } = await streamOneTurn({ openai, model, messages: convo, tools, onEvent }) + if (toolCalls.length === 0) { + onEvent({ type: 'done' }) + return + } + convo.push({ + role: 'assistant', + content: text || null, + tool_calls: toolCalls.map((c) => ({ + id: c.id, + type: 'function', + function: { name: c.name, arguments: c.args }, + })), + }) + for (const c of toolCalls) { + let parsed = null + try { + parsed = c.args ? JSON.parse(c.args) : {} + } catch {} + onEvent({ type: 'tool_call', id: c.id, name: c.name, args: parsed ?? {} }) + const result = + parsed === null + ? { text: JSON.stringify({ error: `Invalid JSON arguments: ${c.args}` }), isError: true } + : await bridge.callTool(c.name, parsed) + onEvent({ type: 'tool_result', id: c.id, name: c.name, result: result.text, isError: result.isError }) + convo.push({ role: 'tool', tool_call_id: c.id, content: result.text }) + } + } + + // Loop guard: force a final, tool-free summary + convo.push({ role: 'user', content: 'Tool budget exhausted — summarize what you did and stop.' }) + await streamOneTurn({ openai, model, messages: convo, onEvent }) + onEvent({ type: 'done' }) +} + +async function streamOneTurn({ openai, model, messages, tools, onEvent }) { + const stream = await openai.chat.completions.create({ + model, + messages, + stream: true, + ...(tools ? { tools } : {}), + }) + let text = '' + const toolCalls = [] + for await (const chunk of stream) { + const delta = chunk.choices?.[0]?.delta + if (!delta) continue + if (delta.content) { + text += delta.content + onEvent({ type: 'text', delta: delta.content }) + } + for (const tc of delta.tool_calls || []) { + const slot = (toolCalls[tc.index] ??= { id: '', name: '', args: '' }) + if (tc.id) slot.id = tc.id + if (tc.function?.name) slot.name += tc.function.name + if (tc.function?.arguments) slot.args += tc.function.arguments + } + } + return { text, toolCalls: toolCalls.filter(Boolean) } +} diff --git a/chat/lib/app.js b/chat/lib/app.js new file mode 100644 index 000000000..b60b12bfe --- /dev/null +++ b/chat/lib/app.js @@ -0,0 +1,71 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import express from 'express' +import { runAgentLoop } from './agentLoop.js' + +const PUBLIC_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'public') + +export function createApp({ cfg, openai, bridge }) { + const app = express() + app.use(express.json({ limit: '2mb' })) + app.use(express.static(PUBLIC_DIR)) + + app.get('/api/health', async (req, res) => { + let toolCount = 0 + try { + toolCount = (await bridge.getOpenAiTools()).length + } catch { + // leave toolCount 0; mcpConnected reflects reality below + } + res.json({ + ok: true, + model: cfg.model, + mcpConnected: bridge.isConnected(), + toolCount, + mmgisUrl: cfg.mcpEnv?.MMGIS_URL ?? null, + // Where the dashboard UI is served (webpack dev server differs from the API port) + dashboardUrl: cfg.mcpEnv?.MMGIS_DASHBOARD_URL ?? cfg.mcpEnv?.MMGIS_URL ?? null, + }) + }) + + app.get('/api/missions', async (req, res) => { + try { + const out = await bridge.callTool('mission_list', {}) + if (out.isError) return res.status(502).json({ error: out.text }) + res.json({ missions: JSON.parse(out.text).missions ?? [] }) + } catch (err) { + res.status(502).json({ error: String(err?.message ?? err) }) + } + }) + + app.get('/api/tools', async (req, res) => { + try { + const tools = await bridge.getOpenAiTools() + res.json({ tools: tools.map((t) => ({ name: t.function.name, description: t.function.description })) }) + } catch (err) { + res.status(502).json({ error: err.message }) + } + }) + + app.post('/api/chat', async (req, res) => { + const { messages } = req.body || {} + const valid = + Array.isArray(messages) && + messages.every((m) => m && typeof m.content === 'string' && ['user', 'assistant'].includes(m.role)) + if (!valid) { + return res.status(400).json({ error: 'body must be {messages: [{role: user|assistant, content: string}]}' }) + } + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + const send = (event) => res.write(`data: ${JSON.stringify(event)}\n\n`) + try { + await runAgentLoop({ messages, openai, bridge, model: cfg.model, onEvent: send }) + } catch (err) { + send({ type: 'error', message: String(err?.message ?? err) }) + } + res.end() + }) + + return app +} diff --git a/chat/lib/config.js b/chat/lib/config.js new file mode 100644 index 000000000..c586152e6 --- /dev/null +++ b/chat/lib/config.js @@ -0,0 +1,18 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +export function loadConfig(env = process.env) { + if (!env.OPENAI_API_KEY) { + throw new Error('OPENAI_API_KEY is required — copy chat/.env.example to chat/.env and set it') + } + return { + apiKey: env.OPENAI_API_KEY, + model: env.OPENAI_MODEL || 'gpt-4o', + port: parseInt(env.CHAT_PORT || '8895', 10), + mcpCommand: env.MCP_COMMAND || 'node', + mcpArgs: (env.MCP_ARGS || '../mcp/dist/index.js').split(' ').filter(Boolean), + // chat/lib -> chat/ + mcpCwd: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'), + mcpEnv: { ...env }, + } +} diff --git a/chat/lib/mcpBridge.js b/chat/lib/mcpBridge.js new file mode 100644 index 000000000..d338f66ec --- /dev/null +++ b/chat/lib/mcpBridge.js @@ -0,0 +1,75 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' + +async function defaultClientFactory(cfg) { + const transport = new StdioClientTransport({ + command: cfg.mcpCommand, + args: cfg.mcpArgs, + cwd: cfg.mcpCwd, + env: cfg.mcpEnv, + }) + const client = new Client({ name: 'mmgis-chat', version: '0.1.0' }) + await client.connect(transport) + return { client } +} + +export class McpBridge { + constructor(cfg, clientFactory = defaultClientFactory) { + this.cfg = cfg + this.clientFactory = clientFactory + this.client = null + this.tools = null + this.connecting = null + } + + async connect() { + if (this.client) return + if (this.connecting) return this.connecting + this.connecting = (async () => { + const { client } = await this.clientFactory(this.cfg) + this.client = client + })() + try { + await this.connecting + } finally { + this.connecting = null + } + } + + isConnected() { + return this.client != null + } + + async getOpenAiTools() { + await this.connect() + if (!this.tools) { + const { tools } = await this.client.listTools() + this.tools = tools.map((t) => ({ + type: 'function', + function: { name: t.name, description: t.description || '', parameters: t.inputSchema }, + })) + } + return this.tools + } + + async callTool(name, args) { + try { + await this.connect() + const res = await this.client.callTool({ name, arguments: args }) + return { text: res.content?.[0]?.text ?? '', isError: Boolean(res.isError) } + } catch (err) { + // Drop the connection so the next call reconnects (fresh MCP process). + // Close the dead client first so its subprocess/transport doesn't leak. + void this.client?.close?.().catch(() => {}) + this.client = null + this.tools = null + return { text: JSON.stringify({ error: String(err?.message ?? err) }), isError: true } + } + } + + async close() { + await this.client?.close() + this.client = null + this.tools = null + } +} diff --git a/chat/package-lock.json b/chat/package-lock.json new file mode 100644 index 000000000..91e9ffe47 --- /dev/null +++ b/chat/package-lock.json @@ -0,0 +1,3218 @@ +{ + "name": "@mmgis/chat", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@mmgis/chat", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "dotenv": "^16.4.0", + "express": "^4.19.0", + "openai": "^5.0.0" + }, + "devDependencies": { + "vitest": "^3.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.15", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.15.tgz", + "integrity": "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-rate-limit/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.1.tgz", + "integrity": "sha512-O81l1g31PlH55F7AChkGM3sdWZX+1KDLnhesJ/V9Ziug5nIOnEPaVb2jLWAo1TbCOSeZPeNbxX4v/ywTuKSn9A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "5.23.2", + "resolved": "https://registry.npmjs.org/openai/-/openai-5.23.2.tgz", + "integrity": "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vite-node/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/chat/package.json b/chat/package.json new file mode 100644 index 000000000..bb58636c7 --- /dev/null +++ b/chat/package.json @@ -0,0 +1,19 @@ +{ + "name": "@mmgis/chat", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js", + "test": "vitest run" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "dotenv": "^16.4.0", + "express": "^4.19.0", + "openai": "^5.0.0" + }, + "devDependencies": { + "vitest": "^3.0.0" + } +} diff --git a/chat/public/app.js b/chat/public/app.js new file mode 100644 index 000000000..cfdb9ccb2 --- /dev/null +++ b/chat/public/app.js @@ -0,0 +1,358 @@ +// --- Pure helpers (unit-tested; no DOM access at module top level) --- + +export function parseSseChunks(buffer) { + const frames = buffer.split('\n\n') + const rest = frames.pop() + const events = [] + for (const frame of frames) { + const data = frame.replace(/^data: /, '').trim() + if (!data) continue + try { + events.push(JSON.parse(data)) + } catch { + // skip malformed frame + } + } + return { events, rest } +} + +export function extractUrls(resultText) { + let parsed + try { + parsed = JSON.parse(resultText) + } catch { + return [] + } + const urls = [] + const walk = (node) => { + if (node == null || typeof node !== 'object') return + for (const [key, value] of Object.entries(node)) { + if (key === 'url' && typeof value === 'string') urls.push(value) + else walk(value) + } + } + walk(parsed) + return urls +} + +// Rewrite a mission URL from the API origin to the dashboard-UI origin (in dev +// the webpack server lives on another port and the API's redirect drops the +// ?mission= query). Non-matching or unparseable URLs pass through unchanged. +export function rewriteDashboardUrl(url, apiBase, dashBase) { + if (!apiBase || !dashBase || apiBase === dashBase) return url + try { + const u = new URL(url) + const api = new URL(apiBase) + if (u.origin !== api.origin) return url + const dash = new URL(dashBase) + return `${dash.origin}${u.pathname}${u.search}${u.hash}` + } catch { + return url + } +} + +// --- Browser wiring --- + +if (typeof document !== 'undefined') { + const transcript = document.getElementById('transcript') + const composer = document.getElementById('composer') + const input = document.getElementById('input') + const status = document.getElementById('status') + const send = document.getElementById('send') + const newChat = document.getElementById('newChat') + const drawerToggle = document.getElementById('jsonDrawerToggle') + const drawer = document.getElementById('jsonDrawer') + const jsonCreate = document.getElementById('jsonCreate') + const split = document.getElementById('split') + const chatCol = document.getElementById('chatCol') + const divider = document.getElementById('divider') + const dashToggle = document.getElementById('dashToggle') + const dashFrame = document.getElementById('dashFrame') + const dashPop = document.getElementById('dashPop') + const missionPicker = document.getElementById('missionPicker') + + let messages = [] + try { + messages = JSON.parse(localStorage.getItem('mmgisChat') || '[]') + } catch { + messages = [] + } + let busy = false + + const save = () => localStorage.setItem('mmgisChat', JSON.stringify(messages)) + + function appendAndScroll(el) { + transcript.appendChild(el) + transcript.scrollTop = transcript.scrollHeight + return el + } + + function addBubble(cls, text) { + const div = document.createElement('div') + div.className = `msg ${cls}` + div.textContent = text + return appendAndScroll(div) + } + + function addToolCard(name, args) { + const details = document.createElement('details') + details.className = 'tool' + const summary = document.createElement('summary') + summary.textContent = `🔧 ${name}` + details.appendChild(summary) + const argsPre = document.createElement('pre') + argsPre.textContent = `args: ${JSON.stringify(args, null, 2)}` + details.appendChild(argsPre) + return appendAndScroll(details) + } + + function finishToolCard(card, result, isError) { + if (isError) card.classList.add('error') + const pre = document.createElement('pre') + try { + pre.textContent = JSON.stringify(JSON.parse(result), null, 2) + } catch { + pre.textContent = result + } + card.appendChild(pre) + for (const url of extractUrls(result)) { + const a = document.createElement('a') + a.className = 'dash-link' + a.href = url + a.target = '_blank' + a.textContent = 'Open dashboard →' + card.appendChild(a) + if (url.includes('?mission=')) showDashboard(url) + } + } + + // --- Dashboard panel --- + + let mmgisUrl = null + let dashboardUrl = null + + function missionFromUrl(url) { + try { + return new URL(url).searchParams.get('mission') + } catch { + return null + } + } + + function showDashboard(rawUrl) { + const url = rewriteDashboardUrl(rawUrl, mmgisUrl, dashboardUrl) + if (dashFrame.src !== url) dashFrame.src = url + dashPop.href = url + split.classList.add('has-dash') + const mission = missionFromUrl(url) + if (mission) { + if (![...missionPicker.options].some((o) => o.value === mission)) { + missionPicker.appendChild(new Option(mission, mission)) + } + missionPicker.value = mission + localStorage.setItem('mmgisChatMission', mission) + } + } + + async function refreshMissions() { + try { + const out = await (await fetch('/api/missions')).json() + const missions = out.missions || [] + const current = missionPicker.value + missionPicker.length = 1 + for (const m of missions) missionPicker.appendChild(new Option(m, m)) + if (current) missionPicker.value = current + // If the remembered/shown mission was deleted, reset the panel + const remembered = localStorage.getItem('mmgisChatMission') + if (remembered && !missions.includes(remembered)) { + localStorage.removeItem('mmgisChatMission') + if (missionFromUrl(dashFrame.src) === remembered) { + dashFrame.src = 'about:blank' + split.classList.remove('has-dash') + missionPicker.value = '' + } + } + } catch { + // panel picker stays as-is; status strip already reports server issues + } + } + + missionPicker.addEventListener('change', () => { + if (missionPicker.value && mmgisUrl) { + showDashboard(`${mmgisUrl}/?mission=${encodeURIComponent(missionPicker.value)}`) + } + }) + + dashToggle.addEventListener('click', () => { + split.classList.toggle('panel-hidden') + localStorage.setItem('mmgisChatPanelHidden', split.classList.contains('panel-hidden') ? '1' : '') + }) + + divider.addEventListener('pointerdown', (e) => { + e.preventDefault() + divider.setPointerCapture(e.pointerId) + const move = (ev) => { + const min = 380 + const max = window.innerWidth - 320 + chatCol.style.flexBasis = `${Math.min(max, Math.max(min, ev.clientX))}px` + } + const up = () => { + divider.removeEventListener('pointermove', move) + divider.removeEventListener('pointerup', up) + } + divider.addEventListener('pointermove', move) + divider.addEventListener('pointerup', up) + }) + + function render() { + transcript.innerHTML = '' + for (const m of messages) addBubble(m.role, m.content) + } + + async function refreshHealth() { + try { + const h = await (await fetch('/api/health')).json() + status.textContent = `${h.model} · ${h.toolCount} tools · MCP ${h.mcpConnected ? 'connected' : 'DISCONNECTED'}` + status.className = `status ${h.mcpConnected ? 'ok' : 'bad'}` + if (h.mmgisUrl && !mmgisUrl) { + mmgisUrl = h.mmgisUrl.replace(/\/+$/, '') + dashboardUrl = (h.dashboardUrl || h.mmgisUrl).replace(/\/+$/, '') + const remembered = localStorage.getItem('mmgisChatMission') + if (remembered && !split.classList.contains('has-dash')) { + showDashboard(`${mmgisUrl}/?mission=${encodeURIComponent(remembered)}`) + } + } + } catch { + status.textContent = 'server unreachable' + status.className = 'status bad' + } + } + + async function sendConversation() { + busy = true + send.disabled = true + newChat.disabled = true + const toolCards = new Map() + let assistantDiv = null + let bubbleText = '' + let fullText = '' + let thinkingDiv = addBubble('assistant thinking', 'thinking…') + const clearThinking = () => { + if (thinkingDiv) { + thinkingDiv.remove() + thinkingDiv = null + } + } + const ctrl = new AbortController() + const abortTimer = setTimeout(() => ctrl.abort(), 180000) + try { + const res = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages }), + signal: ctrl.signal, + }) + if (!res.ok) throw new Error(`server ${res.status}`) + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const { events, rest } = parseSseChunks(buffer) + buffer = rest + for (const ev of events) { + clearThinking() + if (ev.type === 'text') { + if (!assistantDiv) { + assistantDiv = addBubble('assistant', '') + bubbleText = '' + if (fullText) fullText += '\n' + } + bubbleText += ev.delta + fullText += ev.delta + assistantDiv.textContent = bubbleText + appendAndScroll(assistantDiv) + } else if (ev.type === 'tool_call') { + // a new assistant bubble will follow the tool round + assistantDiv = null + toolCards.set(ev.id, addToolCard(ev.name, ev.args)) + } else if (ev.type === 'tool_result') { + const card = toolCards.get(ev.id) + if (card) finishToolCard(card, ev.result, ev.isError) + } else if (ev.type === 'error') { + addBubble('error', `Error: ${ev.message}`) + } + } + } + if (fullText) { + messages.push({ role: 'assistant', content: fullText }) + save() + } + } catch (err) { + const message = + err && err.name === 'AbortError' + ? 'Request timed out after 3 minutes — the server may be down or overloaded. Reload the page and try again.' + : err.message + addBubble('error', `Error: ${message}`) + } finally { + clearTimeout(abortTimer) + clearThinking() + busy = false + send.disabled = false + newChat.disabled = false + refreshMissions() + } + } + + function submitUserMessage(content) { + if (busy || !content.trim()) return + messages.push({ role: 'user', content }) + save() + addBubble('user', content) + sendConversation() + } + + composer.addEventListener('submit', (e) => { + e.preventDefault() + if (busy) return + const content = input.value + input.value = '' + submitUserMessage(content) + }) + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + composer.requestSubmit() + } + }) + newChat.addEventListener('click', () => { + if (busy) return + messages = [] + save() + render() + }) + drawerToggle.addEventListener('click', () => drawer.classList.toggle('hidden')) + jsonCreate.addEventListener('click', () => { + const name = document.getElementById('jsonMissionName').value.trim() || 'From JSON' + const json = document.getElementById('jsonConfig').value.trim() + if (!json) return + try { + JSON.parse(json) + } catch { + addBubble('error', 'Error: the JSON config drawer contains invalid JSON') + return + } + drawer.classList.add('hidden') + submitUserMessage( + `Create a dashboard named "${name}" from this exact config JSON using dashboard_create_from_config (updateExisting: true):\n\`\`\`json\n${json}\n\`\`\`` + ) + }) + + render() + if (localStorage.getItem('mmgisChatPanelHidden') === '1') split.classList.add('panel-hidden') + refreshHealth() + refreshMissions() + setInterval(refreshHealth, 15000) +} diff --git a/chat/public/index.html b/chat/public/index.html new file mode 100644 index 000000000..05848eb5d --- /dev/null +++ b/chat/public/index.html @@ -0,0 +1,49 @@ + + + + + + MMGIS Chat + + + +
+

MMGIS Chat

+
connecting…
+
+ + + +
+
+ + + +
+
+
+
+ + +
+
+
+
+
+ + +
+ +
Create or pick a mission and it will appear here.
+
+
+ + + + diff --git a/chat/public/style.css b/chat/public/style.css new file mode 100644 index 000000000..2092d2c0e --- /dev/null +++ b/chat/public/style.css @@ -0,0 +1,171 @@ +@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Mono:wght@400;500&display=swap'); + +:root { + --bg: #f6f5f1; + --surface: #ffffff; + --surface-2: #eef0f3; + --ink: #1c2733; + --muted: #5d6b7a; + --line: #dfe3e8; + --accent: #1d5cc0; + --accent-ink: #164a9d; + --accent-soft: #e9f0fb; + --ok: #1e7f4f; + --bad: #c2334a; + --bad-soft: #fdeef0; + --sans: 'IBM Plex Sans', -apple-system, 'Segoe UI', sans-serif; + --mono: 'IBM Plex Mono', ui-monospace, Menlo, monospace; +} + +* { box-sizing: border-box; } + +body { + margin: 0; height: 100vh; display: flex; flex-direction: column; + font-family: var(--sans); + background: var(--bg); color: var(--ink); +} + +/* --- Header --- */ +header { + display: flex; align-items: center; gap: 14px; padding: 12px 18px; + background: var(--surface); border-bottom: 1px solid var(--line); +} +header h1 { + font-size: 15px; margin: 0; font-weight: 600; letter-spacing: 0.02em; +} +header h1::before { + content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 50%; + background: var(--accent); margin-right: 8px; vertical-align: baseline; +} +.status { font-size: 12px; color: var(--muted); flex: 1; font-family: var(--mono); } +.status.ok { color: var(--ok); } +.status.bad { color: var(--bad); } +.header-actions { display: flex; gap: 8px; } + +button { + background: var(--surface); color: var(--ink); border: 1px solid var(--line); + border-radius: 7px; padding: 6px 13px; font-size: 13px; cursor: pointer; + font-family: var(--sans); font-weight: 500; + transition: border-color 0.15s ease, background 0.15s ease; +} +button:hover { border-color: var(--accent); color: var(--accent-ink); } +button:disabled { opacity: 0.45; cursor: default; } +button:focus-visible, select:focus-visible, textarea:focus-visible, input:focus-visible { + outline: 2px solid var(--accent); outline-offset: 1px; +} + +/* --- JSON drawer --- */ +.drawer { + padding: 14px 18px; background: var(--surface-2); border-bottom: 1px solid var(--line); + display: flex; flex-direction: column; gap: 10px; +} +.drawer.hidden { display: none; } +.drawer label { font-size: 12px; color: var(--muted); display: flex; flex-direction: column; gap: 5px; font-weight: 500; } +.drawer input, .drawer textarea, #input { + background: var(--surface); color: var(--ink); border: 1px solid var(--line); + border-radius: 7px; padding: 9px 10px; font-size: 13px; font-family: var(--mono); +} +.drawer input::placeholder, .drawer textarea::placeholder, #input::placeholder { color: #9aa6b1; } + +/* --- Split layout --- */ +#split { flex: 1; display: flex; min-height: 0; } +#chatCol { display: flex; flex-direction: column; min-width: 380px; flex: 0 0 45%; min-height: 0; } +#divider { + width: 7px; cursor: col-resize; background: var(--bg); + border-left: 1px solid var(--line); border-right: 1px solid var(--line); flex: none; + transition: background 0.15s ease; +} +#divider:hover { background: var(--accent-soft); } +#dashCol { flex: 1; display: flex; flex-direction: column; min-width: 0; position: relative; background: var(--surface); } +#dashBar { + display: flex; align-items: center; gap: 10px; padding: 8px 12px; + background: var(--surface); border-bottom: 1px solid var(--line); +} +#missionPicker { + flex: 1; background: var(--surface); color: var(--ink); border: 1px solid var(--line); + border-radius: 7px; padding: 6px 9px; font-size: 13px; font-family: var(--sans); +} +#dashPop { color: var(--accent); text-decoration: none; font-size: 16px; padding: 0 6px; } +#dashPop:hover { color: var(--accent-ink); } +#dashFrame { flex: 1; border: 0; width: 100%; background: var(--surface); display: none; } +#dashEmpty { + position: absolute; inset: 44px 0 0 0; display: flex; align-items: center; justify-content: center; + color: var(--muted); font-size: 14px; pointer-events: none; +} +#split.panel-hidden #divider, #split.panel-hidden #dashCol { display: none; } +#split.panel-hidden #chatCol { flex: 1; } +#split.has-dash #dashFrame { display: block; } +#split.has-dash #dashEmpty { display: none; } + +/* --- Transcript --- */ +main { flex: 1; overflow-y: auto; padding: 18px; display: flex; flex-direction: column; gap: 10px; } + +@keyframes arrive { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: none; } +} +.msg, details.tool { animation: arrive 0.18s ease-out; } + +.msg { + max-width: 780px; padding: 10px 14px; border-radius: 10px; + white-space: pre-wrap; font-size: 14px; line-height: 1.5; +} +.msg.user { + background: var(--accent); color: #ffffff; align-self: flex-end; + border-bottom-right-radius: 4px; +} +.msg.assistant { + background: var(--surface); border: 1px solid var(--line); align-self: flex-start; + border-bottom-left-radius: 4px; +} +.msg.error { + background: var(--bad-soft); border: 1px solid var(--bad); color: #7c2231; align-self: stretch; +} +.msg.thinking { + color: var(--muted); font-style: italic; + background: transparent; border: none; + animation: pulse 1.2s ease-in-out infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} + +/* --- Tool cards --- */ +details.tool { + align-self: flex-start; max-width: 780px; width: 100%; + background: var(--surface); border: 1px solid var(--line); + border-left: 3px solid var(--accent); + border-radius: 8px; font-size: 13px; +} +details.tool summary { + padding: 8px 12px; cursor: pointer; color: var(--accent-ink); + font-family: var(--mono); font-size: 12px; +} +details.tool summary::marker { color: var(--muted); } +details.tool.error { border-color: var(--bad); border-left-color: var(--bad); } +details.tool.error summary { color: var(--bad); } +details.tool pre { + margin: 0; padding: 9px 12px; overflow-x: auto; font-size: 12px; line-height: 1.5; + font-family: var(--mono); + background: var(--surface-2); border-top: 1px solid var(--line); white-space: pre-wrap; + color: #33404d; +} +.dash-link { + display: inline-block; margin: 8px 12px; padding: 6px 13px; + background: var(--accent); border: 1px solid var(--accent-ink); border-radius: 7px; + color: #ffffff; text-decoration: none; font-size: 13px; font-weight: 500; + transition: background 0.15s ease; +} +.dash-link:hover { background: var(--accent-ink); } + +/* --- Composer --- */ +#composer { + display: flex; gap: 10px; padding: 14px 18px; + background: var(--surface); border-top: 1px solid var(--line); +} +#input { flex: 1; resize: none; } +#send { + background: var(--accent); color: #ffffff; border-color: var(--accent-ink); +} +#send:hover { background: var(--accent-ink); color: #ffffff; } diff --git a/chat/server.js b/chat/server.js new file mode 100644 index 000000000..20e379c43 --- /dev/null +++ b/chat/server.js @@ -0,0 +1,21 @@ +import 'dotenv/config' +import OpenAI from 'openai' +import { loadConfig } from './lib/config.js' +import { McpBridge } from './lib/mcpBridge.js' +import { createApp } from './lib/app.js' + +const cfg = loadConfig() +const openai = new OpenAI({ apiKey: cfg.apiKey }) +const bridge = new McpBridge(cfg) + +const app = createApp({ cfg, openai, bridge }) +app.listen(cfg.port, () => { + console.log(`MMGIS chat UI: http://localhost:${cfg.port} (model: ${cfg.model})`) +}) + +const shutdown = async () => { + await bridge.close().catch(() => {}) + process.exit(0) +} +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) diff --git a/chat/tests/agentLoop.spec.js b/chat/tests/agentLoop.spec.js new file mode 100644 index 000000000..ebba8ec80 --- /dev/null +++ b/chat/tests/agentLoop.spec.js @@ -0,0 +1,116 @@ +import { describe, it, expect, vi } from 'vitest' +import { runAgentLoop, SYSTEM_PROMPT } from '../lib/agentLoop.js' + +// Builds a fake OpenAI client whose create() returns scripted streams, in order. +// Each script is an array of {content?} | {tool?: {index, id?, name?, args?}} chunk specs. +function fakeOpenai(scripts) { + let call = 0 + const seen = [] + return { + seen, + chat: { + completions: { + create: vi.fn(async (params) => { + seen.push(params) + const script = scripts[call++] + async function* gen() { + for (const c of script) { + if (c.content !== undefined) { + yield { choices: [{ delta: { content: c.content } }] } + } else if (c.tool) { + yield { + choices: [{ + delta: { + tool_calls: [{ + index: c.tool.index, + ...(c.tool.id ? { id: c.tool.id } : {}), + function: { + ...(c.tool.name ? { name: c.tool.name } : {}), + ...(c.tool.args ? { arguments: c.tool.args } : {}), + }, + }], + }, + }], + } + } + } + } + return gen() + }), + }, + }, + } +} + +const bridge = { + getOpenAiTools: async () => [{ type: 'function', function: { name: 'mission_list', description: '', parameters: {} } }], + callTool: vi.fn(async (name) => ({ text: `{"ran":"${name}"}`, isError: false })), +} + +describe('runAgentLoop', () => { + it('streams text and finishes with done when no tools are called', async () => { + const openai = fakeOpenai([[{ content: 'Hello' }, { content: ' there' }]]) + const events = [] + await runAgentLoop({ messages: [{ role: 'user', content: 'hi' }], openai, bridge, model: 'm', onEvent: (e) => events.push(e) }) + expect(events).toEqual([ + { type: 'text', delta: 'Hello' }, + { type: 'text', delta: ' there' }, + { type: 'done' }, + ]) + expect(openai.seen[0].messages[0]).toEqual({ role: 'system', content: SYSTEM_PROMPT }) + }) + + it('accumulates fragmented tool-call deltas, executes via bridge, loops, and threads results back', async () => { + const openai = fakeOpenai([ + [ + { tool: { index: 0, id: 'call_1', name: 'mission_list' } }, + { tool: { index: 0, args: '{"a"' } }, + { tool: { index: 0, args: ':1}' } }, + ], + [{ content: 'Done!' }], + ]) + const events = [] + await runAgentLoop({ messages: [{ role: 'user', content: 'go' }], openai, bridge, model: 'm', onEvent: (e) => events.push(e) }) + expect(events).toEqual([ + { type: 'tool_call', id: 'call_1', name: 'mission_list', args: { a: 1 } }, + { type: 'tool_result', id: 'call_1', name: 'mission_list', result: '{"ran":"mission_list"}', isError: false }, + { type: 'text', delta: 'Done!' }, + { type: 'done' }, + ]) + expect(bridge.callTool).toHaveBeenCalledWith('mission_list', { a: 1 }) + const second = openai.seen[1].messages + expect(second.at(-2).tool_calls[0]).toEqual({ + id: 'call_1', type: 'function', function: { name: 'mission_list', arguments: '{"a":1}' }, + }) + expect(second.at(-1)).toEqual({ role: 'tool', tool_call_id: 'call_1', content: '{"ran":"mission_list"}' }) + }) + + it('stops after maxIterations tool rounds with a final summarize turn', async () => { + const toolRound = [ + { tool: { index: 0, id: 'call_x', name: 'mission_list', args: '{}' } }, + ] + const openai = fakeOpenai([toolRound, toolRound, [{ content: 'Summary.' }]]) + const events = [] + await runAgentLoop({ + messages: [{ role: 'user', content: 'go' }], openai, bridge, model: 'm', + onEvent: (e) => events.push(e), maxIterations: 2, + }) + expect(events.filter((e) => e.type === 'tool_call')).toHaveLength(2) + expect(events.at(-2)).toEqual({ type: 'text', delta: 'Summary.' }) + expect(events.at(-1)).toEqual({ type: 'done' }) + // The forced final call must disable tools + expect(openai.seen[2].tools).toBeUndefined() + }) + + it('passes unparseable tool arguments to the bridge as an empty object with an error result', async () => { + const openai = fakeOpenai([ + [{ tool: { index: 0, id: 'call_b', name: 'mission_list', args: '{not json' } }], + [{ content: 'ok' }], + ]) + const events = [] + await runAgentLoop({ messages: [{ role: 'user', content: 'go' }], openai, bridge, model: 'm', onEvent: (e) => events.push(e) }) + const result = events.find((e) => e.type === 'tool_result') + expect(result.isError).toBe(true) + expect(result.result).toContain('Invalid JSON arguments') + }) +}) diff --git a/chat/tests/app.spec.js b/chat/tests/app.spec.js new file mode 100644 index 000000000..2cb03812f --- /dev/null +++ b/chat/tests/app.spec.js @@ -0,0 +1,140 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { createApp } from '../lib/app.js' + +const cfg = { model: 'test-model', port: 0 } + +function fakeBridge() { + return { + isConnected: () => true, + getOpenAiTools: async () => [{ type: 'function', function: { name: 'mission_list', description: 'List', parameters: {} } }], + callTool: async () => ({ text: '{"ok":true}', isError: false }), + } +} + +function fakeOpenai(script) { + return { + chat: { + completions: { + create: async () => (async function* () { + for (const c of script) yield { choices: [{ delta: c }] } + })(), + }, + }, + } +} + +async function readSse(res) { + const text = await res.text() + return text.split('\n\n').filter(Boolean).map((f) => JSON.parse(f.replace(/^data: /, ''))) +} + +describe('chat app', () => { + let server + afterEach(() => server?.close()) + + async function start(app) { + await new Promise((resolve) => { server = app.listen(0, resolve) }) + return `http://127.0.0.1:${server.address().port}` + } + + it('GET /api/health reports model and mcp status', async () => { + const url = await start(createApp({ cfg, openai: fakeOpenai([]), bridge: fakeBridge() })) + const out = await (await fetch(`${url}/api/health`)).json() + expect(out).toEqual({ ok: true, model: 'test-model', mcpConnected: true, toolCount: 1, mmgisUrl: null, dashboardUrl: null }) + }) + + it('GET /api/tools lists tool names and descriptions', async () => { + const url = await start(createApp({ cfg, openai: fakeOpenai([]), bridge: fakeBridge() })) + const out = await (await fetch(`${url}/api/tools`)).json() + expect(out.tools).toEqual([{ name: 'mission_list', description: 'List' }]) + }) + + it('POST /api/chat streams SSE events ending in done', async () => { + const url = await start(createApp({ cfg, openai: fakeOpenai([{ content: 'Hi' }]), bridge: fakeBridge() })) + const res = await fetch(`${url}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: [{ role: 'user', content: 'hello' }] }), + }) + expect(res.headers.get('content-type')).toContain('text/event-stream') + expect(await readSse(res)).toEqual([{ type: 'text', delta: 'Hi' }, { type: 'done' }]) + }) + + it('rejects malformed bodies with 400', async () => { + const url = await start(createApp({ cfg, openai: fakeOpenai([]), bridge: fakeBridge() })) + const res = await fetch(`${url}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: 'nope' }), + }) + expect(res.status).toBe(400) + }) + + it('maps loop failures to an SSE error event', async () => { + const openai = { chat: { completions: { create: async () => { throw new Error('bad key') } } } } + const url = await start(createApp({ cfg, openai, bridge: fakeBridge() })) + const res = await fetch(`${url}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: [{ role: 'user', content: 'x' }] }), + }) + const events = await readSse(res) + expect(events.at(-1)).toEqual({ type: 'error', message: 'bad key' }) + }) + + it('maps non-Error loop failures to an SSE error event', async () => { + const openai = { chat: { completions: { create: async () => { throw 'bad key' } } } } + const url = await start(createApp({ cfg, openai, bridge: fakeBridge() })) + const res = await fetch(`${url}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: [{ role: 'user', content: 'x' }] }), + }) + const events = await readSse(res) + expect(events.at(-1)).toEqual({ type: 'error', message: 'bad key' }) + }) + + it('GET /api/health degrades gracefully when the bridge is down', async () => { + const downBridge = { + isConnected: () => false, + getOpenAiTools: async () => { throw new Error('mcp process dead') }, + callTool: async () => ({ text: '', isError: true }), + } + const url = await start(createApp({ cfg, openai: fakeOpenai([]), bridge: downBridge })) + const res = await fetch(`${url}/api/health`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true, model: 'test-model', mcpConnected: false, toolCount: 0, mmgisUrl: null, dashboardUrl: null }) + }) +}) + +describe('GET /api/missions', () => { + let server + afterEach(() => server?.close()) + async function start(app) { + await new Promise((resolve) => { server = app.listen(0, resolve) }) + return `http://127.0.0.1:${server.address().port}` + } + it('proxies mission_list through the bridge', async () => { + const bridge = { + isConnected: () => true, + getOpenAiTools: async () => [], + callTool: async (name) => ({ text: JSON.stringify({ missions: ['A', 'B'] }), isError: false }), + } + const url = await start(createApp({ cfg: { model: 'm' }, openai: {}, bridge })) + expect(await (await fetch(`${url}/api/missions`)).json()).toEqual({ missions: ['A', 'B'] }) + }) + it('maps bridge errors to 502', async () => { + const bridge = { + isConnected: () => false, + getOpenAiTools: async () => [], + callTool: async () => ({ text: '{"error":"down"}', isError: true }), + } + const url = await start(createApp({ cfg: { model: 'm' }, openai: {}, bridge })) + expect((await fetch(`${url}/api/missions`)).status).toBe(502) + }) + it('health reports mmgisUrl from the MCP env passthrough', async () => { + const bridge = { isConnected: () => true, getOpenAiTools: async () => [], callTool: async () => ({ text: '{}', isError: false }) } + const url = await start(createApp({ cfg: { model: 'm', mcpEnv: { MMGIS_URL: 'http://mm:8891' } }, openai: {}, bridge })) + expect((await (await fetch(`${url}/api/health`)).json()).mmgisUrl).toBe('http://mm:8891') + }) +}) diff --git a/chat/tests/config.spec.js b/chat/tests/config.spec.js new file mode 100644 index 000000000..95acd3cc4 --- /dev/null +++ b/chat/tests/config.spec.js @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { loadConfig } from '../lib/config.js' + +const base = { OPENAI_API_KEY: 'sk-test' } + +describe('loadConfig', () => { + it('throws without OPENAI_API_KEY', () => { + expect(() => loadConfig({})).toThrow(/OPENAI_API_KEY/) + }) + it('applies defaults', () => { + const cfg = loadConfig({ ...base }) + expect(cfg.model).toBe('gpt-4o') + expect(cfg.port).toBe(8895) + expect(cfg.mcpCommand).toBe('node') + expect(cfg.mcpArgs).toEqual(['../mcp/dist/index.js']) + expect(cfg.mcpCwd.endsWith('/chat')).toBe(true) + }) + it('honors overrides and splits MCP_ARGS on spaces', () => { + const cfg = loadConfig({ ...base, OPENAI_MODEL: 'gpt-4o-mini', CHAT_PORT: '9000', MCP_ARGS: 'dist/index.js --flag' }) + expect(cfg.model).toBe('gpt-4o-mini') + expect(cfg.port).toBe(9000) + expect(cfg.mcpArgs).toEqual(['dist/index.js', '--flag']) + }) + it('passes the whole env through as mcpEnv', () => { + const cfg = loadConfig({ ...base, MMGIS_TOKEN: 'tok' }) + expect(cfg.mcpEnv.MMGIS_TOKEN).toBe('tok') + }) +}) diff --git a/chat/tests/frontend.spec.js b/chat/tests/frontend.spec.js new file mode 100644 index 000000000..36bda4811 --- /dev/null +++ b/chat/tests/frontend.spec.js @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { parseSseChunks, extractUrls } from '../public/app.js' + +describe('parseSseChunks', () => { + it('parses complete frames and keeps the remainder', () => { + const buffer = 'data: {"type":"text","delta":"a"}\n\ndata: {"type":"done"}\n\ndata: {"type":"te' + const { events, rest } = parseSseChunks(buffer) + expect(events).toEqual([{ type: 'text', delta: 'a' }, { type: 'done' }]) + expect(rest).toBe('data: {"type":"te') + }) + it('skips unparseable frames', () => { + const { events } = parseSseChunks('data: not json\n\ndata: {"type":"done"}\n\n') + expect(events).toEqual([{ type: 'done' }]) + }) +}) + +describe('extractUrls', () => { + it('collects url values from tool-result JSON', () => { + expect(extractUrls('{"mission":"X","url":"http://localhost:8891/?mission=X"}')).toEqual([ + 'http://localhost:8891/?mission=X', + ]) + }) + it('returns empty for non-JSON or url-less results', () => { + expect(extractUrls('plain text')).toEqual([]) + expect(extractUrls('{"a":1}')).toEqual([]) + }) + it('finds nested url fields', () => { + expect(extractUrls('{"result":{"url":"http://x/y"}}')).toEqual(['http://x/y']) + }) +}) + +describe('rewriteDashboardUrl', () => { + it('swaps the API origin for the dashboard origin, keeping path and query', async () => { + const { rewriteDashboardUrl } = await import('../public/app.js') + expect(rewriteDashboardUrl('http://localhost:8891/?mission=A%20B', 'http://localhost:8891', 'http://localhost:8892')) + .toBe('http://localhost:8892/?mission=A%20B') + }) + it('passes through non-matching, identical-base, or unparseable urls', async () => { + const { rewriteDashboardUrl } = await import('../public/app.js') + expect(rewriteDashboardUrl('http://other:1234/?mission=X', 'http://localhost:8891', 'http://localhost:8892')).toBe('http://other:1234/?mission=X') + expect(rewriteDashboardUrl('http://localhost:8891/?mission=X', 'http://localhost:8891', 'http://localhost:8891')).toBe('http://localhost:8891/?mission=X') + expect(rewriteDashboardUrl('not a url', 'http://localhost:8891', 'http://localhost:8892')).toBe('not a url') + }) +}) diff --git a/chat/tests/mcpBridge.spec.js b/chat/tests/mcpBridge.spec.js new file mode 100644 index 000000000..b7b90be9e --- /dev/null +++ b/chat/tests/mcpBridge.spec.js @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from 'vitest' +import { McpBridge } from '../lib/mcpBridge.js' + +function fakeClient(overrides = {}) { + return { + listTools: vi.fn(async () => ({ + tools: [ + { name: 'mission_list', description: 'List missions', inputSchema: { type: 'object', properties: {} } }, + ], + })), + callTool: vi.fn(async () => ({ content: [{ type: 'text', text: '{"missions":[]}' }] })), + close: vi.fn(async () => {}), + ...overrides, + } +} + +describe('McpBridge', () => { + it('converts MCP tools to OpenAI function schemas and caches them', async () => { + const client = fakeClient() + const bridge = new McpBridge({}, async () => ({ client })) + const tools = await bridge.getOpenAiTools() + expect(tools).toEqual([ + { + type: 'function', + function: { name: 'mission_list', description: 'List missions', parameters: { type: 'object', properties: {} } }, + }, + ]) + await bridge.getOpenAiTools() + expect(client.listTools).toHaveBeenCalledTimes(1) + }) + it('callTool returns text and isError', async () => { + const client = fakeClient({ + callTool: vi.fn(async ({ name }) => ({ content: [{ type: 'text', text: `ran ${name}` }], isError: false })), + }) + const bridge = new McpBridge({}, async () => ({ client })) + const out = await bridge.callTool('mission_list', {}) + expect(out).toEqual({ text: 'ran mission_list', isError: false }) + expect(client.callTool).toHaveBeenCalledWith({ name: 'mission_list', arguments: {} }) + }) + it('a throwing callTool yields an isError result and resets the connection for retry', async () => { + let calls = 0 + const dead = fakeClient({ callTool: vi.fn(async () => { throw new Error('transport closed') }) }) + const alive = fakeClient() + const bridge = new McpBridge({}, async () => ({ client: ++calls === 1 ? dead : alive })) + const out = await bridge.callTool('mission_list', {}) + expect(out.isError).toBe(true) + expect(out.text).toContain('transport closed') + expect(bridge.isConnected()).toBe(false) + expect(dead.close).toHaveBeenCalledTimes(1) + const retry = await bridge.callTool('mission_list', {}) + expect(retry.isError).toBe(false) + }) + it('concurrent connect() callers only invoke the clientFactory once', async () => { + const client = fakeClient() + const factory = vi.fn(async () => { + await new Promise((r) => setTimeout(r, 20)) + return { client } + }) + const bridge = new McpBridge({}, factory) + await Promise.all([bridge.callTool('mission_list', {}), bridge.callTool('mission_list', {})]) + expect(factory).toHaveBeenCalledTimes(1) + }) + it('callTool serializes a non-Error throw into the error text', async () => { + const client = fakeClient({ callTool: vi.fn(async () => { throw 'boom string' }) }) + const bridge = new McpBridge({}, async () => ({ client })) + const out = await bridge.callTool('mission_list', {}) + expect(out.isError).toBe(true) + expect(out.text).toContain('boom string') + }) +}) diff --git a/chat/vitest.config.js b/chat/vitest.config.js new file mode 100644 index 000000000..df79e3720 --- /dev/null +++ b/chat/vitest.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/**/*.spec.js'] + } +}) diff --git a/configure/package.json b/configure/package.json index 38dfb932e..2c9e31a3f 100644 --- a/configure/package.json +++ b/configure/package.json @@ -1,6 +1,6 @@ { "name": "configure", - "version": "4.2.18-20260717", + "version": "4.2.19-20260728", "homepage": "./configure/build", "private": true, "dependencies": { diff --git a/docs/superpowers/plans/2026-07-22-agentic-mmgis-phase1.md b/docs/superpowers/plans/2026-07-22-agentic-mmgis-phase1.md new file mode 100644 index 000000000..fec8c9b58 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-agentic-mmgis-phase1.md @@ -0,0 +1,2199 @@ +# Agentic MMGIS Phase 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** An MMGIS MCP server that lets any MCP client (Claude Code/Desktop) administer missions, generate complete dashboards from a description, search STAC catalogs for layers, and drive a live browser session (fly, toggle layers, open tools, set time). + +**Architecture:** A standalone TypeScript package `mcp/` speaks MCP over stdio and reaches MMGIS through (a) the REST admin API using a long-term token in the `Authorization` header, (b) the existing WebSocket broadcast relay for browser control, and (c) the `scripts/generate-mission-config.js` CLI for dashboard generation. Browser-side, an `AgentBridge` Plugin-Component (the first in-tree component, per spec 011) executes a whitelisted set of view commands. **Zero backend changes required** — the WS server already relays arbitrary JSON. + +**Tech Stack:** TypeScript + `@modelcontextprotocol/sdk` + `zod` + `ws` (mcp/); plain ES6 JS for the frontend component; Vitest for all tests. + +## Global Constraints + +- Node.js 20+ (repo requirement). +- Test runner is **Vitest**, not Jest (repo root: `npx vitest run tests/unit/.spec.js`; inside `mcp/`: `npm test`). Root `package.json:53` defines `test:unit: vitest run`. +- Frontend code style: 4-space indent, single quotes, camelCase (match `src/essence/`). +- Commits: imperative mood, no `Co-Authored-By` trailer. +- Everything lands on branch `feature/agentic-mmgis`. +- WS envelope constants (used by BOTH `mcp/src/bridge.ts` and `AgentBridge.js` — must match exactly): outer `type: 'agent-bridge'`, `info: { type: 'agentBridge' }`, `body: { mission }`, payload under `agent: { kind: 'command'|'ack'|'presence', id, sessionId?, command?, args?, ok?, result?, error? }`. The `body.mission` + `info.type` fields exist so `src/essence/essence.js:212-319` processes our frames without warnings (it early-returns frames missing `body.mission` at `essence.js:220` and only special-cases `info.type` of `addLayer|updateLayer|removeLayer`). +- **Documented deviation from the design spec:** the spec says WS messages are "authenticated and rate-limited per constitution VII". MMGIS's WS server (`API/websocket.js:48-64`) is an unauthenticated broadcast relay with no rooms, validation, or rate limiting — for anyone, today. Phase 1 therefore keeps bridge commands strictly **view-only** (no data mutation) and schema-validated in the browser before execution. Hardening the relay is deferred (Phase 2 candidate). +- MMGIS env prerequisite for the bridge: `ENABLE_MMGIS_WEBSOCKETS=true` in `.env`. + +## Key codebase facts (from research; verified 2026-07-22) + +- Mission admin REST (mounted at `ROOT_PATH + /api/configure`, behind `ensureAdmin`): `GET /missions` → `{status, missions: [names]}` (`API/Backend/Config/routes/configs.js:604`); `GET /get?mission=X&full=true` → `{status, mission, config, version}` (`configs.js:245`); `POST /add` body `{mission, config?, makedir?}` (SuperAdmin only, merges posted config over template, does NOT run validate) (`configs.js:383,249`); `POST /upsert` body `{mission, config}` (runs `populateUUIDs` + `validate`, inserts new version) (`configs.js:600,403`). +- Long-term tokens: `Authorization` header, `Bearer ` prefix stripped by `validateLongTermToken` (`scripts/server.js:391-392`); minted via `POST /api/longtermtoken/generate` (session auth only — tokens can't mint tokens); token inherits creator's permission. +- Config generator CLI: `node scripts/generate-mission-config.js [--out|--stdout|--check]` (`scripts/generate-mission-config.js:8-17`); `--stdout` still runs full validation + template-superset assertion; profile shape = `{name, description, output?, tools: "all"|string[], exclude?, on?, overrides?, scaffold: {msv, projection, look, panelSettings, panels, time, layers}}`; scaffold copied verbatim; generator never mints layer UUIDs. +- WS: server relays every frame to ALL clients including sender (`API/websocket.js:48-64`); upgrade path must be exactly `(WEBSOCKET_ROOT_PATH || ROOT_PATH || '') + '/'` (`websocket.js:66-82`); no auth. +- Plugin-Components: discovery scans `src/essence/` for dirs containing `Plugin-Components`/`Private-Components` (`API/updateTools.js:272-282`), reads `/config.json`, generates `src/pre/components.js`; `ComponentController_.initializeComponents()` (`src/essence/Basics/ComponentController_/ComponentController_.js:34`) reads `L_.configData.components` (array of `{name, js, on, variables}`), calls `module.init(variables)` in try/catch; called after `fina()` in both `modern.js:342` and `essence.js:540`. `.gitignore:32-33` ignores these dirs — needs a negation for ours. +- Browser internals: `Map_.resetView([lat, lon, zoom])` (`src/essence/Basics/Map_/Map_.js:429`); `L_.asLayerUUID(nameOrUuid)` + `await L_.toggleLayer(L_.layers.data[uuid])` (`Layers_.js:442`), visibility in `L_.layers.on[uuid]`; `ToolController_.makeTool(name)` (`ToolController_.js:514`); `TimeControl.setTime(start, end, isRelative, timeOffset, currentTime)` (`TimeControl.js:92`); current mission in `L_.mission` (`Layers_.js:16`). + +--- + +### Task 1: `mcp/` package scaffold + environment config + +**Files:** +- Create: `mcp/package.json` +- Create: `mcp/tsconfig.json` +- Create: `mcp/.gitignore` +- Create: `mcp/src/config.ts` +- Test: `mcp/tests/config.spec.ts` + +**Interfaces:** +- Consumes: nothing (first task). +- Produces: `loadConfig(env?): McpConfig` where `McpConfig = { mmgisUrl: string; mmgisToken: string; wsUrl: string; repoRoot: string; mapboxToken: string; stacCatalogs: Record; titilerUrl: string }`. All later tasks import `McpConfig`/`loadConfig` from `./config.js` (NodeNext ESM — internal imports use `.js` extensions). + +- [ ] **Step 1: Create the package files** + +`mcp/package.json`: +```json +{ + "name": "@mmgis/mcp-server", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { "mmgis-mcp": "dist/index.js" }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx src/index.ts", + "test": "vitest run" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "ws": "^8.18.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/ws": "^8.5.10", + "tsx": "^4.19.0", + "typescript": "^5.5.0", + "vitest": "^3.0.0" + } +} +``` + +`mcp/tsconfig.json`: +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "declaration": false, + "skipLibCheck": true + }, + "include": ["src"] +} +``` + +`mcp/.gitignore`: +``` +node_modules/ +dist/ +``` + +- [ ] **Step 2: Install dependencies** + +Run: `cd mcp && npm install` +Expected: lockfile created, no errors. (If a pinned version 404s, take the latest matching major — adjust `package.json` accordingly.) + +- [ ] **Step 3: Write the failing test** + +`mcp/tests/config.spec.ts`: +```ts +import { describe, it, expect } from 'vitest' +import { loadConfig } from '../src/config.js' + +const base = { MMGIS_TOKEN: 'tok123' } + +describe('loadConfig', () => { + it('throws without MMGIS_TOKEN', () => { + expect(() => loadConfig({})).toThrow(/MMGIS_TOKEN/) + }) + it('defaults MMGIS_URL to localhost:8888 and strips trailing slashes', () => { + expect(loadConfig({ ...base }).mmgisUrl).toBe('http://localhost:8888') + expect(loadConfig({ ...base, MMGIS_URL: 'https://gis.example.com/' }).mmgisUrl).toBe('https://gis.example.com') + }) + it('derives wsUrl from mmgisUrl unless MMGIS_WS_URL is set', () => { + expect(loadConfig({ ...base }).wsUrl).toBe('ws://localhost:8888/') + expect(loadConfig({ ...base, MMGIS_URL: 'https://gis.example.com' }).wsUrl).toBe('wss://gis.example.com/') + expect(loadConfig({ ...base, MMGIS_WS_URL: 'ws://elsewhere:9000/' }).wsUrl).toBe('ws://elsewhere:9000/') + }) + it('parses STAC_CATALOGS JSON and falls back to defaults', () => { + expect(loadConfig({ ...base, STAC_CATALOGS: '{"mine":"https://stac.me"}' }).stacCatalogs).toEqual({ mine: 'https://stac.me' }) + expect(Object.keys(loadConfig({ ...base }).stacCatalogs)).toContain('veda') + }) + it('resolves repoRoot to the MMGIS checkout by default', () => { + expect(loadConfig({ ...base }).repoRoot.endsWith('MMGIS')).toBe(true) + }) +}) +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `cd mcp && npx vitest run tests/config.spec.ts` +Expected: FAIL — cannot find module `../src/config.js`. + +- [ ] **Step 5: Implement `mcp/src/config.ts`** + +```ts +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +export interface McpConfig { + mmgisUrl: string + mmgisToken: string + wsUrl: string + repoRoot: string + mapboxToken: string + stacCatalogs: Record + titilerUrl: string +} + +const DEFAULT_STAC_CATALOGS: Record = { + veda: 'https://openveda.cloud/api/stac', + 'earth-search': 'https://earth-search.aws.element84.com/v1', +} + +export function loadConfig(env: Record = process.env): McpConfig { + if (!env.MMGIS_TOKEN) { + throw new Error( + 'MMGIS_TOKEN is required. Mint a long-term token: log into MMGIS as an admin, then POST /api/longtermtoken/generate (see mcp/README.md).' + ) + } + const mmgisUrl = (env.MMGIS_URL || 'http://localhost:8888').replace(/\/+$/, '') + // MMGIS's WS upgrade only accepts path (WEBSOCKET_ROOT_PATH || ROOT_PATH || '') + '/' + const wsUrl = env.MMGIS_WS_URL || mmgisUrl.replace(/^http/, 'ws') + '/' + // mcp/src (dev) and mcp/dist (built) are both one level below mcp/ + const defaultRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') + let stacCatalogs = DEFAULT_STAC_CATALOGS + if (env.STAC_CATALOGS) { + try { + stacCatalogs = JSON.parse(env.STAC_CATALOGS) + } catch { + throw new Error('STAC_CATALOGS must be a JSON object of {name: url}') + } + } + return { + mmgisUrl, + mmgisToken: env.MMGIS_TOKEN, + wsUrl, + repoRoot: env.MMGIS_REPO_ROOT || defaultRoot, + mapboxToken: env.MAPBOX_TOKEN || '', + stacCatalogs, + titilerUrl: (env.TITILER_URL || 'https://titiler.xyz').replace(/\/+$/, ''), + } +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd mcp && npx vitest run tests/config.spec.ts` +Expected: PASS (5 tests). + +- [ ] **Step 7: Commit** + +```bash +git add mcp/package.json mcp/tsconfig.json mcp/.gitignore mcp/src/config.ts mcp/tests/config.spec.ts mcp/package-lock.json +git commit -m "Scaffold MMGIS MCP server package with env config" +``` + +--- + +### Task 2: MMGIS REST client + +**Files:** +- Create: `mcp/src/mmgisClient.ts` +- Test: `mcp/tests/mmgisClient.spec.ts` + +**Interfaces:** +- Consumes: nothing from other tasks (constructed with url/token strings). +- Produces: `class MMGISError extends Error { hint?: string }`; `class MmgisClient { constructor(baseUrl: string, token: string, fetchFn?: typeof fetch); listMissions(): Promise; getMission(mission: string): Promise<{mission: string; config: any; version: number}>; addMission(mission: string, config: any): Promise<{mission: string; version: number}>; upsertMission(mission: string, config: any): Promise<{mission: string; version: number}> }`. + +- [ ] **Step 1: Write the failing test** + +`mcp/tests/mmgisClient.spec.ts`: +```ts +import { describe, it, expect, vi } from 'vitest' +import { MmgisClient, MMGISError } from '../src/mmgisClient.js' + +function fakeFetch(status: number, json: unknown) { + return vi.fn(async () => ({ ok: status < 400, status, json: async () => json })) as unknown as typeof fetch +} + +describe('MmgisClient', () => { + it('sends the Authorization header and returns mission names', async () => { + const f = fakeFetch(200, { status: 'success', missions: ['Demo'] }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + expect(await client.listMissions()).toEqual(['Demo']) + const [url, init] = (f as any).mock.calls[0] + expect(url).toBe('http://mm:8888/api/configure/missions') + expect(init.headers.Authorization).toBe('Bearer tok') + }) + it('getMission requests full config with encoded name', async () => { + const f = fakeFetch(200, { status: 'success', mission: 'A B', config: { msv: {} }, version: 3 }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + const out = await client.getMission('A B') + expect(out.version).toBe(3) + expect((f as any).mock.calls[0][0]).toBe('http://mm:8888/api/configure/get?mission=A%20B&full=true') + }) + it('addMission POSTs {mission, config, makedir}', async () => { + const f = fakeFetch(200, { status: 'success', mission: 'X', version: 0 }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + await client.addMission('X', { msv: {} }) + const [, init] = (f as any).mock.calls[0] + expect(init.method).toBe('POST') + expect(JSON.parse(init.body)).toEqual({ mission: 'X', config: { msv: {} }, makedir: true }) + }) + it('throws MMGISError with the server message on status:failure', async () => { + const f = fakeFetch(200, { status: 'failure', message: 'Mission already exists.' }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + await expect(client.addMission('X', {})).rejects.toThrow('Mission already exists.') + }) + it('throws MMGISError with a hint on HTTP errors', async () => { + const f = fakeFetch(500, {}) + const client = new MmgisClient('http://mm:8888', 'tok', f) + const err = await client.listMissions().catch((e) => e) + expect(err).toBeInstanceOf(MMGISError) + expect(err.hint).toMatch(/MMGIS_URL/) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd mcp && npx vitest run tests/mmgisClient.spec.ts` +Expected: FAIL — cannot find module `../src/mmgisClient.js`. + +- [ ] **Step 3: Implement `mcp/src/mmgisClient.ts`** + +```ts +export class MMGISError extends Error { + constructor(message: string, public readonly hint?: string) { + super(message) + this.name = 'MMGISError' + } +} + +export class MmgisClient { + constructor( + private baseUrl: string, + private token: string, + private fetchFn: typeof fetch = fetch + ) {} + + private async request(method: 'GET' | 'POST', apiPath: string, body?: unknown): Promise { + let res + try { + res = await this.fetchFn(`${this.baseUrl}${apiPath}`, { + method, + headers: { + Authorization: `Bearer ${this.token}`, + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + } catch (err) { + throw new MMGISError( + `Could not reach MMGIS at ${this.baseUrl}: ${(err as Error).message}`, + 'Check MMGIS_URL and that the MMGIS server is running.' + ) + } + if (!res.ok) { + throw new MMGISError( + `MMGIS responded ${res.status} for ${apiPath}`, + 'Check MMGIS_URL and that MMGIS_TOKEN is a valid, unexpired long-term token.' + ) + } + const json = await res.json() + if (json && json.status === 'failure') { + throw new MMGISError(json.message || `MMGIS reported failure for ${apiPath}`) + } + return json + } + + async listMissions(): Promise { + const json = await this.request('GET', '/api/configure/missions') + return json.missions + } + + async getMission(mission: string): Promise<{ mission: string; config: any; version: number }> { + return await this.request('GET', `/api/configure/get?mission=${encodeURIComponent(mission)}&full=true`) + } + + async addMission(mission: string, config: any): Promise<{ mission: string; version: number }> { + return await this.request('POST', '/api/configure/add', { mission, config, makedir: true }) + } + + async upsertMission(mission: string, config: any): Promise<{ mission: string; version: number }> { + return await this.request('POST', '/api/configure/upsert', { mission, config }) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd mcp && npx vitest run tests/mmgisClient.spec.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add mcp/src/mmgisClient.ts mcp/tests/mmgisClient.spec.ts +git commit -m "Add MMGIS REST client with long-term token auth" +``` + +--- + +### Task 3: MCP server skeleton + admin tools + +**Files:** +- Create: `mcp/src/tools/result.ts` +- Create: `mcp/src/tools/admin.ts` +- Create: `mcp/src/server.ts` +- Create: `mcp/src/index.ts` +- Test: `mcp/tests/admin.spec.ts` +- Test: `mcp/tests/server.spec.ts` + +**Interfaces:** +- Consumes: `MmgisClient`, `MMGISError` (Task 2); `McpConfig` (Task 1). +- Produces: `ToolDef = { name: string; description: string; schema: z.ZodRawShape; handler: (args: any) => Promise<{content: {type: 'text'; text: string}[]; isError?: boolean}> }`; `toToolResult(data: unknown)`, `toErrorResult(err: unknown)` (result.ts); `makeAdminTools(client: MmgisClient): ToolDef[]`; `buildServer(deps: { tools: ToolDef[] }): McpServer` (server.ts); `index.ts` as the stdio entrypoint. Tasks 5, 6, 8 each add a `make*Tools(...): ToolDef[]` factory and register it in `index.ts`. + +- [ ] **Step 1: Write the failing tests** + +`mcp/tests/admin.spec.ts`: +```ts +import { describe, it, expect } from 'vitest' +import { makeAdminTools } from '../src/tools/admin.js' +import { MMGISError } from '../src/mmgisClient.js' + +const fakeClient = { + listMissions: async () => ['Demo', 'Mars2020'], + getMission: async (m: string) => ({ mission: m, config: { msv: { mission: m } }, version: 2 }), +} as any + +function parse(res: { content: { text: string }[] }) { + return JSON.parse(res.content[0].text) +} + +describe('admin tools', () => { + const tools = Object.fromEntries(makeAdminTools(fakeClient).map((t) => [t.name, t])) + + it('exposes mission_list and mission_get', () => { + expect(Object.keys(tools).sort()).toEqual(['mission_get', 'mission_list']) + }) + it('mission_list returns mission names', async () => { + expect(parse(await tools.mission_list.handler({}))).toEqual({ missions: ['Demo', 'Mars2020'] }) + }) + it('mission_get returns config and version', async () => { + const out = parse(await tools.mission_get.handler({ mission: 'Demo' })) + expect(out.version).toBe(2) + expect(out.config.msv.mission).toBe('Demo') + }) + it('errors become structured {error, hint} results with isError', async () => { + const failing = { listMissions: async () => { throw new MMGISError('boom', 'try this') } } as any + const t = Object.fromEntries(makeAdminTools(failing).map((x) => [x.name, x])) + const res = await t.mission_list.handler({}) + expect(res.isError).toBe(true) + expect(parse(res)).toEqual({ error: 'boom', hint: 'try this' }) + }) +}) +``` + +`mcp/tests/server.spec.ts`: +```ts +import { describe, it, expect } from 'vitest' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { buildServer } from '../src/server.js' +import { makeAdminTools } from '../src/tools/admin.js' + +describe('buildServer', () => { + it('registers tools and answers listTools over MCP', async () => { + const server = buildServer({ tools: makeAdminTools({ listMissions: async () => [] } as any) }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + const client = new Client({ name: 'test', version: '0.0.0' }) + await client.connect(clientTransport) + const { tools } = await client.listTools() + expect(tools.map((t) => t.name)).toContain('mission_list') + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd mcp && npx vitest run tests/admin.spec.ts tests/server.spec.ts` +Expected: FAIL — modules not found. + +- [ ] **Step 3: Implement result helpers, admin tools, server factory, entrypoint** + +`mcp/src/tools/result.ts`: +```ts +import type { z } from 'zod' + +export interface ToolDef { + name: string + description: string + schema: z.ZodRawShape + handler: (args: any) => Promise<{ content: { type: 'text'; text: string }[]; isError?: boolean }> +} + +export function toToolResult(data: unknown) { + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] } +} + +export function toErrorResult(err: unknown) { + const e = err as { message?: string; hint?: string } + return { + isError: true, + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: e?.message || String(err), ...(e?.hint ? { hint: e.hint } : {}) }), + }, + ], + } +} +``` + +`mcp/src/tools/admin.ts`: +```ts +import { z } from 'zod' +import type { MmgisClient } from '../mmgisClient.js' +import { type ToolDef, toToolResult, toErrorResult } from './result.js' + +export function makeAdminTools(client: MmgisClient): ToolDef[] { + return [ + { + name: 'mission_list', + description: 'List all mission (dashboard) names in this MMGIS deployment.', + schema: {}, + handler: async () => { + try { + return toToolResult({ missions: await client.listMissions() }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + { + name: 'mission_get', + description: "Get a mission's full configuration JSON and current version.", + schema: { mission: z.string().describe('Mission name (see mission_list)') }, + handler: async ({ mission }: { mission: string }) => { + try { + const out = await client.getMission(mission) + return toToolResult({ mission: out.mission, version: out.version, config: out.config }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + ] +} +``` + +`mcp/src/server.ts`: +```ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { ToolDef } from './tools/result.js' + +export function buildServer(deps: { tools: ToolDef[] }): McpServer { + const server = new McpServer({ name: 'mmgis', version: '0.1.0' }) + for (const t of deps.tools) { + server.tool(t.name, t.description, t.schema, t.handler) + } + return server +} +``` +(If the installed SDK version has deprecated `server.tool(name, description, schema, handler)`, use `server.registerTool(name, { description, inputSchema: t.schema }, t.handler)` instead — check `node_modules/@modelcontextprotocol/sdk` README and adapt; the `ToolDef` shape stays the same.) + +`mcp/src/index.ts`: +```ts +#!/usr/bin/env node +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { loadConfig } from './config.js' +import { MmgisClient } from './mmgisClient.js' +import { makeAdminTools } from './tools/admin.js' +import { buildServer } from './server.js' + +async function main() { + const cfg = loadConfig() + const client = new MmgisClient(cfg.mmgisUrl, cfg.mmgisToken) + const server = buildServer({ tools: [...makeAdminTools(client)] }) + await server.connect(new StdioServerTransport()) + // stdio server runs until the client disconnects +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd mcp && npx vitest run tests/admin.spec.ts tests/server.spec.ts` +Expected: PASS. + +- [ ] **Step 5: Verify the build compiles** + +Run: `cd mcp && npm run build` +Expected: exit 0, `dist/index.js` exists. + +- [ ] **Step 6: Commit** + +```bash +git add mcp/src mcp/tests +git commit -m "Add MCP server skeleton with mission admin tools" +``` + +--- + +### Task 4: Profile builder + config-generator invocation + +**Files:** +- Create: `mcp/src/profileBuilder.ts` +- Create: `mcp/src/generator.ts` +- Test: `mcp/tests/profileBuilder.spec.ts` +- Test: `mcp/tests/generator.spec.ts` + +**Interfaces:** +- Consumes: `MMGISError` (Task 2); `repoRoot` string from `McpConfig` (Task 1). +- Produces: + - `DashboardSpec = { missionName: string; layers?: any[]; view?: {lat: number; lon: number; zoom: number}; tools?: string[]; on?: string[]; time?: Record; overrides?: Record}>; pageName?: string }` + - `buildProfile(spec: DashboardSpec, repoRoot: string): any` — full generator profile based on `mission-profiles/minimal.json`. + - `generateConfig(profile: any, repoRoot: string): Promise` — runs the CLI, returns the validated config object. + - `resolvePlaceholders(config: any, mapboxToken: string): any` + - `listAvailableTools(repoRoot: string): Promise` + - `AGENT_BRIDGE_COMPONENT = { name: 'AgentBridge', js: 'AgentBridge', on: true, variables: {} }` (exported constant; Task 5 injects it into generated configs — matches the component from Task 7). + +- [ ] **Step 1: Write the failing tests** + +`mcp/tests/profileBuilder.spec.ts`: +```ts +import { describe, it, expect } from 'vitest' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { buildProfile } from '../src/profileBuilder.js' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') + +describe('buildProfile', () => { + it('bases the profile on minimal.json with mission name applied', () => { + const p = buildProfile({ missionName: 'AQ Atlanta' }, repoRoot) + expect(p.scaffold.msv.mission).toBe('AQ Atlanta') + expect(p.scaffold.msv.missionFolderName).toBe('AQ Atlanta') + expect(p.tools).toContain('Title') + expect(p.tools).toContain('LayerManager') + expect(p.output).toBeUndefined() + }) + it('applies view as a string triple and pageName', () => { + const p = buildProfile( + { missionName: 'M', view: { lat: 33.75, lon: -84.39, zoom: 10 }, pageName: 'Air Quality' }, + repoRoot + ) + expect(p.scaffold.msv.view).toEqual(['33.75', '-84.39', '10']) + expect(p.scaffold.look.pagename).toBe('Air Quality') + }) + it('mints uuids for layers that lack one and fills envelope defaults', () => { + const p = buildProfile( + { missionName: 'M', layers: [{ name: 'NO2', type: 'TileLayer', url: 'https://t/{z}/{x}/{y}.png' }] }, + repoRoot + ) + const layer = p.scaffold.layers[0] + expect(layer.uuid).toMatch(/^[0-9a-f-]{36}$/) + expect(layer.sublayers).toEqual([]) + expect(layer.visibility).toBe(true) + expect(layer.name).toBe('NO2') + }) + it('merges extra tools and overrides without dropping the minimal set', () => { + const p = buildProfile( + { missionName: 'M', tools: ['Chart'], on: ['Chart'], overrides: { Chart: { variables: { a: 1 } } } }, + repoRoot + ) + expect(p.tools).toEqual(expect.arrayContaining(['Title', 'LayerManager', 'Chart'])) + expect(p.on).toContain('Chart') + expect(p.overrides.Chart.variables.a).toBe(1) + }) +}) +``` + +`mcp/tests/generator.spec.ts` (integration — runs the real repo CLI): +```ts +import { describe, it, expect } from 'vitest' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { buildProfile } from '../src/profileBuilder.js' +import { generateConfig, resolvePlaceholders, listAvailableTools } from '../src/generator.js' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') + +describe('generateConfig (integration with scripts/generate-mission-config.js)', () => { + it('generates a validated config from a built profile', async () => { + const profile = buildProfile( + { + missionName: 'MCP Test', + view: { lat: 33.75, lon: -84.39, zoom: 10 }, + layers: [ + { + name: 'Basemap Test', + type: 'TileLayer', + sourceType: 'url', + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + tileformat: 'wmts', + controlled: false, + initialOpacity: 1, + minZoom: 0, + maxNativeZoom: 18, + maxZoom: 22, + }, + ], + }, + repoRoot + ) + const config = await generateConfig(profile, repoRoot) + expect(config.msv.mission).toBe('MCP Test') + expect(config.tools.map((t: any) => t.name)).toContain('Title') + expect(config.layers[0].name).toBe('Basemap Test') + }, 30000) + + it('surfaces generator validation errors with a hint', async () => { + const profile = buildProfile({ missionName: 'Bad' }, repoRoot) + delete profile.scaffold.projection // break the template superset + const err = await generateConfig(profile, repoRoot).catch((e) => e) + expect(err.name).toBe('MMGISError') + expect(err.hint).toMatch(/profile/i) + }, 30000) +}) + +describe('resolvePlaceholders', () => { + it('replaces {{MAPBOX_TOKEN}} everywhere', () => { + const out = resolvePlaceholders({ a: { token: '{{MAPBOX_TOKEN}}' } }, 'pk.test') + expect(out.a.token).toBe('pk.test') + }) +}) + +describe('listAvailableTools', () => { + it('returns the generatable tool names', async () => { + const names = await listAvailableTools(repoRoot) + expect(names).toContain('Title') + expect(names).toContain('LayerManager') + }, 30000) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd mcp && npx vitest run tests/profileBuilder.spec.ts tests/generator.spec.ts` +Expected: FAIL — modules not found. + +- [ ] **Step 3: Implement `mcp/src/profileBuilder.ts`** + +```ts +import { randomUUID } from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +export interface DashboardSpec { + missionName: string + layers?: any[] + view?: { lat: number; lon: number; zoom: number } + tools?: string[] + on?: string[] + time?: Record + overrides?: Record }> + pageName?: string +} + +// Mission-config entry that enables the AgentBridge browser component (Task 7) +export const AGENT_BRIDGE_COMPONENT = { + name: 'AgentBridge', + js: 'AgentBridge', + on: true, + variables: {}, +} + +export function buildProfile(spec: DashboardSpec, repoRoot: string): any { + const minimal = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'mission-profiles', 'minimal.json'), 'utf8') + ) + const profile = JSON.parse(JSON.stringify(minimal)) + profile.name = `agent-${spec.missionName}` + profile.description = 'Generated by the MMGIS MCP server' + delete profile.output + profile.tools = Array.from(new Set([...minimal.tools, ...(spec.tools || [])])) + profile.on = Array.from(new Set([...minimal.on, ...(spec.on || [])])) + profile.overrides = spec.overrides || {} + + const scaffold = profile.scaffold + scaffold.msv.mission = spec.missionName + scaffold.msv.missionFolderName = spec.missionName + if (spec.view) { + scaffold.msv.view = [String(spec.view.lat), String(spec.view.lon), String(spec.view.zoom)] + } + if (spec.pageName) scaffold.look.pagename = spec.pageName + if (spec.time) scaffold.time = spec.time + scaffold.layers = (spec.layers || []).map((l) => ({ + uuid: randomUUID(), + sublayers: [], + visibility: true, + ...l, + })) + return profile +} +``` + +- [ ] **Step 4: Implement `mcp/src/generator.ts`** + +```ts +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { MMGISError } from './mmgisClient.js' + +const execFileAsync = promisify(execFile) + +export async function generateConfig(profile: any, repoRoot: string): Promise { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mmgis-mcp-')) + const profilePath = path.join(tmpDir, 'profile.json') + try { + fs.writeFileSync(profilePath, JSON.stringify(profile, null, 2)) + const { stdout } = await execFileAsync( + process.execPath, + ['scripts/generate-mission-config.js', profilePath, '--stdout'], + { cwd: repoRoot, maxBuffer: 32 * 1024 * 1024 } + ) + return JSON.parse(stdout) + } catch (err: any) { + if (err instanceof SyntaxError) { + throw new MMGISError('Config generator produced unparseable output', 'Run the generator manually to debug.') + } + const detail = String(err?.stderr || err?.message || err).trim() + throw new MMGISError(`Config generation failed: ${detail}`, 'Fix the profile fields named in the error and retry.') + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } +} + +export function resolvePlaceholders(config: any, mapboxToken: string): any { + // Tokens are URL-safe (alphanumeric + dots); plain string replace is safe here + return JSON.parse(JSON.stringify(config).split('{{MAPBOX_TOKEN}}').join(mapboxToken)) +} + +export async function listAvailableTools(repoRoot: string): Promise { + const minimal = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'mission-profiles', 'minimal.json'), 'utf8') + ) + const probe = JSON.parse(JSON.stringify(minimal)) + probe.tools = 'all' + probe.on = [] + delete probe.output + const config = await generateConfig(probe, repoRoot) + return config.tools.map((t: { name: string }) => t.name) +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd mcp && npx vitest run tests/profileBuilder.spec.ts tests/generator.spec.ts` +Expected: PASS. (These shell out to the repo's real generator — first run may take a few seconds.) + +- [ ] **Step 6: Commit** + +```bash +git add mcp/src/profileBuilder.ts mcp/src/generator.ts mcp/tests/profileBuilder.spec.ts mcp/tests/generator.spec.ts +git commit -m "Add dashboard profile builder wrapping the mission config generator" +``` + +--- + +### Task 5: Dashboard MCP tools (NL → mission) + +**Files:** +- Create: `mcp/src/tools/dashboard.ts` +- Modify: `mcp/src/index.ts` (register dashboard tools) +- Test: `mcp/tests/dashboard.spec.ts` + +**Interfaces:** +- Consumes: `buildProfile`, `DashboardSpec`, `AGENT_BRIDGE_COMPONENT` (Task 4); `generateConfig`, `resolvePlaceholders`, `listAvailableTools` (Task 4); `MmgisClient`, `MMGISError` (Task 2); `McpConfig` (Task 1); `ToolDef`, `toToolResult`, `toErrorResult` (Task 3). +- Produces: `makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef[]` exposing three tools: `dashboard_profile_schema`, `dashboard_tool_options`, `dashboard_generate`. + +- [ ] **Step 1: Write the failing test** + +`mcp/tests/dashboard.spec.ts`: +```ts +import { describe, it, expect } from 'vitest' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { makeDashboardTools } from '../src/tools/dashboard.js' +import { MMGISError } from '../src/mmgisClient.js' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') +const cfg = { + mmgisUrl: 'http://mm:8888', + mmgisToken: 't', + wsUrl: 'ws://mm:8888/', + repoRoot, + mapboxToken: 'pk.test', + stacCatalogs: {}, + titilerUrl: 'https://titiler.xyz', +} as any + +function parse(res: { content: { text: string }[] }) { + return JSON.parse(res.content[0].text) +} + +describe('dashboard tools', () => { + it('dashboard_profile_schema documents the DashboardSpec shape with layer examples', async () => { + const tools = Object.fromEntries(makeDashboardTools({} as any, cfg).map((t) => [t.name, t])) + const schema = parse(await tools.dashboard_profile_schema.handler({})) + expect(schema.spec.missionName).toBeDefined() + expect(schema.layerExamples.tile.type).toBe('TileLayer') + expect(schema.layerExamples.geojson.type).toBe('GeoJsonLayer') + }) + + it('dashboard_generate builds, generates, injects AgentBridge, resolves tokens, and adds the mission', async () => { + const calls: any[] = [] + const client = { + addMission: async (mission: string, config: any) => { + calls.push({ mission, config }) + return { mission, version: 0 } + }, + } as any + const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) + const out = parse( + await tools.dashboard_generate.handler({ + missionName: 'AQ Test', + view: { lat: 33.7, lon: -84.4, zoom: 9 }, + layers: [ + { + name: 'NO2', + type: 'TileLayer', + sourceType: 'url', + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + tileformat: 'wmts', + controlled: false, + initialOpacity: 1, + minZoom: 0, + maxNativeZoom: 18, + maxZoom: 22, + }, + ], + }) + ) + expect(out.mission).toBe('AQ Test') + expect(out.url).toBe('http://mm:8888/?mission=AQ%20Test') + const posted = calls[0].config + expect(posted.components).toEqual([{ name: 'AgentBridge', js: 'AgentBridge', on: true, variables: {} }]) + expect(JSON.stringify(posted)).not.toContain('{{MAPBOX_TOKEN}}') + expect(posted.msv.basemap.accessToken).toBe('pk.test') + }, 30000) + + it('falls back to upsert when the mission exists and updateExisting is set', async () => { + const client = { + addMission: async () => { + throw new MMGISError('Mission already exists.') + }, + upsertMission: async (mission: string) => ({ mission, version: 4 }), + } as any + const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) + const out = parse(await tools.dashboard_generate.handler({ missionName: 'AQ Test', updateExisting: true })) + expect(out.version).toBe(4) + }, 30000) + + it('reports exists-error with a hint when updateExisting is not set', async () => { + const client = { + addMission: async () => { + throw new MMGISError('Mission already exists.') + }, + } as any + const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) + const res = await tools.dashboard_generate.handler({ missionName: 'AQ Test' }) + expect(res.isError).toBe(true) + expect(parse(res).hint).toMatch(/updateExisting/) + }, 30000) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd mcp && npx vitest run tests/dashboard.spec.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `mcp/src/tools/dashboard.ts`** + +```ts +import { z } from 'zod' +import type { MmgisClient } from '../mmgisClient.js' +import type { McpConfig } from '../config.js' +import { buildProfile, AGENT_BRIDGE_COMPONENT, type DashboardSpec } from '../profileBuilder.js' +import { generateConfig, resolvePlaceholders, listAvailableTools } from '../generator.js' +import { type ToolDef, toToolResult, toErrorResult } from './result.js' + +const LAYER_EXAMPLES = { + tile: { + name: 'Sentinel-2 True Color', + type: 'TileLayer', + sourceType: 'url', + url: 'https://example.com/tiles/WebMercatorQuad/{z}/{x}/{y}@1x.png', + tileformat: 'wmts', + controlled: false, + initialOpacity: 1, + minZoom: 0, + maxNativeZoom: 18, + maxZoom: 22, + boundingBox: [-88.1, 36.0, -86.8, 37.1], + style: { brightness: 1, contrast: 1, saturation: 1, blend: 'none' }, + time: { enabled: false }, + variables: {}, + }, + geojson: { + name: 'Monitoring Stations', + type: 'GeoJsonLayer', + sourceType: 'url', + url: 'https://example.com/stations.geojson', + controlled: false, + initialOpacity: 1, + visibility: true, + style: {}, + variables: {}, + }, +} + +const dashboardGenerateSchema = { + missionName: z.string().describe('Name for the new mission/dashboard'), + layers: z + .array(z.record(z.any())) + .optional() + .describe('MMGIS layer entries (see dashboard_profile_schema layerExamples). uuids are minted automatically.'), + view: z + .object({ lat: z.number(), lon: z.number(), zoom: z.number() }) + .optional() + .describe('Initial map view'), + tools: z.array(z.string()).optional().describe('Extra tools beyond Title+LayerManager (see dashboard_tool_options)'), + on: z.array(z.string()).optional().describe('Tools that start opened'), + time: z.record(z.any()).optional().describe('Time config, e.g. {"enabled": true}'), + overrides: z.record(z.object({ variables: z.record(z.any()) })).optional(), + pageName: z.string().optional().describe('Browser page title / branding'), + updateExisting: z.boolean().optional().describe('If the mission exists, replace its config (new version)'), +} + +export function makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef[] { + return [ + { + name: 'dashboard_profile_schema', + description: + 'Get the input schema and layer-entry examples for dashboard_generate. Call this before generating a dashboard.', + schema: {}, + handler: async () => + toToolResult({ + spec: { + missionName: 'string (required)', + layers: 'array of MMGIS layer entries — see layerExamples', + view: '{lat, lon, zoom} initial map view', + tools: 'string[] extra tools (dashboard_tool_options lists valid names)', + on: 'string[] tools opened at start', + time: 'object, e.g. {"enabled": true} for time-enabled layers', + overrides: '{ToolName: {variables: {...}}} per-tool settings', + pageName: 'string page title', + updateExisting: 'boolean — replace config if mission exists', + }, + layerExamples: LAYER_EXAMPLES, + }), + }, + { + name: 'dashboard_tool_options', + description: 'List tool names that dashboard_generate can include in a dashboard.', + schema: {}, + handler: async () => { + try { + return toToolResult({ tools: await listAvailableTools(cfg.repoRoot) }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + { + name: 'dashboard_generate', + description: + 'Generate a complete MMGIS mission (dashboard) from a description of layers, view, and tools, and install it. Returns the mission URL.', + schema: dashboardGenerateSchema, + handler: async (args: DashboardSpec & { updateExisting?: boolean }) => { + try { + const profile = buildProfile(args, cfg.repoRoot) + let config = await generateConfig(profile, cfg.repoRoot) + config = resolvePlaceholders(config, cfg.mapboxToken) + // Injected after generation: `components` is not a template key, + // and /api/configure/add does not run backend validation. + config.components = [AGENT_BRIDGE_COMPONENT] + let out + try { + out = await client.addMission(args.missionName, config) + } catch (err: any) { + if (/already exists/i.test(err?.message || '') && args.updateExisting) { + out = await client.upsertMission(args.missionName, config) + } else if (/already exists/i.test(err?.message || '')) { + err.hint = 'Pass updateExisting: true to replace the existing mission config.' + throw err + } else { + throw err + } + } + return toToolResult({ + mission: out.mission, + version: out.version, + url: `${cfg.mmgisUrl}/?mission=${encodeURIComponent(args.missionName)}`, + }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + ] +} +``` + +- [ ] **Step 4: Register in `mcp/src/index.ts`** + +Add imports and extend the tools array: +```ts +import { makeDashboardTools } from './tools/dashboard.js' +``` +and change the `buildServer` call to: +```ts + const server = buildServer({ + tools: [...makeAdminTools(client), ...makeDashboardTools(client, cfg)], + }) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd mcp && npx vitest run tests/dashboard.spec.ts && npm run build` +Expected: PASS; build exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add mcp/src/tools/dashboard.ts mcp/src/index.ts mcp/tests/dashboard.spec.ts +git commit -m "Add dashboard generation MCP tools" +``` + +--- + +### Task 6: STAC catalog tools + +**Files:** +- Create: `mcp/src/stac.ts` +- Create: `mcp/src/tools/catalog.ts` +- Modify: `mcp/src/index.ts` (register catalog tools) +- Test: `mcp/tests/stac.spec.ts` +- Test: `mcp/tests/catalog.spec.ts` + +**Interfaces:** +- Consumes: `McpConfig` (Task 1); `ToolDef`, `toToolResult`, `toErrorResult` (Task 3); `MMGISError` (Task 2). +- Produces: + - `StacItemSummary = { id: string; collection: string; datetime: string | null; bbox: number[] | null; selfHref: string | null; assets: {key: string; title?: string; type?: string; href: string}[] }` + - `searchStac(catalogUrl: string, params: {bbox?: number[]; datetime?: string; collections?: string[]; limit?: number}, fetchFn?: typeof fetch): Promise` + - `searchCollections(catalogUrl: string, keyword?: string, fetchFn?: typeof fetch): Promise<{id: string; title?: string; description?: string}[]>` + - `stacItemToTileLayer(item: StacItemSummary, opts: {name: string; titilerUrl: string; asset?: string; rescale?: string; colormap?: string}): any` + - `makeCatalogTools(cfg: McpConfig): ToolDef[]` exposing `catalog_collections`, `catalog_search`, `catalog_item_to_layer`. + +- [ ] **Step 1: Write the failing tests** + +`mcp/tests/stac.spec.ts`: +```ts +import { describe, it, expect, vi } from 'vitest' +import { searchStac, searchCollections, stacItemToTileLayer } from '../src/stac.js' + +const ITEM = { + id: 'i1', + collection: 'no2-monthly', + bbox: [-90, 30, -80, 40], + properties: { datetime: '2026-06-01T00:00:00Z' }, + links: [{ rel: 'self', href: 'https://stac.test/collections/no2-monthly/items/i1' }], + assets: { cog_default: { href: 'https://data.test/i1.tif', type: 'image/tiff', title: 'COG' } }, +} + +function fakeFetch(json: unknown) { + return vi.fn(async () => ({ ok: true, status: 200, json: async () => json })) as unknown as typeof fetch +} + +describe('searchStac', () => { + it('POSTs to /search and summarizes items', async () => { + const f = fakeFetch({ features: [ITEM] }) + const items = await searchStac('https://stac.test', { bbox: [-90, 30, -80, 40], collections: ['no2-monthly'], limit: 5 }, f) + expect((f as any).mock.calls[0][0]).toBe('https://stac.test/search') + expect(JSON.parse((f as any).mock.calls[0][1].body)).toEqual({ + bbox: [-90, 30, -80, 40], + collections: ['no2-monthly'], + limit: 5, + }) + expect(items[0]).toEqual({ + id: 'i1', + collection: 'no2-monthly', + datetime: '2026-06-01T00:00:00Z', + bbox: [-90, 30, -80, 40], + selfHref: 'https://stac.test/collections/no2-monthly/items/i1', + assets: [{ key: 'cog_default', title: 'COG', type: 'image/tiff', href: 'https://data.test/i1.tif' }], + }) + }) +}) + +describe('searchCollections', () => { + it('filters collections by keyword across id/title/description', async () => { + const f = fakeFetch({ + collections: [ + { id: 'no2-monthly', title: 'NO2 Monthly', description: 'Nitrogen dioxide' }, + { id: 'dem', title: 'Elevation', description: 'Terrain' }, + ], + }) + const out = await searchCollections('https://stac.test', 'nitrogen', f) + expect(out.map((c) => c.id)).toEqual(['no2-monthly']) + }) +}) + +describe('stacItemToTileLayer', () => { + it('builds a TileLayer entry with a titiler stac tile URL', () => { + const item = { + id: 'i1', + collection: 'no2-monthly', + datetime: '2026-06-01T00:00:00Z', + bbox: [-90, 30, -80, 40], + selfHref: 'https://stac.test/collections/no2-monthly/items/i1', + assets: [{ key: 'cog_default', href: 'https://data.test/i1.tif' }], + } + const layer = stacItemToTileLayer(item as any, { name: 'NO2 June', titilerUrl: 'https://titiler.xyz', asset: 'cog_default' }) + expect(layer.type).toBe('TileLayer') + expect(layer.name).toBe('NO2 June') + expect(layer.boundingBox).toEqual([-90, 30, -80, 40]) + expect(layer.url).toBe( + 'https://titiler.xyz/stac/tiles/WebMercatorQuad/{z}/{x}/{y}@1x.png?url=' + + encodeURIComponent('https://stac.test/collections/no2-monthly/items/i1') + + '&assets=cog_default' + ) + }) +}) +``` + +`mcp/tests/catalog.spec.ts`: +```ts +import { describe, it, expect } from 'vitest' +import { makeCatalogTools } from '../src/tools/catalog.js' + +const cfg = { stacCatalogs: { test: 'https://stac.test' }, titilerUrl: 'https://titiler.xyz' } as any + +describe('catalog tools', () => { + const tools = Object.fromEntries(makeCatalogTools(cfg).map((t) => [t.name, t])) + it('exposes the three catalog tools', () => { + expect(Object.keys(tools).sort()).toEqual(['catalog_collections', 'catalog_item_to_layer', 'catalog_search']) + }) + it('rejects unknown catalog names with the configured list in the hint', async () => { + const res = await tools.catalog_search.handler({ catalog: 'nope' }) + expect(res.isError).toBe(true) + expect(JSON.parse(res.content[0].text).hint).toContain('test') + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd mcp && npx vitest run tests/stac.spec.ts tests/catalog.spec.ts` +Expected: FAIL — modules not found. + +- [ ] **Step 3: Implement `mcp/src/stac.ts`** + +```ts +import { MMGISError } from './mmgisClient.js' + +export interface StacItemSummary { + id: string + collection: string + datetime: string | null + bbox: number[] | null + selfHref: string | null + assets: { key: string; title?: string; type?: string; href: string }[] +} + +async function stacFetch(url: string, init: RequestInit | undefined, fetchFn: typeof fetch): Promise { + let res + try { + res = await fetchFn(url, init) + } catch (err) { + throw new MMGISError( + `Could not reach STAC catalog at ${url}: ${(err as Error).message}`, + 'The catalog may be down — try another configured catalog, or use layers already in the deployment.' + ) + } + if (!res.ok) throw new MMGISError(`STAC catalog responded ${res.status} for ${url}`) + return await res.json() +} + +function summarizeItem(feature: any): StacItemSummary { + return { + id: feature.id, + collection: feature.collection, + datetime: feature.properties?.datetime ?? null, + bbox: feature.bbox ?? null, + selfHref: (feature.links || []).find((l: any) => l.rel === 'self')?.href ?? null, + assets: Object.entries(feature.assets || {}).map(([key, a]: [string, any]) => ({ + key, + ...(a.title ? { title: a.title } : {}), + ...(a.type ? { type: a.type } : {}), + href: a.href, + })), + } +} + +export async function searchStac( + catalogUrl: string, + params: { bbox?: number[]; datetime?: string; collections?: string[]; limit?: number }, + fetchFn: typeof fetch = fetch +): Promise { + const body: Record = { limit: params.limit ?? 10 } + if (params.bbox) body.bbox = params.bbox + if (params.datetime) body.datetime = params.datetime + if (params.collections) body.collections = params.collections + const json = await stacFetch( + `${catalogUrl.replace(/\/+$/, '')}/search`, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, + fetchFn + ) + return (json.features || []).map(summarizeItem) +} + +export async function searchCollections( + catalogUrl: string, + keyword?: string, + fetchFn: typeof fetch = fetch +): Promise<{ id: string; title?: string; description?: string }[]> { + const json = await stacFetch(`${catalogUrl.replace(/\/+$/, '')}/collections`, undefined, fetchFn) + let collections = (json.collections || []).map((c: any) => ({ + id: c.id, + ...(c.title ? { title: c.title } : {}), + ...(c.description ? { description: c.description } : {}), + })) + if (keyword) { + const k = keyword.toLowerCase() + collections = collections.filter((c: any) => + [c.id, c.title, c.description].some((s) => s && s.toLowerCase().includes(k)) + ) + } + return collections +} + +export function stacItemToTileLayer( + item: StacItemSummary, + opts: { name: string; titilerUrl: string; asset?: string; rescale?: string; colormap?: string } +): any { + if (!item.selfHref) { + throw new MMGISError(`STAC item ${item.id} has no self link; cannot build a tile URL`) + } + const asset = opts.asset || item.assets[0]?.key + if (!asset) throw new MMGISError(`STAC item ${item.id} has no assets`) + let url = + `${opts.titilerUrl}/stac/tiles/WebMercatorQuad/{z}/{x}/{y}@1x.png` + + `?url=${encodeURIComponent(item.selfHref)}&assets=${asset}` + if (opts.rescale) url += `&rescale=${opts.rescale}` + if (opts.colormap) url += `&colormap_name=${opts.colormap}` + return { + name: opts.name, + type: 'TileLayer', + sourceType: 'url', + url, + tileformat: 'wmts', + controlled: false, + visibility: true, + initialOpacity: 1, + minZoom: 0, + maxNativeZoom: 18, + maxZoom: 22, + ...(item.bbox ? { boundingBox: item.bbox } : {}), + style: { brightness: 1, contrast: 1, saturation: 1, blend: 'none' }, + time: { enabled: false }, + variables: {}, + } +} +``` + +- [ ] **Step 4: Implement `mcp/src/tools/catalog.ts`** + +```ts +import { z } from 'zod' +import type { McpConfig } from '../config.js' +import { searchStac, searchCollections, stacItemToTileLayer } from '../stac.js' +import { MMGISError } from '../mmgisClient.js' +import { type ToolDef, toToolResult, toErrorResult } from './result.js' + +function resolveCatalog(cfg: McpConfig, catalog: string): string { + if (/^https?:\/\//.test(catalog)) return catalog + const url = cfg.stacCatalogs[catalog] + if (!url) { + throw new MMGISError( + `Unknown catalog "${catalog}"`, + `Configured catalogs: ${Object.keys(cfg.stacCatalogs).join(', ')} — or pass a full STAC API URL.` + ) + } + return url +} + +export function makeCatalogTools(cfg: McpConfig): ToolDef[] { + return [ + { + name: 'catalog_collections', + description: 'List/search dataset collections in a STAC catalog. Use to find data for a dashboard.', + schema: { + catalog: z.string().describe(`Catalog name (${Object.keys(cfg.stacCatalogs).join(', ')}) or a STAC API URL`), + keyword: z.string().optional().describe('Filter by keyword, e.g. "no2", "fire", "flood"'), + }, + handler: async ({ catalog, keyword }: { catalog: string; keyword?: string }) => { + try { + return toToolResult({ collections: await searchCollections(resolveCatalog(cfg, catalog), keyword) }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + { + name: 'catalog_search', + description: 'Search a STAC catalog for items (scenes/granules) by collection, bbox, and datetime.', + schema: { + catalog: z.string().describe('Catalog name or STAC API URL'), + collections: z.array(z.string()).optional(), + bbox: z.array(z.number()).length(4).optional().describe('[west, south, east, north]'), + datetime: z.string().optional().describe('RFC3339 interval, e.g. "2026-01-01T00:00:00Z/2026-06-30T23:59:59Z"'), + limit: z.number().optional(), + }, + handler: async ({ catalog, ...params }: any) => { + try { + return toToolResult({ items: await searchStac(resolveCatalog(cfg, catalog), params) }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + { + name: 'catalog_item_to_layer', + description: + 'Convert a STAC item (from catalog_search) into an MMGIS TileLayer entry for dashboard_generate, rendered through TiTiler.', + schema: { + item: z.record(z.any()).describe('A StacItemSummary object exactly as returned by catalog_search'), + name: z.string().describe('Display name for the layer'), + asset: z.string().optional().describe('Asset key to render (defaults to the first asset)'), + rescale: z.string().optional().describe('e.g. "0,255"'), + colormap: z.string().optional().describe('e.g. "viridis"'), + }, + handler: async ({ item, name, asset, rescale, colormap }: any) => { + try { + return toToolResult({ + layer: stacItemToTileLayer(item, { name, titilerUrl: cfg.titilerUrl, asset, rescale, colormap }), + }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + ] +} +``` + +- [ ] **Step 5: Register in `mcp/src/index.ts`** + +Add `import { makeCatalogTools } from './tools/catalog.js'` and extend the tools array with `...makeCatalogTools(cfg)`. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd mcp && npx vitest run tests/stac.spec.ts tests/catalog.spec.ts && npm run build` +Expected: PASS; build exit 0. + +- [ ] **Step 7: Commit** + +```bash +git add mcp/src/stac.ts mcp/src/tools/catalog.ts mcp/src/index.ts mcp/tests/stac.spec.ts mcp/tests/catalog.spec.ts +git commit -m "Add STAC catalog search and layer conversion tools" +``` + +--- + +### Task 7: AgentBridge frontend Plugin-Component + +**Files:** +- Modify: `.gitignore` (add negation after line 33) +- Create: `src/essence/MMGIS-Plugin-Components/AgentBridge/config.json` +- Create: `src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js` +- Create: `src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js` +- Test: `tests/unit/agentBridgeCommands.spec.js` + +**Interfaces:** +- Consumes: browser internals via injected deps (see Key codebase facts) — never imported by `commands.js` itself (dependency injection keeps it unit-testable). +- Produces: `executeCommand(command, args, deps): Promise<{ok: boolean, result?: any, error?: string}>` and `getViewState(deps)` from `commands.js`; `AgentBridge` default export with `init(vars)` from `AgentBridge.js`. The WS envelope produced here must match `mcp/src/bridge.ts` (Task 8) — see Global Constraints. + +- [ ] **Step 1: Un-ignore the component directory** + +In `.gitignore`, directly after line 33 (`/src/essence/*Plugin-Components*`), add: +``` +!/src/essence/MMGIS-Plugin-Components/ +``` + +Verify: `git check-ignore -v src/essence/MMGIS-Plugin-Components/AgentBridge/config.json || echo NOT_IGNORED` prints `NOT_IGNORED` (create the dir first if needed). + +- [ ] **Step 2: Write the failing test** + +`tests/unit/agentBridgeCommands.spec.js`: +```js +import { describe, it, expect, vi } from 'vitest' +import { + executeCommand, + getViewState, +} from '../../src/essence/MMGIS-Plugin-Components/AgentBridge/commands' + +function makeDeps() { + return { + Map_: { + resetView: vi.fn(), + map: { getCenter: () => ({ lat: 1, lng: 2 }), getZoom: () => 5 }, + }, + L_: { + mission: 'Demo', + asLayerUUID: (v) => (v === 'NO2' || v === 'uuid-1' ? 'uuid-1' : null), + layers: { data: { 'uuid-1': { name: 'NO2' } }, on: { 'uuid-1': false } }, + toggleLayer: vi.fn(async function (l) { + this.layers.on['uuid-1'] = !this.layers.on['uuid-1'] + }), + }, + ToolController_: { makeTool: vi.fn(), activeToolName: 'LayerManager' }, + TimeControl: { + setTime: vi.fn(() => true), + getTime: () => '2026-06-01T00:00:00Z', + }, + } +} + +describe('executeCommand', () => { + it('fly_to validates lat/lon and calls Map_.resetView', async () => { + const deps = makeDeps() + const res = await executeCommand('fly_to', { lat: 33.7, lon: -84.4, zoom: 9 }, deps) + expect(res.ok).toBe(true) + expect(deps.Map_.resetView).toHaveBeenCalledWith([33.7, -84.4, 9]) + }) + it('fly_to rejects non-numeric coordinates', async () => { + const res = await executeCommand('fly_to', { lat: 'x', lon: 0 }, makeDeps()) + expect(res.ok).toBe(false) + expect(res.error).toMatch(/lat/) + }) + it('toggle_layer resolves names to uuids and toggles', async () => { + const deps = makeDeps() + const res = await executeCommand('toggle_layer', { layer: 'NO2' }, deps) + expect(res.ok).toBe(true) + expect(res.result).toEqual({ layer: 'uuid-1', on: true }) + }) + it('toggle_layer is a no-op when already in the requested state', async () => { + const deps = makeDeps() + const res = await executeCommand('toggle_layer', { layer: 'NO2', on: false }, deps) + expect(res.ok).toBe(true) + expect(deps.L_.toggleLayer).not.toHaveBeenCalled() + }) + it('toggle_layer errors on unknown layers', async () => { + const res = await executeCommand('toggle_layer', { layer: 'Nope' }, makeDeps()) + expect(res.ok).toBe(false) + expect(res.error).toMatch(/Unknown layer/) + }) + it('open_tool calls ToolController_.makeTool', async () => { + const deps = makeDeps() + const res = await executeCommand('open_tool', { name: 'Chart' }, deps) + expect(res.ok).toBe(true) + expect(deps.ToolController_.makeTool).toHaveBeenCalledWith('Chart') + }) + it('set_time requires startTime and endTime', async () => { + const res = await executeCommand('set_time', { startTime: '2026-01-01T00:00:00Z' }, makeDeps()) + expect(res.ok).toBe(false) + }) + it('get_view_state reports mission, center, zoom, layers, tool', async () => { + const res = await executeCommand('get_view_state', {}, makeDeps()) + expect(res.ok).toBe(true) + expect(res.result.mission).toBe('Demo') + expect(res.result.center).toEqual({ lat: 1, lng: 2 }) + expect(res.result.zoom).toBe(5) + expect(res.result.activeTool).toBe('LayerManager') + }) + it('rejects unknown commands', async () => { + const res = await executeCommand('rm_rf', {}, makeDeps()) + expect(res.ok).toBe(false) + expect(res.error).toMatch(/Unknown command/) + }) +}) + +describe('getViewState', () => { + it('tolerates a missing map object', () => { + const deps = makeDeps() + deps.Map_.map = null + const state = getViewState(deps) + expect(state.center).toBe(null) + expect(state.zoom).toBe(null) + }) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run tests/unit/agentBridgeCommands.spec.js` (from repo root) +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement `commands.js`** + +`src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js`: +```js +// Whitelisted, view-only commands the agent bridge can execute. +// All MMGIS internals arrive via `deps` so this module stays unit-testable. + +function isFiniteNumber(v) { + return typeof v === 'number' && isFinite(v) +} + +export function getViewState(deps) { + const { Map_, L_, ToolController_, TimeControl } = deps + return { + mission: L_.mission || null, + center: Map_.map && Map_.map.getCenter ? Map_.map.getCenter() : null, + zoom: Map_.map && Map_.map.getZoom ? Map_.map.getZoom() : null, + layersOn: L_.layers ? L_.layers.on : {}, + activeTool: ToolController_ ? ToolController_.activeToolName : null, + currentTime: TimeControl && TimeControl.getTime ? TimeControl.getTime() : null, + } +} + +export async function executeCommand(command, args, deps) { + const { Map_, L_, ToolController_, TimeControl } = deps + const a = args || {} + switch (command) { + case 'fly_to': { + if (!isFiniteNumber(a.lat) || !isFiniteNumber(a.lon)) + return { ok: false, error: 'fly_to requires numeric lat and lon' } + Map_.resetView([a.lat, a.lon, isFiniteNumber(a.zoom) ? a.zoom : undefined]) + return { ok: true, result: getViewState(deps) } + } + case 'toggle_layer': { + if (typeof a.layer !== 'string') + return { ok: false, error: 'toggle_layer requires a layer name or uuid' } + const uuid = L_.asLayerUUID(a.layer) + if (uuid == null || L_.layers.data[uuid] == null) + return { ok: false, error: `Unknown layer: ${a.layer}` } + const current = L_.layers.on[uuid] + if (typeof a.on === 'boolean' && current === a.on) + return { ok: true, result: { layer: uuid, on: current } } + await L_.toggleLayer(L_.layers.data[uuid]) + return { ok: true, result: { layer: uuid, on: L_.layers.on[uuid] } } + } + case 'open_tool': { + if (typeof a.name !== 'string') + return { ok: false, error: 'open_tool requires a tool name' } + ToolController_.makeTool(a.name) + return { ok: true, result: { activeTool: ToolController_.activeToolName } } + } + case 'set_time': { + if (!a.startTime || !a.endTime) + return { ok: false, error: 'set_time requires startTime and endTime (ISO strings)' } + const ok = TimeControl.setTime(a.startTime, a.endTime, false, '00:00:00', a.currentTime) + if (ok === false) + return { ok: false, error: 'Time is not enabled for this mission' } + return { ok: true, result: { currentTime: TimeControl.getTime() } } + } + case 'get_view_state': + return { ok: true, result: getViewState(deps) } + default: + return { ok: false, error: `Unknown command: ${command}` } + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run tests/unit/agentBridgeCommands.spec.js` +Expected: PASS (10 tests). + +- [ ] **Step 6: Implement `AgentBridge.js` and `config.json`** + +`src/essence/MMGIS-Plugin-Components/AgentBridge/config.json`: +```json +{ + "AgentBridge": { + "name": "AgentBridge", + "description": "Lets the MMGIS MCP server drive this browser session (fly, toggle layers, open tools, set time) over the MMGIS websocket.", + "defaultIcon": "robot", + "hasVars": false, + "config": { "rows": [] }, + "paths": { + "AgentBridge": "essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge" + } + } +} +``` + +`src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js`: +```js +import Map_ from '../../Basics/Map_/Map_' +import L_ from '../../Basics/Layers_/Layers_' +import ToolController_ from '../../Basics/ToolController_/ToolController_' +import TimeControl from '../../Basics/TimeControl_/TimeControl' +import { executeCommand } from './commands' + +// Envelope contract shared with mcp/src/bridge.ts — keep in sync. +const FRAME_TYPE = 'agent-bridge' +const RECONNECT_MS = 10000 + +const AgentBridge = { + ws: null, + sessionId: null, + + init: function (vars) { + this.sessionId = + window.crypto && window.crypto.randomUUID + ? window.crypto.randomUUID() + : String(Math.random()).slice(2) + this.connect() + }, + + getWsPath: function () { + const g = window.mmgisglobal || {} + if (g.ENABLE_MMGIS_WEBSOCKETS !== 'true') return null + const protocol = + window.location.protocol.indexOf('https') !== -1 ? 'wss' : 'ws' + const rootPath = g.WEBSOCKET_ROOT_PATH || g.ROOT_PATH || '' + const host = + g.NODE_ENV === 'development' + ? `localhost:${parseInt(g.PORT || '8888', 10)}` + : window.location.host + return `${protocol}://${host}${rootPath}/` + }, + + connect: function () { + const path = this.getWsPath() + if (path == null) { + console.warn( + '[AgentBridge] Websockets disabled (ENABLE_MMGIS_WEBSOCKETS != true); agent bridge inactive.' + ) + return + } + try { + this.ws = new WebSocket(path) + } catch (err) { + console.warn('[AgentBridge] Failed to open websocket:', err) + setTimeout(() => this.connect(), RECONNECT_MS) + return + } + this.ws.onopen = () => { + this.send({ kind: 'presence', sessionId: this.sessionId }) + } + this.ws.onmessage = (event) => this.onMessage(event) + this.ws.onclose = () => { + setTimeout(() => this.connect(), RECONNECT_MS) + } + }, + + send: function (agent) { + if (!this.ws || this.ws.readyState !== 1) return + this.ws.send( + JSON.stringify({ + type: FRAME_TYPE, + body: { mission: L_.mission }, + info: { type: 'agentBridge' }, + agent, + }) + ) + }, + + onMessage: async function (event) { + let parsed + try { + parsed = JSON.parse(event.data) + } catch (err) { + return + } + if (parsed == null || parsed.type !== FRAME_TYPE) return + if (parsed.agent == null || parsed.agent.kind !== 'command') return + if (parsed.body == null || parsed.body.mission !== L_.mission) return + + const { id, command, args } = parsed.agent + let outcome + try { + outcome = await executeCommand(command, args, { + Map_, + L_, + ToolController_, + TimeControl, + }) + } catch (err) { + outcome = { ok: false, error: `Command threw: ${err.message}` } + } + this.send({ + kind: 'ack', + id, + sessionId: this.sessionId, + ok: outcome.ok, + result: outcome.result, + error: outcome.error, + }) + }, +} + +export default AgentBridge +``` + +- [ ] **Step 7: Regenerate the component registry and verify discovery** + +Run: `node -e "require('./API/updateTools').updateComponents()"` +Expected: `src/pre/components.js` now imports AgentBridge and exports it in `componentModules`; `configure/public/componentConfigs.json` includes AgentBridge. Verify with: `grep AgentBridge src/pre/components.js`. + +- [ ] **Step 8: Run the full unit suite to check for regressions** + +Run: `npx vitest run` +Expected: all tests pass (pre-existing suite + new commands tests). If root vitest picks up `mcp/tests/*.spec.ts` and fails on them, add `mcp/**` to the root vitest config's `exclude` (check `vitest.config.*` at repo root) — mcp tests run via `cd mcp && npm test`. + +- [ ] **Step 9: Commit** + +```bash +git add .gitignore src/essence/MMGIS-Plugin-Components tests/unit/agentBridgeCommands.spec.js +git commit -m "Add AgentBridge plugin component for browser-side agent control" +``` + +**Known risk (verify during Task 9 manual E2E):** in modern mode the active tool controller may be `ToolControllerModern_` rather than `ToolController_` — if `open_tool` doesn't activate tools in the live app, wire the modern controller into the deps object in `AgentBridge.js` (commands.js needs no change). + +--- + +### Task 8: Bridge client + view MCP tools + +**Files:** +- Create: `mcp/src/bridge.ts` +- Create: `mcp/src/tools/view.ts` +- Modify: `mcp/src/index.ts` (register view tools) +- Test: `mcp/tests/bridge.spec.ts` + +**Interfaces:** +- Consumes: `wsUrl` from `McpConfig` (Task 1); `ToolDef`/result helpers (Task 3); envelope contract (Global Constraints, matching Task 7's `AgentBridge.js`). +- Produces: `class BridgeClient { constructor(wsUrl: string, timeoutMs?: number); sendCommand(mission: string, command: string, args: object): Promise; close(): void }`; `makeViewTools(bridge: BridgeClient): ToolDef[]` exposing `view_fly_to`, `view_toggle_layer`, `view_open_tool`, `view_set_time`, `view_get_state`. + +- [ ] **Step 1: Write the failing test** + +`mcp/tests/bridge.spec.ts`: +```ts +import { describe, it, expect, afterEach } from 'vitest' +import { WebSocketServer, WebSocket } from 'ws' +import { BridgeClient } from '../src/bridge.js' + +// Mimics MMGIS API/websocket.js: relay every frame to ALL clients (sender included) +function startRelay(): Promise<{ wss: WebSocketServer; url: string }> { + return new Promise((resolve) => { + const wss = new WebSocketServer({ port: 0 }, () => { + const { port } = wss.address() as { port: number } + resolve({ wss, url: `ws://127.0.0.1:${port}/` }) + }) + wss.on('connection', (ws) => { + ws.on('message', (m) => { + for (const c of wss.clients) if (c.readyState === WebSocket.OPEN) c.send(m.toString()) + }) + }) + }) +} + +// Fake AgentBridge browser session +function fakeBrowser(url: string, mission: string, respond: (agent: any) => any): WebSocket { + const ws = new WebSocket(url) + ws.on('message', (m) => { + const parsed = JSON.parse(m.toString()) + if (parsed.type !== 'agent-bridge' || parsed.agent?.kind !== 'command') return + if (parsed.body?.mission !== mission) return + ws.send( + JSON.stringify({ + type: 'agent-bridge', + body: { mission }, + info: { type: 'agentBridge' }, + agent: { kind: 'ack', id: parsed.agent.id, sessionId: 's1', ...respond(parsed.agent) }, + }) + ) + }) + return ws +} + +describe('BridgeClient', () => { + let wss: WebSocketServer, browser: WebSocket | null = null, bridge: BridgeClient + + afterEach(() => { + bridge?.close() + browser?.close() + wss?.close() + }) + + it('sends a command frame and resolves with the ack result', async () => { + const relay = await startRelay() + wss = relay.wss + browser = fakeBrowser(relay.url, 'Demo', (agent) => ({ ok: true, result: { echoed: agent.command } })) + await new Promise((r) => browser!.on('open', r)) + bridge = new BridgeClient(relay.url, 2000) + const result = await bridge.sendCommand('Demo', 'fly_to', { lat: 1, lon: 2 }) + expect(result).toEqual({ echoed: 'fly_to' }) + }) + + it('rejects with the browser-reported error on failed acks', async () => { + const relay = await startRelay() + wss = relay.wss + browser = fakeBrowser(relay.url, 'Demo', () => ({ ok: false, error: 'Unknown layer: X' })) + await new Promise((r) => browser!.on('open', r)) + bridge = new BridgeClient(relay.url, 2000) + await expect(bridge.sendCommand('Demo', 'toggle_layer', { layer: 'X' })).rejects.toThrow('Unknown layer: X') + }) + + it('times out with a helpful hint when no session responds', async () => { + const relay = await startRelay() + wss = relay.wss + bridge = new BridgeClient(relay.url, 300) + const err = await bridge.sendCommand('Demo', 'fly_to', {}).catch((e) => e) + expect(err.message).toMatch(/No browser session/) + expect(err.hint).toMatch(/AgentBridge/) + }) + + it('ignores acks for other missions (browser filters by mission)', async () => { + const relay = await startRelay() + wss = relay.wss + browser = fakeBrowser(relay.url, 'OtherMission', () => ({ ok: true, result: {} })) + await new Promise((r) => browser!.on('open', r)) + bridge = new BridgeClient(relay.url, 300) + await expect(bridge.sendCommand('Demo', 'fly_to', {})).rejects.toThrow(/No browser session/) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd mcp && npx vitest run tests/bridge.spec.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `mcp/src/bridge.ts`** + +```ts +import WebSocket from 'ws' +import { randomUUID } from 'node:crypto' +import { MMGISError } from './mmgisClient.js' + +export class BridgeClient { + private ws: WebSocket | null = null + + constructor(private wsUrl: string, private timeoutMs = 5000) {} + + private connect(): Promise { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + return Promise.resolve(this.ws) + } + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.wsUrl) + ws.once('open', () => { + this.ws = ws + resolve(ws) + }) + ws.once('error', (err) => { + reject( + new MMGISError( + `Could not connect to the MMGIS websocket at ${this.wsUrl}: ${err.message}`, + 'Set ENABLE_MMGIS_WEBSOCKETS=true in the MMGIS .env and check MMGIS_WS_URL.' + ) + ) + }) + }) + } + + async sendCommand(mission: string, command: string, args: object): Promise { + const ws = await this.connect() + const id = randomUUID() + const frame = JSON.stringify({ + type: 'agent-bridge', + body: { mission }, + info: { type: 'agentBridge' }, + agent: { kind: 'command', id, command, args }, + }) + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup() + reject( + new MMGISError( + `No browser session responded for mission "${mission}" within ${this.timeoutMs}ms`, + 'Open the mission in a browser — the AgentBridge component must be enabled in its config (dashboard_generate does this automatically).' + ) + ) + }, this.timeoutMs) + const onMessage = (data: WebSocket.RawData) => { + try { + const parsed = JSON.parse(data.toString()) + if (parsed?.type === 'agent-bridge' && parsed.agent?.kind === 'ack' && parsed.agent.id === id) { + cleanup() + if (parsed.agent.ok) resolve(parsed.agent.result) + else reject(new MMGISError(parsed.agent.error || 'Command failed in the browser')) + } + } catch { + // non-JSON or unrelated frame — ignore + } + } + const cleanup = () => { + clearTimeout(timer) + ws.off('message', onMessage) + } + ws.on('message', onMessage) + ws.send(frame) + }) + } + + close() { + this.ws?.close() + this.ws = null + } +} +``` + +- [ ] **Step 4: Implement `mcp/src/tools/view.ts`** + +```ts +import { z } from 'zod' +import type { BridgeClient } from '../bridge.js' +import { type ToolDef, toToolResult, toErrorResult } from './result.js' + +export function makeViewTools(bridge: BridgeClient): ToolDef[] { + const run = async (mission: string, command: string, args: object) => { + try { + return toToolResult({ result: await bridge.sendCommand(mission, command, args) }) + } catch (err) { + return toErrorResult(err) + } + } + const mission = z.string().describe('Mission name of the browser session to drive') + return [ + { + name: 'view_fly_to', + description: "Fly a connected browser session's map to a lat/lon (and optional zoom).", + schema: { mission, lat: z.number(), lon: z.number(), zoom: z.number().optional() }, + handler: ({ mission, ...args }: any) => run(mission, 'fly_to', args), + }, + { + name: 'view_toggle_layer', + description: 'Toggle (or set) a layer\'s visibility in a connected browser session.', + schema: { + mission, + layer: z.string().describe('Layer name or uuid'), + on: z.boolean().optional().describe('Target state; omit to flip'), + }, + handler: ({ mission, ...args }: any) => run(mission, 'toggle_layer', args), + }, + { + name: 'view_open_tool', + description: 'Open a tool panel (e.g. Chart, Measure) in a connected browser session.', + schema: { mission, name: z.string().describe('Tool name') }, + handler: ({ mission, ...args }: any) => run(mission, 'open_tool', args), + }, + { + name: 'view_set_time', + description: 'Set the global time range in a connected browser session (time must be enabled).', + schema: { + mission, + startTime: z.string().describe('ISO datetime'), + endTime: z.string().describe('ISO datetime'), + currentTime: z.string().optional(), + }, + handler: ({ mission, ...args }: any) => run(mission, 'set_time', args), + }, + { + name: 'view_get_state', + description: 'Get the current view state (center, zoom, layers on, active tool, time) of a connected browser session.', + schema: { mission }, + handler: ({ mission }: any) => run(mission, 'get_view_state', {}), + }, + ] +} +``` + +- [ ] **Step 5: Register in `mcp/src/index.ts`** + +Final `index.ts` main body: +```ts +import { BridgeClient } from './bridge.js' +import { makeViewTools } from './tools/view.js' +``` +and: +```ts + const bridge = new BridgeClient(cfg.wsUrl) + const server = buildServer({ + tools: [ + ...makeAdminTools(client), + ...makeDashboardTools(client, cfg), + ...makeCatalogTools(cfg), + ...makeViewTools(bridge), + ], + }) +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd mcp && npx vitest run tests/bridge.spec.ts && npm test && npm run build` +Expected: bridge tests PASS; full mcp suite PASS; build exit 0. + +- [ ] **Step 7: Commit** + +```bash +git add mcp/src/bridge.ts mcp/src/tools/view.ts mcp/src/index.ts mcp/tests/bridge.spec.ts +git commit -m "Add websocket bridge client and browser view control tools" +``` + +--- + +### Task 9: Wiring, docs, and the demo runbook + +**Files:** +- Create: `.mcp.json` (repo root) +- Create: `mcp/README.md` +- Modify: `AGENTS.md` (one line in Project Structure) + +**Interfaces:** +- Consumes: everything prior; no new code. +- Produces: a registered project MCP server + a human-executable demo checklist. + +- [ ] **Step 1: Register the MCP server for this project** + +`.mcp.json` (repo root; if the file already exists, merge the `mmgis` entry into `mcpServers`): +```json +{ + "mcpServers": { + "mmgis": { + "command": "node", + "args": ["mcp/dist/index.js"], + "env": { + "MMGIS_URL": "http://localhost:8888", + "MMGIS_TOKEN": "${MMGIS_TOKEN}" + } + } + } +} +``` + +- [ ] **Step 2: Write `mcp/README.md`** + +```markdown +# MMGIS MCP Server + +Lets AI agents (any MCP client — Claude Code, Claude Desktop, ...) drive MMGIS: +administer missions, generate dashboards from natural language, search STAC +catalogs for data layers, and control a live browser session. + +## Setup + +1. `cd mcp && npm install && npm run build` +2. In the MMGIS `.env`, set `ENABLE_MMGIS_WEBSOCKETS=true` (needed for + browser control) and start MMGIS (`npm start`). +3. Mint a long-term API token (must be done with an admin **session** — tokens + cannot mint tokens). Log into MMGIS as an admin in a browser, then run in + the devtools console: + + ```js + fetch('/api/longtermtoken/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'mcp', period: 'never' }), + }).then((r) => r.json()).then(console.log) + ``` + + Copy `body.token`. The token inherits your permission (create missions + requires a SuperAdmin's token). +4. `export MMGIS_TOKEN=` — the repo `.mcp.json` picks it up, or + register manually: `claude mcp add mmgis -- node mcp/dist/index.js`. + +## Environment variables + +| Variable | Default | Purpose | +| --- | --- | --- | +| `MMGIS_URL` | `http://localhost:8888` | MMGIS base URL (include ROOT_PATH if set) | +| `MMGIS_TOKEN` | (required) | Long-term token, sent as `Authorization: Bearer ...` | +| `MMGIS_WS_URL` | derived from `MMGIS_URL` | Websocket endpoint (`ws://host:port/`) | +| `MMGIS_REPO_ROOT` | auto (this checkout) | MMGIS repo containing `scripts/generate-mission-config.js` | +| `MAPBOX_TOKEN` | empty | Substituted into generated configs' basemap | +| `STAC_CATALOGS` | veda + earth-search | JSON object `{name: stacApiUrl}` | +| `TITILER_URL` | `https://titiler.xyz` | TiTiler used for `catalog_item_to_layer` tile URLs | + +## Tools + +- `mission_list`, `mission_get` — admin plane +- `dashboard_profile_schema`, `dashboard_tool_options`, `dashboard_generate` — NL → dashboard +- `catalog_collections`, `catalog_search`, `catalog_item_to_layer` — STAC data discovery +- `view_fly_to`, `view_toggle_layer`, `view_open_tool`, `view_set_time`, `view_get_state` — live browser control (requires an open browser session on the mission; `dashboard_generate` enables the AgentBridge component automatically) + +## Demo (end-to-end) + +Ask your MCP client: + +> Set up an MMGIS dashboard called "Air Quality Atlanta" showing NO2 data +> over the southeastern US, then fly the view to Atlanta. + +Expected flow: `catalog_collections`(keyword no2) → `catalog_search` → +`catalog_item_to_layer` → `dashboard_generate` → open the returned URL in a +browser → `view_fly_to`. + +## Manual E2E checklist + +- [ ] `mission_list` returns the deployment's missions +- [ ] `dashboard_generate` creates a mission that loads in the browser +- [ ] With the mission open in a browser: `view_get_state` returns the mission name +- [ ] `view_fly_to` visibly moves the map +- [ ] `view_toggle_layer` flips a layer on/off (check LayerManager) +- [ ] `view_open_tool` opens a tool panel (if not: wire `ToolControllerModern_` — see Task 7 note) +- [ ] `view_*` with no browser open returns the "No browser session" hint + +## Security notes + +- Bridge commands are view-only and whitelist-validated in the browser + (`src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js`). +- The MMGIS websocket relay is unauthenticated upstream; do not expose it + publicly on deployments where that matters (Phase 2 hardening candidate). +``` + +- [ ] **Step 3: Add `mcp/` to AGENTS.md project structure** + +In the `Project Structure` tree in `AGENTS.md`, after the `configure/` line, add: +``` +├── mcp/ # MCP server: agents drive MMGIS + generate dashboards +``` + +- [ ] **Step 4: Full verification sweep** + +Run: `cd mcp && npm test && npm run build && cd .. && npx vitest run` +Expected: all suites pass. + +- [ ] **Step 5: Execute the manual E2E checklist against a live deployment** + +Use the `mmgis-deployment` skill to boot a dev instance (with `ENABLE_MMGIS_WEBSOCKETS=true`), mint a token, register the MCP server, and walk the checklist in `mcp/README.md`. Record any deviations (especially the modern-mode `open_tool` risk from Task 7) and fix before closing the task. + +- [ ] **Step 6: Commit** + +```bash +git add .mcp.json mcp/README.md AGENTS.md +git commit -m "Register MMGIS MCP server and add setup/demo documentation" +``` + +--- + +## Spec coverage map + +| Spec (Phase 1) requirement | Task | +| --- | --- | +| MCP server package, stdio transport, MMGIS_URL/MMGIS_TOKEN config | 1, 3 | +| Admin plane tools over existing REST | 2, 3 — trimmed to `mission_list`/`mission_get`; layers are managed via `dashboard_generate`, and geodataset CRUD (not needed for the milestone demo) moves to Phase 2 | +| `dashboard_generate` + `get_profile_schema` (named `dashboard_profile_schema`) via config generator | 4, 5 | +| Catalog search (STAC) returning profile-ready layers | 6 | +| AgentBridge Plugin-Component, whitelisted commands, per-mission scoping | 7 | +| ~5 browser commands (`fly_to`, `toggle_layer`, `open_tool`, `set_time`, `get_view_state`) | 7, 8 | +| Structured errors `{error, hint}`, atomic generation, 5s browser timeout, catalog degradation | 2, 4, 6, 8 | +| Testing: unit (mocked REST/WS), integration (real generator), manual E2E runbook | all, 9 | +| Deviation: WS auth/rate-limit deferred (upstream relay has none) | Global Constraints | + +Out of Phase 1 scope (per spec): streamable HTTP transport, CMR search (STAC covers the demo; CMR is additive later), data upload/ingestion tools, screenshots, plugin scaffolding (Phase 3). diff --git a/docs/superpowers/plans/2026-07-24-chat-ui.md b/docs/superpowers/plans/2026-07-24-chat-ui.md new file mode 100644 index 000000000..e9d2a0422 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-chat-ui.md @@ -0,0 +1,1414 @@ +# MMGIS Chat UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A standalone `chat/` web app where the stakeholder's OpenAI key drives MMGIS dashboard creation and live view control through the existing MCP server, with every tool call visible in the transcript and a JSON-config round-trip (see generated config; create dashboards from raw JSON). + +**Architecture:** `chat/` is a self-contained Express (ESM) app. The backend holds the OpenAI key (`chat/.env`), runs a function-calling agent loop, and executes tools as an **MCP stdio client** of `mcp/dist/index.js` (tools auto-discovered — zero duplicated logic). The frontend is a no-build vanilla HTML/CSS/JS page consuming an SSE stream. One small MCP-server addition provides the JSON round-trip. + +**Tech Stack:** Node 20 ESM, Express ^4, openai ^5, @modelcontextprotocol/sdk ^1.29, dotenv ^16, Vitest ^3. Frontend: vanilla JS (ES modules), no build step. + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-07-24-chat-ui-design.md`. Branch: `feature/agentic-mmgis`. +- `chat/` is self-contained: own `package.json`, own `.env` (gitignored; `.env.example` committed). The OpenAI key is server-side only — never sent to the browser. +- All chat-server code is plain JavaScript ESM (`"type": "module"`), NOT TypeScript. The one MCP-server task (Task 1) is TypeScript (NodeNext, `.js` internal imports) matching `mcp/`. +- SSE event protocol (backend emits, frontend parses — MUST match): `{type:'text', delta}` · `{type:'tool_call', id, name, args}` · `{type:'tool_result', id, name, result, isError}` · `{type:'done'}` · `{type:'error', message}`. One `data: \n\n` frame per event. +- Agent loop guard: max 15 tool iterations, then one final no-tools summarize turn. +- Tests: Vitest (`cd chat && npm test`; Task 1 in `cd mcp && npm test`). +- Commits: imperative mood, NO Co-Authored-By trailer. +- Defaults: `CHAT_PORT=8895`, `OPENAI_MODEL=gpt-4o`. + +## Verified codebase facts + +- `mcp/src/tools/dashboard.ts` (159 lines): `validateMissionName(missionName): {message, hint} | null` at line 47; `dashboard_generate` handler at lines 115-157 contains the install flow to extract (validate name → build/generate → track `neededMapboxToken` → `resolvePlaceholders` → inject `AGENT_BRIDGE_COMPONENT` → `addMission` with add→upsert fallback → warnings + `{mission, version, url}` result). +- MCP client API (SDK 1.29): `client.listTools()` → `{tools: [{name, description, inputSchema}]}` where `inputSchema` is JSON Schema (directly usable as OpenAI function `parameters`); `client.callTool({name, arguments})` → `{content: [{type:'text', text}], isError?}`. +- OpenAI streaming: `chat.completions.create({stream: true, tools})` yields chunks whose `choices[0].delta` carries `content` and/or `tool_calls` deltas (accumulate by `index`; `id`/`function.name` arrive once, `function.arguments` arrives in fragments). +- Local MMGIS test deployment: `http://localhost:8891`, token `` (see `mcp/README.md` for minting fresh ones). + +--- + +### Task 1: MCP server — JSON config round-trip + +**Files:** +- Modify: `mcp/src/tools/dashboard.ts` +- Modify: `mcp/README.md` (tool list: add `dashboard_create_from_config`; mention `returnConfig`) +- Test: `mcp/tests/dashboard.spec.ts` (extend) + +**Interfaces:** +- Consumes: existing `validateMissionName`, `resolvePlaceholders`, `AGENT_BRIDGE_COMPONENT`, `MmgisClient`, `toToolResult`/`toErrorResult` — all already in/imported by `dashboard.ts`. +- Produces: `dashboard_generate` accepts `returnConfig?: boolean` (result gains `config` when true); new tool `dashboard_create_from_config` with schema `{missionName: string, config: object, updateExisting?: boolean}` returning `{mission, version, url, warnings?}`; internal helper `installMission(client, missionName, config, updateExisting): Promise<{mission, version}>` shared by both tools. + +- [ ] **Step 1: Write the failing tests** — append to `mcp/tests/dashboard.spec.ts` inside the existing `describe('dashboard tools', ...)` (reuse the existing `cfg`, `parse` helpers already defined in that file): + +```ts + it('dashboard_generate includes the full config when returnConfig is true', async () => { + const client = { addMission: async (m: string) => ({ mission: m, version: 0 }) } as any + const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) + const out = parse(await tools.dashboard_generate.handler({ missionName: 'RC Test', returnConfig: true })) + expect(out.config.msv.mission).toBe('RC Test') + const without = parse(await tools.dashboard_generate.handler({ missionName: 'RC Test' })) + expect(without.config).toBeUndefined() + }, 30000) + + it('dashboard_create_from_config installs raw config with placeholder resolution and component injection', async () => { + const calls: any[] = [] + const client = { + addMission: async (mission: string, config: any) => { + calls.push({ mission, config }) + return { mission, version: 0 } + }, + } as any + const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) + const rawConfig = { + msv: { mission: 'From JSON', basemap: { accessToken: '{{MAPBOX_TOKEN}}' } }, + layers: [], + } + const out = parse( + await tools.dashboard_create_from_config.handler({ missionName: 'From JSON', config: rawConfig }) + ) + expect(out.url).toBe('http://mm:8888/?mission=From%20JSON') + expect(calls[0].config.components).toEqual([ + { name: 'AgentBridge', js: 'AgentBridge', on: true, variables: {} }, + ]) + expect(calls[0].config.msv.basemap.accessToken).toBe('pk.test') + }) + + it('dashboard_create_from_config keeps caller-provided components untouched', async () => { + const calls: any[] = [] + const client = { + addMission: async (mission: string, config: any) => { + calls.push({ mission, config }) + return { mission, version: 0 } + }, + } as any + const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) + await tools.dashboard_create_from_config.handler({ + missionName: 'From JSON', + config: { components: [{ name: 'X', js: 'X', on: false, variables: {} }] }, + }) + expect(calls[0].config.components).toEqual([{ name: 'X', js: 'X', on: false, variables: {} }]) + }) + + it('dashboard_create_from_config rejects bad mission names before any client call', async () => { + const client = { addMission: async () => { throw new Error('should not be called') } } as any + const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) + const res = await tools.dashboard_create_from_config.handler({ missionName: 'bad-name!', config: {} }) + expect(res.isError).toBe(true) + }) +``` + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `cd mcp && npx vitest run tests/dashboard.spec.ts` +Expected: FAIL — `tools.dashboard_create_from_config` undefined; `out.config` undefined. + +- [ ] **Step 3: Implement in `mcp/src/tools/dashboard.ts`** + +(a) Extract the install flow (currently inline at lines ~130-142) into a module-level helper ABOVE `makeDashboardTools`: + +```ts +async function installMission( + client: MmgisClient, + missionName: string, + config: any, + updateExisting: boolean | undefined +): Promise<{ mission: string; version: number }> { + try { + return await client.addMission(missionName, config) + } catch (err: any) { + if (/already exists/i.test(err?.message || '')) { + if (updateExisting) return await client.upsertMission(missionName, config) + err.hint = 'Pass updateExisting: true to replace the existing mission config.' + } + throw err + } +} +``` + +(b) In `dashboardGenerateSchema` add: + +```ts + returnConfig: z + .boolean() + .optional() + .describe('Include the full generated mission config JSON in the result'), +``` + +(c) In the `dashboard_generate` handler: replace the inline try/catch install block with `const out = await installMission(client, args.missionName, config, args.updateExisting)`, and extend the result: + +```ts + return toToolResult({ + mission: out.mission, + version: out.version, + url: `${cfg.mmgisUrl}/?mission=${encodeURIComponent(args.missionName)}`, + ...(warnings.length > 0 ? { warnings } : {}), + ...(args.returnConfig ? { config } : {}), + }) +``` + +(d) Append the new tool to the returned array (after `dashboard_generate`): + +```ts + { + name: 'dashboard_create_from_config', + description: + 'Install an MMGIS mission (dashboard) from a complete raw mission config JSON — use when the user provides or edits config JSON directly. Returns the mission URL.', + schema: { + missionName: z.string().describe(`Name for the mission. ${MISSION_NAME_RULE}`), + config: z.record(z.any()).describe('Complete MMGIS mission config object (e.g. from dashboard_generate with returnConfig)'), + updateExisting: z.boolean().optional().describe('If the mission exists, replace its config (new version)'), + }, + handler: async (args: { missionName: string; config: any; updateExisting?: boolean }) => { + try { + const nameError = validateMissionName(args.missionName) + if (nameError) return toErrorResult(nameError) + const neededMapboxToken = JSON.stringify(args.config).includes('{{MAPBOX_TOKEN}}') + const config = resolvePlaceholders(args.config, cfg.mapboxToken) + if (!Array.isArray(config.components)) { + config.components = [AGENT_BRIDGE_COMPONENT] + } + const out = await installMission(client, args.missionName, config, args.updateExisting) + const warnings: string[] = [] + if (neededMapboxToken && cfg.mapboxToken === '') { + warnings.push('MAPBOX_TOKEN is not set — the basemap will not render') + } + return toToolResult({ + mission: out.mission, + version: out.version, + url: `${cfg.mmgisUrl}/?mission=${encodeURIComponent(args.missionName)}`, + ...(warnings.length > 0 ? { warnings } : {}), + }) + } catch (err) { + return toErrorResult(err) + } + }, + }, +``` + +Note: `MISSION_NAME_RULE` is the existing constant at line ~43 — reference it, don't redefine. If the existing `dashboard_generate` name-rule text lives elsewhere, match how that schema references it. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd mcp && npx vitest run tests/dashboard.spec.ts && npm run build` +Expected: all pass (existing + 4 new); build exit 0. + +- [ ] **Step 5: Update `mcp/README.md`** — in the Tools section, change the dashboard line to: + +```markdown +- `dashboard_profile_schema`, `dashboard_tool_options`, `dashboard_generate` (supports `returnConfig` to get the full config JSON back), `dashboard_create_from_config` (install a dashboard from raw config JSON) — NL → dashboard +``` + +- [ ] **Step 6: Commit** + +```bash +git add mcp/src/tools/dashboard.ts mcp/tests/dashboard.spec.ts mcp/README.md +git commit -m "Add dashboard JSON config round-trip to MCP server" +``` + +--- + +### Task 2: `chat/` scaffold + config + +**Files:** +- Create: `chat/package.json` +- Create: `chat/.gitignore` +- Create: `chat/.env.example` +- Create: `chat/lib/config.js` +- Test: `chat/tests/config.spec.js` + +**Interfaces:** +- Produces: `loadConfig(env = process.env): {apiKey, model, port, mcpCommand, mcpArgs, mcpCwd, mcpEnv}` from `chat/lib/config.js`. `mcpArgs` is a string array; `mcpCwd` is the absolute path of the `chat/` directory (so relative `MCP_ARGS` resolve against it); `mcpEnv` is the full env passthrough object. + +- [ ] **Step 1: Create the package files** + +`chat/package.json`: +```json +{ + "name": "@mmgis/chat", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js", + "test": "vitest run" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "dotenv": "^16.4.0", + "express": "^4.19.0", + "openai": "^5.0.0" + }, + "devDependencies": { + "vitest": "^3.0.0" + } +} +``` + +`chat/.gitignore`: +``` +node_modules/ +.env +``` + +`chat/.env.example`: +``` +# Your OpenAI API key (required). Never sent to the browser. +OPENAI_API_KEY=sk-... +OPENAI_MODEL=gpt-4o +CHAT_PORT=8895 + +# How to launch the MMGIS MCP server (relative paths resolve against chat/) +MCP_COMMAND=node +MCP_ARGS=../mcp/dist/index.js + +# Passed through to the MCP server process +MMGIS_URL=http://localhost:8891 +MMGIS_TOKEN= +MAPBOX_TOKEN= +``` + +Run: `cd chat && npm install` +Expected: lockfile created. (If a pinned major 404s, use the latest matching major and note it.) + +- [ ] **Step 2: Write the failing test** + +`chat/tests/config.spec.js`: +```js +import { describe, it, expect } from 'vitest' +import { loadConfig } from '../lib/config.js' + +const base = { OPENAI_API_KEY: 'sk-test' } + +describe('loadConfig', () => { + it('throws without OPENAI_API_KEY', () => { + expect(() => loadConfig({})).toThrow(/OPENAI_API_KEY/) + }) + it('applies defaults', () => { + const cfg = loadConfig({ ...base }) + expect(cfg.model).toBe('gpt-4o') + expect(cfg.port).toBe(8895) + expect(cfg.mcpCommand).toBe('node') + expect(cfg.mcpArgs).toEqual(['../mcp/dist/index.js']) + expect(cfg.mcpCwd.endsWith('/chat')).toBe(true) + }) + it('honors overrides and splits MCP_ARGS on spaces', () => { + const cfg = loadConfig({ ...base, OPENAI_MODEL: 'gpt-4o-mini', CHAT_PORT: '9000', MCP_ARGS: 'dist/index.js --flag' }) + expect(cfg.model).toBe('gpt-4o-mini') + expect(cfg.port).toBe(9000) + expect(cfg.mcpArgs).toEqual(['dist/index.js', '--flag']) + }) + it('passes the whole env through as mcpEnv', () => { + const cfg = loadConfig({ ...base, MMGIS_TOKEN: 'tok' }) + expect(cfg.mcpEnv.MMGIS_TOKEN).toBe('tok') + }) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd chat && npx vitest run tests/config.spec.js` +Expected: FAIL — cannot find `../lib/config.js`. + +- [ ] **Step 4: Implement `chat/lib/config.js`** + +```js +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +export function loadConfig(env = process.env) { + if (!env.OPENAI_API_KEY) { + throw new Error('OPENAI_API_KEY is required — copy chat/.env.example to chat/.env and set it') + } + return { + apiKey: env.OPENAI_API_KEY, + model: env.OPENAI_MODEL || 'gpt-4o', + port: parseInt(env.CHAT_PORT || '8895', 10), + mcpCommand: env.MCP_COMMAND || 'node', + mcpArgs: (env.MCP_ARGS || '../mcp/dist/index.js').split(' ').filter(Boolean), + // chat/lib -> chat/ + mcpCwd: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'), + mcpEnv: { ...env }, + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd chat && npx vitest run tests/config.spec.js` +Expected: PASS (4 tests). + +- [ ] **Step 6: Commit** + +```bash +git add chat/package.json chat/package-lock.json chat/.gitignore chat/.env.example chat/lib/config.js chat/tests/config.spec.js +git commit -m "Scaffold standalone chat app with env config" +``` + +--- + +### Task 3: MCP bridge + +**Files:** +- Create: `chat/lib/mcpBridge.js` +- Test: `chat/tests/mcpBridge.spec.js` + +**Interfaces:** +- Consumes: `loadConfig` output shape (Task 2). +- Produces: `class McpBridge { constructor(cfg, clientFactory?); async getOpenAiTools(): Promise>; async callTool(name, args): Promise<{text: string, isError: boolean}>; isConnected(): boolean; async close(): void }`. `clientFactory(cfg)` must resolve to `{client}` where client has `listTools()`/`callTool()`/`close()` (MCP SDK shape) — injectable for tests. + +- [ ] **Step 1: Write the failing test** + +`chat/tests/mcpBridge.spec.js`: +```js +import { describe, it, expect, vi } from 'vitest' +import { McpBridge } from '../lib/mcpBridge.js' + +function fakeClient(overrides = {}) { + return { + listTools: vi.fn(async () => ({ + tools: [ + { name: 'mission_list', description: 'List missions', inputSchema: { type: 'object', properties: {} } }, + ], + })), + callTool: vi.fn(async () => ({ content: [{ type: 'text', text: '{"missions":[]}' }] })), + close: vi.fn(async () => {}), + ...overrides, + } +} + +describe('McpBridge', () => { + it('converts MCP tools to OpenAI function schemas and caches them', async () => { + const client = fakeClient() + const bridge = new McpBridge({}, async () => ({ client })) + const tools = await bridge.getOpenAiTools() + expect(tools).toEqual([ + { + type: 'function', + function: { name: 'mission_list', description: 'List missions', parameters: { type: 'object', properties: {} } }, + }, + ]) + await bridge.getOpenAiTools() + expect(client.listTools).toHaveBeenCalledTimes(1) + }) + it('callTool returns text and isError', async () => { + const client = fakeClient({ + callTool: vi.fn(async ({ name }) => ({ content: [{ type: 'text', text: `ran ${name}` }], isError: false })), + }) + const bridge = new McpBridge({}, async () => ({ client })) + const out = await bridge.callTool('mission_list', {}) + expect(out).toEqual({ text: 'ran mission_list', isError: false }) + expect(client.callTool).toHaveBeenCalledWith({ name: 'mission_list', arguments: {} }) + }) + it('a throwing callTool yields an isError result and resets the connection for retry', async () => { + let calls = 0 + const dead = fakeClient({ callTool: vi.fn(async () => { throw new Error('transport closed') }) }) + const alive = fakeClient() + const bridge = new McpBridge({}, async () => ({ client: ++calls === 1 ? dead : alive })) + const out = await bridge.callTool('mission_list', {}) + expect(out.isError).toBe(true) + expect(out.text).toContain('transport closed') + expect(bridge.isConnected()).toBe(false) + const retry = await bridge.callTool('mission_list', {}) + expect(retry.isError).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd chat && npx vitest run tests/mcpBridge.spec.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `chat/lib/mcpBridge.js`** + +```js +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' + +async function defaultClientFactory(cfg) { + const transport = new StdioClientTransport({ + command: cfg.mcpCommand, + args: cfg.mcpArgs, + cwd: cfg.mcpCwd, + env: cfg.mcpEnv, + }) + const client = new Client({ name: 'mmgis-chat', version: '0.1.0' }) + await client.connect(transport) + return { client } +} + +export class McpBridge { + constructor(cfg, clientFactory = defaultClientFactory) { + this.cfg = cfg + this.clientFactory = clientFactory + this.client = null + this.tools = null + } + + async connect() { + if (this.client) return + const { client } = await this.clientFactory(this.cfg) + this.client = client + } + + isConnected() { + return this.client != null + } + + async getOpenAiTools() { + await this.connect() + if (!this.tools) { + const { tools } = await this.client.listTools() + this.tools = tools.map((t) => ({ + type: 'function', + function: { name: t.name, description: t.description || '', parameters: t.inputSchema }, + })) + } + return this.tools + } + + async callTool(name, args) { + try { + await this.connect() + const res = await this.client.callTool({ name, arguments: args }) + return { text: res.content?.[0]?.text ?? '', isError: Boolean(res.isError) } + } catch (err) { + // Drop the connection so the next call reconnects (fresh MCP process) + this.client = null + this.tools = null + return { text: JSON.stringify({ error: err.message }), isError: true } + } + } + + async close() { + await this.client?.close() + this.client = null + this.tools = null + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd chat && npx vitest run tests/mcpBridge.spec.js` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add chat/lib/mcpBridge.js chat/tests/mcpBridge.spec.js +git commit -m "Add MCP bridge exposing tools as OpenAI function schemas" +``` + +--- + +### Task 4: Agent loop + +**Files:** +- Create: `chat/lib/agentLoop.js` +- Test: `chat/tests/agentLoop.spec.js` + +**Interfaces:** +- Consumes: `McpBridge` (`getOpenAiTools`, `callTool`) from Task 3. +- Produces: `SYSTEM_PROMPT` (string) and `runAgentLoop({messages, openai, bridge, model, onEvent, maxIterations = 15}): Promise` from `chat/lib/agentLoop.js`. `openai` needs only `chat.completions.create`. `onEvent(event)` receives the SSE-protocol events (Global Constraints) — `runAgentLoop` emits everything INCLUDING the final `{type:'done'}`; it never emits `{type:'error'}` (the server maps thrown errors to that). + +- [ ] **Step 1: Write the failing test** + +`chat/tests/agentLoop.spec.js`: +```js +import { describe, it, expect, vi } from 'vitest' +import { runAgentLoop, SYSTEM_PROMPT } from '../lib/agentLoop.js' + +// Builds a fake OpenAI client whose create() returns scripted streams, in order. +// Each script is an array of {content?} | {tool?: {index, id?, name?, args?}} chunk specs. +function fakeOpenai(scripts) { + let call = 0 + const seen = [] + return { + seen, + chat: { + completions: { + create: vi.fn(async (params) => { + seen.push(params) + const script = scripts[call++] + async function* gen() { + for (const c of script) { + if (c.content !== undefined) { + yield { choices: [{ delta: { content: c.content } }] } + } else if (c.tool) { + yield { + choices: [{ + delta: { + tool_calls: [{ + index: c.tool.index, + ...(c.tool.id ? { id: c.tool.id } : {}), + function: { + ...(c.tool.name ? { name: c.tool.name } : {}), + ...(c.tool.args ? { arguments: c.tool.args } : {}), + }, + }], + }, + }], + } + } + } + } + return gen() + }), + }, + }, + } +} + +const bridge = { + getOpenAiTools: async () => [{ type: 'function', function: { name: 'mission_list', description: '', parameters: {} } }], + callTool: vi.fn(async (name) => ({ text: `{"ran":"${name}"}`, isError: false })), +} + +describe('runAgentLoop', () => { + it('streams text and finishes with done when no tools are called', async () => { + const openai = fakeOpenai([[{ content: 'Hello' }, { content: ' there' }]]) + const events = [] + await runAgentLoop({ messages: [{ role: 'user', content: 'hi' }], openai, bridge, model: 'm', onEvent: (e) => events.push(e) }) + expect(events).toEqual([ + { type: 'text', delta: 'Hello' }, + { type: 'text', delta: ' there' }, + { type: 'done' }, + ]) + expect(openai.seen[0].messages[0]).toEqual({ role: 'system', content: SYSTEM_PROMPT }) + }) + + it('accumulates fragmented tool-call deltas, executes via bridge, loops, and threads results back', async () => { + const openai = fakeOpenai([ + [ + { tool: { index: 0, id: 'call_1', name: 'mission_list' } }, + { tool: { index: 0, args: '{"a"' } }, + { tool: { index: 0, args: ':1}' } }, + ], + [{ content: 'Done!' }], + ]) + const events = [] + await runAgentLoop({ messages: [{ role: 'user', content: 'go' }], openai, bridge, model: 'm', onEvent: (e) => events.push(e) }) + expect(events).toEqual([ + { type: 'tool_call', id: 'call_1', name: 'mission_list', args: { a: 1 } }, + { type: 'tool_result', id: 'call_1', name: 'mission_list', result: '{"ran":"mission_list"}', isError: false }, + { type: 'text', delta: 'Done!' }, + { type: 'done' }, + ]) + expect(bridge.callTool).toHaveBeenCalledWith('mission_list', { a: 1 }) + const second = openai.seen[1].messages + expect(second.at(-2).tool_calls[0]).toEqual({ + id: 'call_1', type: 'function', function: { name: 'mission_list', arguments: '{"a":1}' }, + }) + expect(second.at(-1)).toEqual({ role: 'tool', tool_call_id: 'call_1', content: '{"ran":"mission_list"}' }) + }) + + it('stops after maxIterations tool rounds with a final summarize turn', async () => { + const toolRound = [ + { tool: { index: 0, id: 'call_x', name: 'mission_list', args: '{}' } }, + ] + const openai = fakeOpenai([toolRound, toolRound, [{ content: 'Summary.' }]]) + const events = [] + await runAgentLoop({ + messages: [{ role: 'user', content: 'go' }], openai, bridge, model: 'm', + onEvent: (e) => events.push(e), maxIterations: 2, + }) + expect(events.filter((e) => e.type === 'tool_call')).toHaveLength(2) + expect(events.at(-2)).toEqual({ type: 'text', delta: 'Summary.' }) + expect(events.at(-1)).toEqual({ type: 'done' }) + // The forced final call must disable tools + expect(openai.seen[2].tools).toBeUndefined() + }) + + it('passes unparseable tool arguments to the bridge as an empty object with an error result', async () => { + const openai = fakeOpenai([ + [{ tool: { index: 0, id: 'call_b', name: 'mission_list', args: '{not json' } }], + [{ content: 'ok' }], + ]) + const events = [] + await runAgentLoop({ messages: [{ role: 'user', content: 'go' }], openai, bridge, model: 'm', onEvent: (e) => events.push(e) }) + const result = events.find((e) => e.type === 'tool_result') + expect(result.isError).toBe(true) + expect(result.result).toContain('Invalid JSON arguments') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd chat && npx vitest run tests/agentLoop.spec.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `chat/lib/agentLoop.js`** + +```js +export const SYSTEM_PROMPT = `You are an assistant that builds and drives MMGIS dashboards through tools. + +Workflow guidance: +- Before generating a dashboard, call dashboard_profile_schema (input shape + layer examples) and dashboard_tool_options (valid tool names). +- Find data layers with catalog_collections / catalog_search, and convert items with catalog_item_to_layer. +- Mission names must avoid punctuation (letters, numbers, spaces, underscores are safe). +- After dashboard_generate or dashboard_create_from_config succeeds, ALWAYS give the user the mission URL. +- When the user wants to see or edit the raw config, call dashboard_generate with returnConfig: true and show the JSON. +- When the user provides config JSON, install it with dashboard_create_from_config. +- Use view_* tools to drive a browser session that has the mission open (view_get_state first if unsure). +- Tool errors include a "hint" — follow it to self-correct. If you cannot recover, tell the user the error and hint plainly. +- Be concise. Never invent tool results.` + +export async function runAgentLoop({ messages, openai, bridge, model, onEvent, maxIterations = 15 }) { + const tools = await bridge.getOpenAiTools() + const convo = [{ role: 'system', content: SYSTEM_PROMPT }, ...messages] + + for (let i = 0; i < maxIterations; i++) { + const { text, toolCalls } = await streamOneTurn({ openai, model, messages: convo, tools, onEvent }) + if (toolCalls.length === 0) { + onEvent({ type: 'done' }) + return + } + convo.push({ + role: 'assistant', + content: text || null, + tool_calls: toolCalls.map((c) => ({ + id: c.id, + type: 'function', + function: { name: c.name, arguments: c.args }, + })), + }) + for (const c of toolCalls) { + let parsed = null + try { + parsed = c.args ? JSON.parse(c.args) : {} + } catch { + parsed = null + } + onEvent({ type: 'tool_call', id: c.id, name: c.name, args: parsed ?? {} }) + const result = + parsed === null + ? { text: JSON.stringify({ error: `Invalid JSON arguments: ${c.args}` }), isError: true } + : await bridge.callTool(c.name, parsed) + onEvent({ type: 'tool_result', id: c.id, name: c.name, result: result.text, isError: result.isError }) + convo.push({ role: 'tool', tool_call_id: c.id, content: result.text }) + } + } + + // Loop guard: force a final, tool-free summary + convo.push({ role: 'user', content: 'Tool budget exhausted — summarize what you did and stop.' }) + await streamOneTurn({ openai, model, messages: convo, onEvent }) + onEvent({ type: 'done' }) +} + +async function streamOneTurn({ openai, model, messages, tools, onEvent }) { + const stream = await openai.chat.completions.create({ + model, + messages, + stream: true, + ...(tools ? { tools } : {}), + }) + let text = '' + const toolCalls = [] + for await (const chunk of stream) { + const delta = chunk.choices?.[0]?.delta + if (!delta) continue + if (delta.content) { + text += delta.content + onEvent({ type: 'text', delta: delta.content }) + } + for (const tc of delta.tool_calls || []) { + const slot = (toolCalls[tc.index] ??= { id: '', name: '', args: '' }) + if (tc.id) slot.id = tc.id + if (tc.function?.name) slot.name += tc.function.name + if (tc.function?.arguments) slot.args += tc.function.arguments + } + } + return { text, toolCalls: toolCalls.filter(Boolean) } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd chat && npx vitest run tests/agentLoop.spec.js` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add chat/lib/agentLoop.js chat/tests/agentLoop.spec.js +git commit -m "Add OpenAI function-calling agent loop" +``` + +--- + +### Task 5: Express server + SSE + +**Files:** +- Create: `chat/lib/app.js` (Express app factory — testable) +- Create: `chat/server.js` (entrypoint) +- Test: `chat/tests/app.spec.js` + +**Interfaces:** +- Consumes: `runAgentLoop`/`SYSTEM_PROMPT` (Task 4), `McpBridge` shape (Task 3), `loadConfig` (Task 2). +- Produces: `createApp({cfg, openai, bridge}): express.Application` from `chat/lib/app.js` with routes `GET /api/health`, `GET /api/tools`, `POST /api/chat` (SSE), static `public/`. `chat/server.js` wires real deps and listens on `cfg.port`. + +- [ ] **Step 1: Write the failing test** + +`chat/tests/app.spec.js`: +```js +import { describe, it, expect, afterEach } from 'vitest' +import { createApp } from '../lib/app.js' + +const cfg = { model: 'test-model', port: 0 } + +function fakeBridge() { + return { + isConnected: () => true, + getOpenAiTools: async () => [{ type: 'function', function: { name: 'mission_list', description: 'List', parameters: {} } }], + callTool: async () => ({ text: '{"ok":true}', isError: false }), + } +} + +function fakeOpenai(script) { + return { + chat: { + completions: { + create: async () => (async function* () { + for (const c of script) yield { choices: [{ delta: c }] } + })(), + }, + }, + } +} + +async function readSse(res) { + const text = await res.text() + return text.split('\n\n').filter(Boolean).map((f) => JSON.parse(f.replace(/^data: /, ''))) +} + +describe('chat app', () => { + let server + afterEach(() => server?.close()) + + async function start(app) { + await new Promise((resolve) => { server = app.listen(0, resolve) }) + return `http://127.0.0.1:${server.address().port}` + } + + it('GET /api/health reports model and mcp status', async () => { + const url = await start(createApp({ cfg, openai: fakeOpenai([]), bridge: fakeBridge() })) + const out = await (await fetch(`${url}/api/health`)).json() + expect(out).toEqual({ ok: true, model: 'test-model', mcpConnected: true, toolCount: 1 }) + }) + + it('GET /api/tools lists tool names and descriptions', async () => { + const url = await start(createApp({ cfg, openai: fakeOpenai([]), bridge: fakeBridge() })) + const out = await (await fetch(`${url}/api/tools`)).json() + expect(out.tools).toEqual([{ name: 'mission_list', description: 'List' }]) + }) + + it('POST /api/chat streams SSE events ending in done', async () => { + const url = await start(createApp({ cfg, openai: fakeOpenai([{ content: 'Hi' }]), bridge: fakeBridge() })) + const res = await fetch(`${url}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: [{ role: 'user', content: 'hello' }] }), + }) + expect(res.headers.get('content-type')).toContain('text/event-stream') + expect(await readSse(res)).toEqual([{ type: 'text', delta: 'Hi' }, { type: 'done' }]) + }) + + it('rejects malformed bodies with 400', async () => { + const url = await start(createApp({ cfg, openai: fakeOpenai([]), bridge: fakeBridge() })) + const res = await fetch(`${url}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: 'nope' }), + }) + expect(res.status).toBe(400) + }) + + it('maps loop failures to an SSE error event', async () => { + const openai = { chat: { completions: { create: async () => { throw new Error('bad key') } } } } + const url = await start(createApp({ cfg, openai, bridge: fakeBridge() })) + const res = await fetch(`${url}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: [{ role: 'user', content: 'x' }] }), + }) + const events = await readSse(res) + expect(events.at(-1)).toEqual({ type: 'error', message: 'bad key' }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd chat && npx vitest run tests/app.spec.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `chat/lib/app.js`** + +```js +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import express from 'express' +import { runAgentLoop } from './agentLoop.js' + +const PUBLIC_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'public') + +export function createApp({ cfg, openai, bridge }) { + const app = express() + app.use(express.json({ limit: '2mb' })) + app.use(express.static(PUBLIC_DIR)) + + app.get('/api/health', async (req, res) => { + let toolCount = 0 + try { + toolCount = (await bridge.getOpenAiTools()).length + } catch { + // leave toolCount 0; mcpConnected reflects reality below + } + res.json({ ok: true, model: cfg.model, mcpConnected: bridge.isConnected(), toolCount }) + }) + + app.get('/api/tools', async (req, res) => { + try { + const tools = await bridge.getOpenAiTools() + res.json({ tools: tools.map((t) => ({ name: t.function.name, description: t.function.description })) }) + } catch (err) { + res.status(502).json({ error: err.message }) + } + }) + + app.post('/api/chat', async (req, res) => { + const { messages } = req.body || {} + const valid = + Array.isArray(messages) && + messages.every((m) => m && typeof m.content === 'string' && ['user', 'assistant'].includes(m.role)) + if (!valid) { + return res.status(400).json({ error: 'body must be {messages: [{role: user|assistant, content: string}]}' }) + } + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + const send = (event) => res.write(`data: ${JSON.stringify(event)}\n\n`) + try { + await runAgentLoop({ messages, openai, bridge, model: cfg.model, onEvent: send }) + } catch (err) { + send({ type: 'error', message: err.message }) + } + res.end() + }) + + return app +} +``` + +- [ ] **Step 4: Implement `chat/server.js`** + +```js +import 'dotenv/config' +import OpenAI from 'openai' +import { loadConfig } from './lib/config.js' +import { McpBridge } from './lib/mcpBridge.js' +import { createApp } from './lib/app.js' + +const cfg = loadConfig() +const openai = new OpenAI({ apiKey: cfg.apiKey }) +const bridge = new McpBridge(cfg) + +const app = createApp({ cfg, openai, bridge }) +app.listen(cfg.port, () => { + console.log(`MMGIS chat UI: http://localhost:${cfg.port} (model: ${cfg.model})`) +}) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd chat && npm test` +Expected: PASS (config 4, bridge 3, agentLoop 4, app 5 = 16 tests). + +- [ ] **Step 6: Commit** + +```bash +git add chat/lib/app.js chat/server.js chat/tests/app.spec.js +git commit -m "Add chat server with SSE streaming endpoints" +``` + +--- + +### Task 6: Frontend + +**Files:** +- Create: `chat/public/index.html` +- Create: `chat/public/style.css` +- Create: `chat/public/app.js` +- Test: `chat/tests/frontend.spec.js` (pure helpers only) + +**Interfaces:** +- Consumes: the server routes and SSE protocol (Task 5, Global Constraints). +- Produces: exported pure helpers in `app.js` used by tests: `parseSseChunks(buffer): {events, rest}` and `extractUrls(resultText): string[]`. DOM wiring runs only in the browser (guarded by `typeof document !== 'undefined'`). + +- [ ] **Step 1: Write the failing test** + +`chat/tests/frontend.spec.js`: +```js +import { describe, it, expect } from 'vitest' +import { parseSseChunks, extractUrls } from '../public/app.js' + +describe('parseSseChunks', () => { + it('parses complete frames and keeps the remainder', () => { + const buffer = 'data: {"type":"text","delta":"a"}\n\ndata: {"type":"done"}\n\ndata: {"type":"te' + const { events, rest } = parseSseChunks(buffer) + expect(events).toEqual([{ type: 'text', delta: 'a' }, { type: 'done' }]) + expect(rest).toBe('data: {"type":"te') + }) + it('skips unparseable frames', () => { + const { events } = parseSseChunks('data: not json\n\ndata: {"type":"done"}\n\n') + expect(events).toEqual([{ type: 'done' }]) + }) +}) + +describe('extractUrls', () => { + it('collects url values from tool-result JSON', () => { + expect(extractUrls('{"mission":"X","url":"http://localhost:8891/?mission=X"}')).toEqual([ + 'http://localhost:8891/?mission=X', + ]) + }) + it('returns empty for non-JSON or url-less results', () => { + expect(extractUrls('plain text')).toEqual([]) + expect(extractUrls('{"a":1}')).toEqual([]) + }) + it('finds nested url fields', () => { + expect(extractUrls('{"result":{"url":"http://x/y"}}')).toEqual(['http://x/y']) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd chat && npx vitest run tests/frontend.spec.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Create `chat/public/index.html`** + +```html + + + + + + MMGIS Chat + + + +
+

MMGIS Chat

+
connecting…
+
+ + +
+
+ + + +
+ +
+ + +
+ + + + +``` + +- [ ] **Step 4: Create `chat/public/style.css`** + +```css +* { box-sizing: border-box; } +body { + margin: 0; height: 100vh; display: flex; flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: #12151a; color: #e6e8eb; +} +header { + display: flex; align-items: center; gap: 12px; padding: 10px 16px; + background: #1a1f27; border-bottom: 1px solid #2a3040; +} +header h1 { font-size: 15px; margin: 0; } +.status { font-size: 12px; color: #8b94a3; flex: 1; } +.status.ok { color: #5dd39e; } +.status.bad { color: #e5636c; } +.header-actions { display: flex; gap: 8px; } +button { + background: #2a3550; color: #e6e8eb; border: 1px solid #3a4a70; + border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer; +} +button:hover { background: #34426a; } +.drawer { padding: 12px 16px; background: #161b22; border-bottom: 1px solid #2a3040; display: flex; flex-direction: column; gap: 8px; } +.drawer.hidden { display: none; } +.drawer label { font-size: 12px; color: #8b94a3; display: flex; flex-direction: column; gap: 4px; } +.drawer input, .drawer textarea, #input { + background: #0d1117; color: #e6e8eb; border: 1px solid #2a3040; + border-radius: 6px; padding: 8px; font-size: 13px; font-family: ui-monospace, Menlo, monospace; +} +main { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; } +.msg { max-width: 780px; padding: 10px 14px; border-radius: 10px; white-space: pre-wrap; font-size: 14px; line-height: 1.45; } +.msg.user { background: #2a3550; align-self: flex-end; } +.msg.assistant { background: #1c2230; align-self: flex-start; } +.msg.error { background: #3a1d22; border: 1px solid #e5636c; align-self: stretch; } +details.tool { + align-self: flex-start; max-width: 780px; width: 100%; + background: #171d28; border: 1px solid #2a3040; border-radius: 10px; font-size: 13px; +} +details.tool summary { padding: 8px 12px; cursor: pointer; color: #9fb4d8; } +details.tool.error { border-color: #e5636c; } +details.tool.error summary { color: #e5636c; } +details.tool pre { + margin: 0; padding: 8px 12px; overflow-x: auto; font-size: 12px; + background: #0d1117; border-top: 1px solid #2a3040; white-space: pre-wrap; +} +.dash-link { + display: inline-block; margin: 8px 12px; padding: 6px 12px; + background: #1d5c3f; border: 1px solid #2e8b5f; border-radius: 6px; + color: #d7ffe9; text-decoration: none; font-size: 13px; +} +#composer { display: flex; gap: 8px; padding: 12px 16px; background: #1a1f27; border-top: 1px solid #2a3040; } +#input { flex: 1; resize: none; } +``` + +- [ ] **Step 5: Create `chat/public/app.js`** + +```js +// --- Pure helpers (unit-tested; no DOM access at module top level) --- + +export function parseSseChunks(buffer) { + const frames = buffer.split('\n\n') + const rest = frames.pop() + const events = [] + for (const frame of frames) { + const data = frame.replace(/^data: /, '').trim() + if (!data) continue + try { + events.push(JSON.parse(data)) + } catch { + // skip malformed frame + } + } + return { events, rest } +} + +export function extractUrls(resultText) { + let parsed + try { + parsed = JSON.parse(resultText) + } catch { + return [] + } + const urls = [] + const walk = (node) => { + if (node == null || typeof node !== 'object') return + for (const [key, value] of Object.entries(node)) { + if (key === 'url' && typeof value === 'string') urls.push(value) + else walk(value) + } + } + walk(parsed) + return urls +} + +// --- Browser wiring --- + +if (typeof document !== 'undefined') { + const transcript = document.getElementById('transcript') + const composer = document.getElementById('composer') + const input = document.getElementById('input') + const status = document.getElementById('status') + const newChat = document.getElementById('newChat') + const drawerToggle = document.getElementById('jsonDrawerToggle') + const drawer = document.getElementById('jsonDrawer') + const jsonCreate = document.getElementById('jsonCreate') + + let messages = JSON.parse(localStorage.getItem('mmgisChat') || '[]') + let busy = false + + const save = () => localStorage.setItem('mmgisChat', JSON.stringify(messages)) + + function addBubble(cls, text) { + const div = document.createElement('div') + div.className = `msg ${cls}` + div.textContent = text + transcript.appendChild(div) + transcript.scrollTop = transcript.scrollHeight + return div + } + + function addToolCard(name, args) { + const details = document.createElement('details') + details.className = 'tool' + details.innerHTML = `🔧 ${name}` + const argsPre = document.createElement('pre') + argsPre.textContent = `args: ${JSON.stringify(args, null, 2)}` + details.appendChild(argsPre) + transcript.appendChild(details) + transcript.scrollTop = transcript.scrollHeight + return details + } + + function finishToolCard(card, result, isError) { + if (isError) card.classList.add('error') + const pre = document.createElement('pre') + try { + pre.textContent = JSON.stringify(JSON.parse(result), null, 2) + } catch { + pre.textContent = result + } + card.appendChild(pre) + for (const url of extractUrls(result)) { + const a = document.createElement('a') + a.className = 'dash-link' + a.href = url + a.target = '_blank' + a.textContent = 'Open dashboard →' + card.appendChild(a) + } + } + + function render() { + transcript.innerHTML = '' + for (const m of messages) addBubble(m.role, m.content) + } + + async function refreshHealth() { + try { + const h = await (await fetch('/api/health')).json() + status.textContent = `${h.model} · ${h.toolCount} tools · MCP ${h.mcpConnected ? 'connected' : 'DISCONNECTED'}` + status.className = `status ${h.mcpConnected ? 'ok' : 'bad'}` + } catch { + status.textContent = 'server unreachable' + status.className = 'status bad' + } + } + + async function sendConversation() { + busy = true + const toolCards = new Map() + let assistantDiv = null + let assistantText = '' + try { + const res = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages }), + }) + if (!res.ok) throw new Error(`server ${res.status}`) + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const { events, rest } = parseSseChunks(buffer) + buffer = rest + for (const ev of events) { + if (ev.type === 'text') { + if (!assistantDiv) assistantDiv = addBubble('assistant', '') + assistantText += ev.delta + assistantDiv.textContent = assistantText + transcript.scrollTop = transcript.scrollHeight + } else if (ev.type === 'tool_call') { + // a new assistant bubble will follow the tool round + assistantDiv = null + toolCards.set(ev.id, addToolCard(ev.name, ev.args)) + } else if (ev.type === 'tool_result') { + const card = toolCards.get(ev.id) + if (card) finishToolCard(card, ev.result, ev.isError) + } else if (ev.type === 'error') { + addBubble('error', `Error: ${ev.message}`) + } + } + } + if (assistantText) { + messages.push({ role: 'assistant', content: assistantText }) + save() + } + } catch (err) { + addBubble('error', `Error: ${err.message}`) + } finally { + busy = false + } + } + + function submitUserMessage(content) { + if (busy || !content.trim()) return + messages.push({ role: 'user', content }) + save() + addBubble('user', content) + sendConversation() + } + + composer.addEventListener('submit', (e) => { + e.preventDefault() + const content = input.value + input.value = '' + submitUserMessage(content) + }) + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + composer.requestSubmit() + } + }) + newChat.addEventListener('click', () => { + messages = [] + save() + render() + }) + drawerToggle.addEventListener('click', () => drawer.classList.toggle('hidden')) + jsonCreate.addEventListener('click', () => { + const name = document.getElementById('jsonMissionName').value.trim() || 'From JSON' + const json = document.getElementById('jsonConfig').value.trim() + if (!json) return + try { + JSON.parse(json) + } catch { + addBubble('error', 'Error: the JSON config drawer contains invalid JSON') + return + } + drawer.classList.add('hidden') + submitUserMessage( + `Create a dashboard named "${name}" from this exact config JSON using dashboard_create_from_config (updateExisting: true):\n\`\`\`json\n${json}\n\`\`\`` + ) + }) + + render() + refreshHealth() + setInterval(refreshHealth, 15000) +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd chat && npm test` +Expected: PASS (all suites; frontend 5 tests). + +- [ ] **Step 7: Commit** + +```bash +git add chat/public chat/tests/frontend.spec.js +git commit -m "Add chat frontend with tool cards and JSON config drawer" +``` + +--- + +### Task 7: README + manual E2E + +**Files:** +- Create: `chat/README.md` +- Modify: `AGENTS.md` (Project Structure: add `chat/` line after the `mcp/` line) + +- [ ] **Step 1: Write `chat/README.md`** + +```markdown +# MMGIS Chat UI + +A standalone chat app for driving MMGIS with your own OpenAI key: describe a +dashboard, watch the MCP tools fire, open the result, and steer the live map — +all from a browser chat. + +## Quickstart + +1. Build the MCP server once: `cd ../mcp && npm install && npm run build` +2. `cd chat && npm install` +3. `cp .env.example .env` and set `OPENAI_API_KEY`, `MMGIS_URL`, `MMGIS_TOKEN` + (mint a token per `../mcp/README.md`). +4. `npm start` → open http://localhost:8895 + +## What you can do + +- "Create an air quality dashboard over Atlanta" → watch `catalog_*` + + `dashboard_generate` fire; click "Open dashboard →". +- "Show me the config JSON for that dashboard" → the model calls + `dashboard_generate` with `returnConfig: true`; copy the JSON from the tool card. +- **JSON config** drawer → paste/edit config JSON, name it, "Create dashboard + from JSON" (runs `dashboard_create_from_config` through the agent, visibly). +- With a dashboard open in another tab: "fly the map to Huntsville" (`view_*` + tools drive that session over the MMGIS websocket). + +## How it works + +Browser (static page, SSE) → `server.js` (Express; your key stays here) → +OpenAI function calling → MCP client over stdio → `../mcp/dist/index.js` → +MMGIS REST + websocket. Conversation state lives in your browser +(localStorage); the server is stateless. + +## Env vars + +| Var | Default | Purpose | +| --- | --- | --- | +| `OPENAI_API_KEY` | (required) | Server-side only | +| `OPENAI_MODEL` | `gpt-4o` | Chat model | +| `CHAT_PORT` | `8895` | UI port | +| `MCP_COMMAND` / `MCP_ARGS` | `node` / `../mcp/dist/index.js` | MCP server launch (paths relative to `chat/`) | +| `MMGIS_URL`, `MMGIS_TOKEN`, `MAPBOX_TOKEN`, ... | — | Passed through to the MCP server | + +## Manual E2E checklist + +- [ ] `/api/health` shows the model and `MCP connected` with 14 tools +- [ ] Simple prompt streams a text reply +- [ ] Dashboard request shows tool cards and an "Open dashboard →" button that loads in MMGIS +- [ ] "show me the config JSON" returns the full config in a tool card +- [ ] JSON drawer creates a mission from pasted (edited) config +- [ ] `view_fly_to` request visibly moves an open dashboard's map +- [ ] Bad OpenAI key shows a red error bubble, conversation survives a retry +``` + +- [ ] **Step 2: Add `chat/` to AGENTS.md project structure** + +In the Project Structure tree, after the `mcp/` line, add: +``` +├── chat/ # Standalone chat UI: OpenAI-driven MMGIS control via the MCP server +``` + +- [ ] **Step 3: Full verification sweep** + +Run: `cd mcp && npm test && npm run build && cd ../chat && npm test && cd .. && npx vitest run` +Expected: all pass. + +- [ ] **Step 4: Manual E2E against the live deployment** + +With the running MMGIS instance (localhost:8891, token in `mcp/README.md` context) and a real `OPENAI_API_KEY` in `chat/.env`: run `cd chat && npm start`, walk the README checklist. Record deviations and fix before closing. + +- [ ] **Step 5: Commit** + +```bash +git add chat/README.md AGENTS.md +git commit -m "Add chat app documentation and register it in the project structure" +``` + +--- + +## Spec coverage map + +| Spec requirement | Task | +| --- | --- | +| Standalone `chat/` folder, own package/.env, key server-side, `.env.example` | 2 | +| MCP-client tool execution, auto-discovery, zero duplication | 3 | +| Agent loop: function calling, streaming, 15-iteration guard, system prompt | 4 | +| `POST /api/chat` SSE protocol, `/api/health`, `/api/tools`, stateless server | 5 | +| Vanilla no-build UI: transcript, streaming, tool cards, dashboard links, localStorage, New chat, status strip | 6 | +| JSON round-trip: `returnConfig`, `dashboard_create_from_config`, JSON drawer | 1 (server), 6 (drawer) | +| Error handling: OpenAI failure → error bubble; tool errors visible; MCP reconnect; loop guard | 3, 4, 5, 6 | +| Testing: unit (loop/bridge/config/app/helpers) + manual E2E script | 2-6, 7 | +| Non-goals respected: no auth/persistence/providers/embedding | all | + +Deliberate scope notes: the capabilities sidebar from the spec is served by `/api/tools` + the status strip (tool count); a full sidebar UI was trimmed as YAGNI for a test harness — the tool cards themselves show what's being used. diff --git a/docs/superpowers/plans/2026-07-25-chat-full-config.md b/docs/superpowers/plans/2026-07-25-chat-full-config.md new file mode 100644 index 000000000..b1ecca113 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-chat-full-config.md @@ -0,0 +1,1106 @@ +# Chat-Driven Full Configuration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 14 new MCP tools so the chat can edit existing dashboards live (merge-patch + layer tools with real-time refresh in open sessions) and run admin operations (mission clone/delete, geodataset list/ingest/delete, user list/create/permissions) with a visible confirm-in-chat protocol. + +**Architecture:** Everything lands in `mcp/` (TypeScript, NodeNext ESM): `MmgisClient` grows methods for the existing REST endpoints; a `mergePatch` util + `editConfig` helper implement get → mutate → upsert(`forceClientUpdate`); new tool groups `edit.ts` and admin additions register in `index.ts`. One tiny frontend addition (`reload` bridge command) and one minimal, flagged backend change (token annotation for `/api/users/signup`). Chat app: only the system prompt changes. + +**Tech Stack:** existing mcp/ package (TS, zod, vitest); one bridge command in `chat`-side AgentBridge component (plain JS); ~15 lines of backend JS. + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-07-25-chat-full-config-design.md`. Branch: `feature/agentic-mmgis`. +- TypeScript NodeNext, `.js` internal imports in mcp/; frontend 4-space single-quote; commits imperative, NO Co-Authored-By trailer. +- Structured tool results only (`toToolResult`/`toErrorResult`; `{error, hint}`). +- Destructive tools (`mission_delete`, `geodataset_delete`, `user_create`, `user_set_permission`) require `confirm: true`; without it return `{needsConfirmation: true, ...preview}` and make NO mutating client call. +- Merge-patch semantics are RFC 7386: non-object patch (incl. arrays) replaces; object patches recurse; `null` deletes the key. +- Live refresh contract (verified in code): `/upsert` accepts `forceClientUpdate` and optional `info`; the frontend AUTO-APPLIES only when `info.type ∈ {addLayer, updateLayer, removeLayer}` AND `forceClientUpdate` (essence.js:229-271); any other `info.type` shows a one-click RELOAD button. Layer tools therefore send layer-typed `info`; `mission_update_config`/`tool_toggle` rely on RELOAD or the new `view_reload` bridge command. +- Tests: `cd mcp && npm test` (+ targeted vitest runs per task); root `npx vitest run` must stay green. + +## Verified endpoint facts (from code research, 2026-07-25 — file:line refs in `.superpowers/sdd/` explorer reports) + +- `POST /api/configure/upsert` body `{mission, config, forceClientUpdate?, info?}`; guarded by `checkMissionPermission` (honors long-term tokens); response `{status, mission, version, newlyAddedUUIDs}`; broadcasts `{info, body, forceClientUpdate}` over WS after persisting (configs.js:561-571). Default `info.type` is `'upsert'`. +- `POST /api/configure/clone` body `{existingMission, cloneMission, hasPaths?}`; shells out to `execFile("python", ["private/api/create_mission.py", ...])` — **may fail on hosts without a `python` binary** (macOS often has only `python3`); response = add()'s response. No route-level permission check (behind ensureAdmin only). +- `POST /api/configure/destroy` body `{mission}`; deletes all config rows, renames `Missions/` dir to `_deleted_`; success `{status:'success', message:'Successfully Deleted Mission: '}`. No route-level permission check. +- `POST /api/geodatasets/entries` (no body) → `{status, body: {entries: [{name, updated, filename, num_features, occurrences: {mission: [...]}}]}}`. Token-friendly (ensureAdmin no-args). +- `POST /api/geodatasets/recreate/:name` — HTTP body is the RAW GeoJSON FeatureCollection; response `{status, message, body}`. `DELETE /api/geodatasets/remove/:name` → `{status, message}`. Both token-friendly. +- Geodataset → layer: layer entry `type: 'vector'`, `url: 'geodatasets:'` (Layers_.js:3918). +- `GET /api/accounts/entries` → `{status, body: {entries: [{id, username, email, permission, missions_managing, ...}]}}`. `POST /api/accounts/update` body `{id, permission?, email?, missions_managing?}` — permission applied ONLY if exactly `'110'` or `'001'`; `missions_managing` only with `'110'`; user id 1 permission-protected. Both behind ensureAdmin → token-friendly. +- `POST /api/users/signup` body `{username, password, skipLogin: true}` — creates permission `'001'`; password must be ≥8 chars with upper+lower+number+symbol; **gate checks `req.session.permission === '111'` only** (users.js:82-96) and `/api/users` is NOT behind ensureAdmin, so `req.isLongTermToken` is never set → token-based creation requires the Task 5 backend change (flagged). + +--- + +### Task 1: MmgisClient endpoint methods + +**Files:** +- Modify: `mcp/src/mmgisClient.ts` +- Test: `mcp/tests/mmgisClient.spec.ts` (extend) + +**Interfaces:** +- Consumes: existing `request()` private helper, `MMGISError`. +- Produces (later tasks rely on these EXACT signatures): + - `upsertMission(mission: string, config: any, opts?: {forceClientUpdate?: boolean; info?: {type: string; layerName?: string | string[]}}): Promise<{mission, version}>` — body gains `forceClientUpdate`/`info` only when provided (existing two-arg callers unchanged). + - `cloneMission(existingMission: string, cloneMission: string): Promise` → POST `/api/configure/clone`. + - `destroyMission(mission: string): Promise<{message: string}>` → POST `/api/configure/destroy`. + - `geodatasetEntries(): Promise` → POST `/api/geodatasets/entries`, returns `json.body.entries`. + - `geodatasetRecreate(name: string, geojson: any): Promise` → POST `/api/geodatasets/recreate/${encodeURIComponent(name)}` with the RAW geojson as body. + - `geodatasetRemove(name: string): Promise<{message: string}>` → DELETE `/api/geodatasets/remove/${encodeURIComponent(name)}`. + - `accountEntries(): Promise` → GET `/api/accounts/entries`, returns `json.body.entries`. + - `accountUpdate(input: {id: number; permission?: '110' | '001'; missionsManaging?: string[]}): Promise` → POST `/api/accounts/update` body `{id, permission, missions_managing}` (snake_case on the wire). + - `userSignup(username: string, password: string): Promise` → POST `/api/users/signup` body `{username, password, skipLogin: true}`. + - `request` gains DELETE support: change its method union to `'GET' | 'POST' | 'DELETE'`. + +- [ ] **Step 1: Write the failing tests** — append inside the existing `describe('MmgisClient', ...)` in `mcp/tests/mmgisClient.spec.ts` (reuse its `fakeFetch` helper): + +```ts + it('upsertMission passes forceClientUpdate and info only when provided', async () => { + const f = fakeFetch(200, { status: 'success', mission: 'X', version: 2 }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + await client.upsertMission('X', { a: 1 }) + expect(JSON.parse((f as any).mock.calls[0][1].body)).toEqual({ mission: 'X', config: { a: 1 } }) + await client.upsertMission('X', { a: 1 }, { forceClientUpdate: true, info: { type: 'updateLayer', layerName: 'L' } }) + expect(JSON.parse((f as any).mock.calls[1][1].body)).toEqual({ + mission: 'X', config: { a: 1 }, forceClientUpdate: true, info: { type: 'updateLayer', layerName: 'L' }, + }) + }) + it('cloneMission and destroyMission hit the configure endpoints', async () => { + const f = fakeFetch(200, { status: 'success' }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + await client.cloneMission('A', 'B') + expect((f as any).mock.calls[0][0]).toBe('http://mm:8888/api/configure/clone') + expect(JSON.parse((f as any).mock.calls[0][1].body)).toEqual({ existingMission: 'A', cloneMission: 'B' }) + await client.destroyMission('A') + expect((f as any).mock.calls[1][0]).toBe('http://mm:8888/api/configure/destroy') + expect(JSON.parse((f as any).mock.calls[1][1].body)).toEqual({ mission: 'A' }) + }) + it('geodataset methods use the right verbs, paths, and raw bodies', async () => { + const f = fakeFetch(200, { status: 'success', body: { entries: [{ name: 'g1' }] } }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + expect(await client.geodatasetEntries()).toEqual([{ name: 'g1' }]) + expect((f as any).mock.calls[0][1].method).toBe('POST') + const fc = { type: 'FeatureCollection', features: [] } + await client.geodatasetRecreate('my set', fc) + expect((f as any).mock.calls[1][0]).toBe('http://mm:8888/api/geodatasets/recreate/my%20set') + expect(JSON.parse((f as any).mock.calls[1][1].body)).toEqual(fc) + await client.geodatasetRemove('my set') + expect((f as any).mock.calls[2][1].method).toBe('DELETE') + expect((f as any).mock.calls[2][0]).toBe('http://mm:8888/api/geodatasets/remove/my%20set') + }) + it('account and signup methods match the backend wire shapes', async () => { + const f = fakeFetch(200, { status: 'success', body: { entries: [{ id: 1, username: 'admin' }] } }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + expect(await client.accountEntries()).toEqual([{ id: 1, username: 'admin' }]) + expect((f as any).mock.calls[0][1].method === undefined || (f as any).mock.calls[0][1].method === 'GET').toBe(true) + await client.accountUpdate({ id: 2, permission: '110', missionsManaging: ['Demo'] }) + expect(JSON.parse((f as any).mock.calls[1][1].body)).toEqual({ id: 2, permission: '110', missions_managing: ['Demo'] }) + await client.userSignup('alice', 'Str0ng!Pass') + expect((f as any).mock.calls[2][0]).toBe('http://mm:8888/api/users/signup') + expect(JSON.parse((f as any).mock.calls[2][1].body)).toEqual({ username: 'alice', password: 'Str0ng!Pass', skipLogin: true }) + }) +``` + +- [ ] **Step 2: Run to verify the new tests fail** + +Run: `cd mcp && npx vitest run tests/mmgisClient.spec.ts` +Expected: FAIL — new methods undefined. + +- [ ] **Step 3: Implement in `mcp/src/mmgisClient.ts`** — change `request`'s signature to `private async request(method: 'GET' | 'POST' | 'DELETE', apiPath: string, body?: unknown)` (no other change needed there), adjust `upsertMission`, and append the new methods: + +```ts + async upsertMission( + mission: string, + config: any, + opts?: { forceClientUpdate?: boolean; info?: { type: string; layerName?: string | string[] } } + ): Promise<{ mission: string; version: number }> { + return await this.request('POST', '/api/configure/upsert', { + mission, + config, + ...(opts?.forceClientUpdate !== undefined ? { forceClientUpdate: opts.forceClientUpdate } : {}), + ...(opts?.info ? { info: opts.info } : {}), + }) + } + + async cloneMission(existingMission: string, cloneMission: string): Promise { + return await this.request('POST', '/api/configure/clone', { existingMission, cloneMission }) + } + + async destroyMission(mission: string): Promise<{ message: string }> { + return await this.request('POST', '/api/configure/destroy', { mission }) + } + + async geodatasetEntries(): Promise { + const json = await this.request('POST', '/api/geodatasets/entries', {}) + return json.body?.entries ?? [] + } + + async geodatasetRecreate(name: string, geojson: any): Promise { + return await this.request('POST', `/api/geodatasets/recreate/${encodeURIComponent(name)}`, geojson) + } + + async geodatasetRemove(name: string): Promise<{ message: string }> { + return await this.request('DELETE', `/api/geodatasets/remove/${encodeURIComponent(name)}`) + } + + async accountEntries(): Promise { + const json = await this.request('GET', '/api/accounts/entries') + return json.body?.entries ?? [] + } + + async accountUpdate(input: { id: number; permission?: '110' | '001'; missionsManaging?: string[] }): Promise { + return await this.request('POST', '/api/accounts/update', { + id: input.id, + ...(input.permission ? { permission: input.permission } : {}), + ...(input.missionsManaging ? { missions_managing: input.missionsManaging } : {}), + }) + } + + async userSignup(username: string, password: string): Promise { + return await this.request('POST', '/api/users/signup', { username, password, skipLogin: true }) + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd mcp && npx vitest run tests/mmgisClient.spec.ts && npm run build` +Expected: PASS (existing 7 + 4 new); build exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add mcp/src/mmgisClient.ts mcp/tests/mmgisClient.spec.ts +git commit -m "Add REST client methods for config editing and admin operations" +``` + +--- + +### Task 2: Merge-patch util + editConfig helper + +**Files:** +- Create: `mcp/src/configEdit.ts` +- Test: `mcp/tests/configEdit.spec.ts` + +**Interfaces:** +- Consumes: `MmgisClient.getMission`/`upsertMission` (Task 1 signatures). +- Produces: + - `mergePatch(target: any, patch: any): any` — RFC 7386; returns a NEW value (does not mutate target). + - `editConfig(client: MmgisClient, missionName: string, mutate: (config: any) => {info?: {type: string; layerName?: string | string[]}} | void): Promise<{mission: string; version: number}>` — fetches config, deep-clones it, runs `mutate` (mutates the clone in place; may return `{info}`), upserts with `forceClientUpdate: true` and `info` (default `{type: 'upsert'}`). + - `findLayerIndex(config: any, nameOrUuid: string): number` — index in `config.layers` matching `name` or `uuid` (top level only), else -1. + +- [ ] **Step 1: Write the failing test** + +`mcp/tests/configEdit.spec.ts`: +```ts +import { describe, it, expect, vi } from 'vitest' +import { mergePatch, editConfig, findLayerIndex } from '../src/configEdit.js' + +describe('mergePatch (RFC 7386)', () => { + it.each([ + ['nested objects merge', { a: { b: 1, c: 2 } }, { a: { c: 3 } }, { a: { b: 1, c: 3 } }], + ['null deletes a key', { a: 1, b: 2 }, { b: null }, { a: 1 }], + ['arrays replace wholesale', { a: [1, 2] }, { a: [3] }, { a: [3] }], + ['scalars replace', { a: 1 }, { a: 'x' }, { a: 'x' }], + ['non-object patch replaces target', { a: 1 }, 'str', 'str'], + ['new nested keys are created', { a: {} }, { a: { b: { c: 1 } } }, { a: { b: { c: 1 } } }], + ['null inside new object is dropped', {}, { a: { b: null } }, { a: {} }], + ])('%s', (_name, target, patch, expected) => { + expect(mergePatch(target, patch)).toEqual(expected) + }) + it('does not mutate the target', () => { + const target = { a: { b: 1 } } + mergePatch(target, { a: { b: 2 } }) + expect(target.a.b).toBe(1) + }) +}) + +describe('editConfig', () => { + function fakeClient(config: any) { + return { + getMission: vi.fn(async () => ({ mission: 'M', config, version: 3 })), + upsertMission: vi.fn(async () => ({ mission: 'M', version: 4 })), + } as any + } + it('fetches, mutates a clone, and upserts with forceClientUpdate and default info', async () => { + const original = { look: { pagename: 'Old' }, layers: [] } + const client = fakeClient(original) + const out = await editConfig(client, 'M', (config) => { + config.look.pagename = 'New' + }) + expect(out.version).toBe(4) + expect(original.look.pagename).toBe('Old') + const [mission, sent, opts] = client.upsertMission.mock.calls[0] + expect(mission).toBe('M') + expect(sent.look.pagename).toBe('New') + expect(opts).toEqual({ forceClientUpdate: true, info: { type: 'upsert' } }) + }) + it('uses the info returned by the mutator', async () => { + const client = fakeClient({ layers: [] }) + await editConfig(client, 'M', (config) => { + config.layers.push({ name: 'L' }) + return { info: { type: 'addLayer', layerName: 'L' } } + }) + expect(client.upsertMission.mock.calls[0][2]).toEqual({ + forceClientUpdate: true, info: { type: 'addLayer', layerName: 'L' }, + }) + }) +}) + +describe('findLayerIndex', () => { + const config = { layers: [{ name: 'A', uuid: 'u1' }, { name: 'B', uuid: 'u2' }] } + it('finds by name and by uuid', () => { + expect(findLayerIndex(config, 'B')).toBe(1) + expect(findLayerIndex(config, 'u1')).toBe(0) + }) + it('returns -1 for unknown and missing layers array', () => { + expect(findLayerIndex(config, 'nope')).toBe(-1) + expect(findLayerIndex({}, 'A')).toBe(-1) + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd mcp && npx vitest run tests/configEdit.spec.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `mcp/src/configEdit.ts`** + +```ts +import type { MmgisClient } from './mmgisClient.js' + +function isPlainObject(v: any): boolean { + return v != null && typeof v === 'object' && !Array.isArray(v) +} + +// RFC 7386 JSON Merge Patch. Returns a new value; never mutates `target`. +export function mergePatch(target: any, patch: any): any { + if (!isPlainObject(patch)) return patch + const base = isPlainObject(target) ? target : {} + const out: any = { ...base } + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete out[key] + else out[key] = mergePatch(base[key], value) + } + return out +} + +export interface EditInfo { + type: string + layerName?: string | string[] +} + +export async function editConfig( + client: MmgisClient, + missionName: string, + mutate: (config: any) => { info?: EditInfo } | void +): Promise<{ mission: string; version: number }> { + const current = await client.getMission(missionName) + const config = JSON.parse(JSON.stringify(current.config)) + const result = mutate(config) || {} + return await client.upsertMission(missionName, config, { + forceClientUpdate: true, + info: result.info ?? { type: 'upsert' }, + }) +} + +export function findLayerIndex(config: any, nameOrUuid: string): number { + const layers = Array.isArray(config?.layers) ? config.layers : [] + return layers.findIndex((l: any) => l?.name === nameOrUuid || l?.uuid === nameOrUuid) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd mcp && npx vitest run tests/configEdit.spec.ts` +Expected: PASS (11 tests). + +- [ ] **Step 5: Commit** + +```bash +git add mcp/src/configEdit.ts mcp/tests/configEdit.spec.ts +git commit -m "Add RFC 7386 merge patch and config edit helper" +``` + +--- + +### Task 3: Editing tools (`edit.ts`) + +**Files:** +- Create: `mcp/src/tools/edit.ts` +- Modify: `mcp/src/index.ts` (register) +- Test: `mcp/tests/edit.spec.ts` + +**Interfaces:** +- Consumes: `mergePatch`, `editConfig`, `findLayerIndex` (Task 2); `MmgisClient` (Task 1); `ToolDef`/`toToolResult`/`toErrorResult`; `randomUUID` from `node:crypto`. +- Produces: `makeEditTools(client: MmgisClient): ToolDef[]` with exactly: `mission_update_config`, `layer_add`, `layer_update`, `layer_remove`, `tool_toggle`. + +- [ ] **Step 1: Write the failing test** + +`mcp/tests/edit.spec.ts`: +```ts +import { describe, it, expect, vi } from 'vitest' +import { makeEditTools } from '../src/tools/edit.js' + +function parse(res: { content: { text: string }[] }) { + return JSON.parse(res.content[0].text) +} + +function fakeClient(config: any) { + return { + getMission: vi.fn(async () => ({ mission: 'M', config, version: 1 })), + upsertMission: vi.fn(async (_m: string, cfg: any) => ({ mission: 'M', version: 2, _sent: cfg })), + } as any +} + +const baseConfig = () => ({ + look: { pagename: 'Old' }, + layers: [{ name: 'OSM', uuid: 'u1', visibility: true }], + tools: [{ name: 'LayerManager', on: true }, { name: 'Chart', on: false }], +}) + +describe('edit tools', () => { + const tools = (client: any) => Object.fromEntries(makeEditTools(client).map((t) => [t.name, t])) + + it('exposes exactly the five editing tools', () => { + expect(Object.keys(tools(fakeClient({}))).sort()).toEqual([ + 'layer_add', 'layer_remove', 'layer_update', 'mission_update_config', 'tool_toggle', + ]) + }) + + it('mission_update_config applies a merge patch and upserts with reload info', async () => { + const client = fakeClient(baseConfig()) + const out = parse(await tools(client).mission_update_config.handler({ + missionName: 'M', patch: { look: { pagename: 'New' } }, + })) + expect(out.version).toBe(2) + expect(out.refresh).toMatch(/RELOAD|view_reload/) + const sent = client.upsertMission.mock.calls[0][1] + expect(sent.look.pagename).toBe('New') + expect(sent.layers).toHaveLength(1) + expect(client.upsertMission.mock.calls[0][2]).toEqual({ forceClientUpdate: true, info: { type: 'upsert' } }) + }) + + it('layer_add appends (or inserts at position), mints uuid, and sends addLayer info', async () => { + const client = fakeClient(baseConfig()) + const out = parse(await tools(client).layer_add.handler({ + missionName: 'M', layer: { name: 'NewLayer', type: 'vector', url: 'geodatasets:g1' }, position: 0, + })) + expect(out.layer.uuid).toMatch(/^[0-9a-f-]{36}$/) + const sent = client.upsertMission.mock.calls[0][1] + expect(sent.layers[0].name).toBe('NewLayer') + expect(client.upsertMission.mock.calls[0][2].info).toEqual({ type: 'addLayer', layerName: 'NewLayer' }) + }) + + it('layer_update merge-patches one layer found by name or uuid', async () => { + const client = fakeClient(baseConfig()) + await tools(client).layer_update.handler({ missionName: 'M', layer: 'u1', patch: { visibility: false } }) + const sent = client.upsertMission.mock.calls[0][1] + expect(sent.layers[0]).toEqual({ name: 'OSM', uuid: 'u1', visibility: false }) + expect(client.upsertMission.mock.calls[0][2].info).toEqual({ type: 'updateLayer', layerName: 'OSM' }) + }) + + it('layer_remove deletes by name and sends removeLayer info', async () => { + const client = fakeClient(baseConfig()) + await tools(client).layer_remove.handler({ missionName: 'M', layer: 'OSM' }) + const sent = client.upsertMission.mock.calls[0][1] + expect(sent.layers).toHaveLength(0) + expect(client.upsertMission.mock.calls[0][2].info).toEqual({ type: 'removeLayer', layerName: 'OSM' }) + }) + + it('unknown layers error with the available names and no upsert', async () => { + const client = fakeClient(baseConfig()) + const res = await tools(client).layer_update.handler({ missionName: 'M', layer: 'Nope', patch: {} }) + expect(res.isError).toBe(true) + expect(parse(res).hint).toContain('OSM') + expect(client.upsertMission).not.toHaveBeenCalled() + }) + + it('tool_toggle flips the named tool and errors on unknown tools', async () => { + const client = fakeClient(baseConfig()) + await tools(client).tool_toggle.handler({ missionName: 'M', toolName: 'Chart', on: true }) + const sent = client.upsertMission.mock.calls[0][1] + expect(sent.tools.find((t: any) => t.name === 'Chart').on).toBe(true) + const res = await tools(client).tool_toggle.handler({ missionName: 'M', toolName: 'Nope', on: true }) + expect(res.isError).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd mcp && npx vitest run tests/edit.spec.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `mcp/src/tools/edit.ts`** + +```ts +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import type { MmgisClient } from '../mmgisClient.js' +import { mergePatch, editConfig, findLayerIndex } from '../configEdit.js' +import { type ToolDef, toToolResult, toErrorResult } from './result.js' + +const RELOAD_NOTE = + 'Change saved. Open sessions show a RELOAD button; or call view_reload to apply it immediately.' +const LIVE_NOTE = 'Change saved and pushed live to open sessions.' + +function layerNames(config: any): string { + return (config?.layers ?? []).map((l: any) => l.name).join(', ') || '(none)' +} + +export function makeEditTools(client: MmgisClient): ToolDef[] { + const missionName = z.string().describe('Mission to edit (see mission_list)') + return [ + { + name: 'mission_update_config', + description: + 'Edit ANY part of a mission config with an RFC 7386 JSON merge-patch (objects merge, null deletes a key, arrays replace). Backend validation runs server-side. Prefer layer_*/tool_toggle for common edits.', + schema: { + missionName, + patch: z.record(z.any()).describe('Merge patch, e.g. {"look": {"pagename": "New Name"}} or {"msv": {"basemap": {...}}}'), + }, + handler: async ({ missionName, patch }: any) => { + try { + const out = await editConfig(client, missionName, (config) => { + const merged = mergePatch(config, patch) + for (const key of Object.keys(config)) delete config[key] + Object.assign(config, merged) + }) + return toToolResult({ ...out, refresh: RELOAD_NOTE }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + { + name: 'layer_add', + description: 'Add a layer entry to a mission. Applies live in open sessions.', + schema: { + missionName, + layer: z.record(z.any()).describe('MMGIS layer entry (see dashboard_profile_schema layerExamples; vector layers can use url "geodatasets:")'), + position: z.number().optional().describe('Index to insert at (default: end)'), + }, + handler: async ({ missionName, layer, position }: any) => { + try { + const entry = { uuid: randomUUID(), sublayers: [], visibility: true, ...layer } + const out = await editConfig(client, missionName, (config) => { + config.layers = config.layers ?? [] + const at = position === undefined ? config.layers.length : Math.max(0, Math.min(position, config.layers.length)) + config.layers.splice(at, 0, entry) + return { info: { type: 'addLayer', layerName: entry.name } } + }) + return toToolResult({ ...out, layer: entry, refresh: LIVE_NOTE }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + { + name: 'layer_update', + description: 'Merge-patch a single layer (found by name or uuid). Applies live in open sessions.', + schema: { + missionName, + layer: z.string().describe('Layer name or uuid'), + patch: z.record(z.any()).describe('Merge patch for the layer entry, e.g. {"visibility": false} or {"initialOpacity": 0.5}'), + }, + handler: async ({ missionName, layer, patch }: any) => { + try { + let updatedName = '' + const out = await editConfig(client, missionName, (config) => { + const idx = findLayerIndex(config, layer) + if (idx === -1) { + throw Object.assign(new Error(`Unknown layer: ${layer}`), { + hint: `Available layers: ${layerNames(config)}`, + }) + } + config.layers[idx] = mergePatch(config.layers[idx], patch) + updatedName = config.layers[idx].name + return { info: { type: 'updateLayer', layerName: updatedName } } + }) + return toToolResult({ ...out, layer: updatedName, refresh: LIVE_NOTE }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + { + name: 'layer_remove', + description: 'Remove a layer (by name or uuid). Applies live in open sessions.', + schema: { missionName, layer: z.string().describe('Layer name or uuid') }, + handler: async ({ missionName, layer }: any) => { + try { + let removedName = '' + const out = await editConfig(client, missionName, (config) => { + const idx = findLayerIndex(config, layer) + if (idx === -1) { + throw Object.assign(new Error(`Unknown layer: ${layer}`), { + hint: `Available layers: ${layerNames(config)}`, + }) + } + removedName = config.layers[idx].name + config.layers.splice(idx, 1) + return { info: { type: 'removeLayer', layerName: removedName } } + }) + return toToolResult({ ...out, removed: removedName, refresh: LIVE_NOTE }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + { + name: 'tool_toggle', + description: "Turn a mission's tool on or off (e.g. Chart, Measure).", + schema: { missionName, toolName: z.string(), on: z.boolean() }, + handler: async ({ missionName, toolName, on }: any) => { + try { + const out = await editConfig(client, missionName, (config) => { + const tool = (config.tools ?? []).find((t: any) => t.name === toolName) + if (!tool) { + throw Object.assign(new Error(`Unknown tool: ${toolName}`), { + hint: `Configured tools: ${(config.tools ?? []).map((t: any) => t.name).join(', ') || '(none)'}`, + }) + } + tool.on = on + }) + return toToolResult({ ...out, tool: toolName, on, refresh: RELOAD_NOTE }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + ] +} +``` + +- [ ] **Step 4: Register in `mcp/src/index.ts`** — `import { makeEditTools } from './tools/edit.js'` and add `...makeEditTools(client)` to the tools array. + +- [ ] **Step 5: Run tests + build** + +Run: `cd mcp && npx vitest run tests/edit.spec.ts && npm run build` +Expected: PASS (7 tests); build clean. + +- [ ] **Step 6: Commit** + +```bash +git add mcp/src/tools/edit.ts mcp/src/index.ts mcp/tests/edit.spec.ts +git commit -m "Add live config editing tools" +``` + +--- + +### Task 4: Admin tools — missions + geodatasets + +**Files:** +- Modify: `mcp/src/tools/admin.ts` +- Test: `mcp/tests/admin.spec.ts` (extend) + +**Interfaces:** +- Consumes: `MmgisClient` methods from Task 1; `ToolDef` helpers; `MMGISError`. +- Produces: `makeAdminTools(client)` additionally returns `mission_clone`, `mission_delete`, `geodataset_list`, `geodataset_ingest`, `geodataset_delete` (existing `mission_list`/`mission_get` unchanged). Internal helper `needsConfirmation(preview: object)` → `toToolResult({needsConfirmation: true, ...preview})`. + +- [ ] **Step 1: Write the failing tests** — append to `mcp/tests/admin.spec.ts` (reuse its `parse`; note its existing fakeClient only has list/get — build local fakes per test): + +```ts + it('mission_clone calls the clone endpoint', async () => { + const client = { cloneMission: vi.fn(async () => ({ status: 'success', mission: 'B', version: 0 })) } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + const out = parse(await t.mission_clone.handler({ fromMission: 'A', toMission: 'B' })) + expect(out.mission).toBe('B') + expect(client.cloneMission).toHaveBeenCalledWith('A', 'B') + }) + + it('mission_delete requires confirm and previews first', async () => { + const client = { destroyMission: vi.fn(async () => ({ message: 'Successfully Deleted Mission: A' })) } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + const preview = parse(await t.mission_delete.handler({ missionName: 'A' })) + expect(preview.needsConfirmation).toBe(true) + expect(client.destroyMission).not.toHaveBeenCalled() + const done = parse(await t.mission_delete.handler({ missionName: 'A', confirm: true })) + expect(done.message).toContain('Deleted') + expect(client.destroyMission).toHaveBeenCalledWith('A') + }) + + it('geodataset_list returns entries', async () => { + const client = { geodatasetEntries: vi.fn(async () => [{ name: 'g1', num_features: 5 }]) } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + expect(parse(await t.geodataset_list.handler({})).geodatasets).toEqual([{ name: 'g1', num_features: 5 }]) + }) + + it('geodataset_ingest accepts inline FeatureCollections and rejects bad shapes', async () => { + const client = { geodatasetRecreate: vi.fn(async () => ({ status: 'success' })) } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + const fc = { type: 'FeatureCollection', features: [{ type: 'Feature', geometry: null, properties: {} }] } + const out = parse(await t.geodataset_ingest.handler({ name: 'g1', geojson: fc })) + expect(out.name).toBe('g1') + expect(out.features).toBe(1) + expect(client.geodatasetRecreate).toHaveBeenCalledWith('g1', fc) + const bad = await t.geodataset_ingest.handler({ name: 'g1', geojson: { type: 'Point' } }) + expect(bad.isError).toBe(true) + }) + + it('geodataset_ingest fetches from a url with a size cap', async () => { + const fc = { type: 'FeatureCollection', features: [] } + const fetcher = vi.fn(async () => ({ + ok: true, headers: { get: () => null }, text: async () => JSON.stringify(fc), + })) as any + const client = { geodatasetRecreate: vi.fn(async () => ({ status: 'success' })) } as any + const t = Object.fromEntries(makeAdminTools(client, fetcher).map((x) => [x.name, x])) + const out = parse(await t.geodataset_ingest.handler({ name: 'g2', url: 'https://x/y.geojson' })) + expect(out.features).toBe(0) + expect(fetcher).toHaveBeenCalledWith('https://x/y.geojson') + const big = vi.fn(async () => ({ ok: true, headers: { get: () => String(30 * 1024 * 1024) }, text: async () => '' })) as any + const t2 = Object.fromEntries(makeAdminTools(client, big).map((x) => [x.name, x])) + expect((await t2.geodataset_ingest.handler({ name: 'g3', url: 'https://x/big.geojson' })).isError).toBe(true) + }) + + it('geodataset_delete requires confirm', async () => { + const client = { geodatasetRemove: vi.fn(async () => ({ message: "Successfully deleted geodataset 'g1'." })) } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + expect(parse(await t.geodataset_delete.handler({ name: 'g1' })).needsConfirmation).toBe(true) + expect(client.geodatasetRemove).not.toHaveBeenCalled() + parse(await t.geodataset_delete.handler({ name: 'g1', confirm: true })) + expect(client.geodatasetRemove).toHaveBeenCalledWith('g1') + }) +``` + +Also update the existing `'exposes mission_list and mission_get'` test's expected name list to the new full sorted set: `['geodataset_delete', 'geodataset_ingest', 'geodataset_list', 'mission_clone', 'mission_delete', 'mission_get', 'mission_list']`. Add `import { vi } from 'vitest'` if absent. + +- [ ] **Step 2: Run to verify the new tests fail** + +Run: `cd mcp && npx vitest run tests/admin.spec.ts` +Expected: FAIL. + +- [ ] **Step 3: Implement in `mcp/src/tools/admin.ts`** — change the signature to `makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetch)` and append after `mission_get`: + +```ts +const MAX_GEOJSON_BYTES = 20 * 1024 * 1024 + +function isFeatureCollection(v: any): boolean { + return v != null && v.type === 'FeatureCollection' && Array.isArray(v.features) +} +``` + +and the tools: + +```ts + { + name: 'mission_clone', + description: 'Clone an existing mission (dashboard) to a new name.', + schema: { + fromMission: z.string().describe('Existing mission to copy'), + toMission: z.string().describe('Name for the new mission'), + }, + handler: async ({ fromMission, toMission }: any) => { + try { + const out = await client.cloneMission(fromMission, toMission) + return toToolResult({ mission: toMission, ...out }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + { + name: 'mission_delete', + description: 'DESTRUCTIVE: delete a mission and all its config versions. Requires confirm: true — without it returns a preview. Always show the preview to the user and get their explicit yes first.', + schema: { + missionName: z.string(), + confirm: z.boolean().optional().describe('Must be true to actually delete'), + }, + handler: async ({ missionName, confirm }: any) => { + try { + if (confirm !== true) { + return toToolResult({ + needsConfirmation: true, + wouldDelete: `Mission "${missionName}" and every config version of it (the Missions/ folder is renamed, not erased).`, + }) + } + return toToolResult(await client.destroyMission(missionName)) + } catch (err) { + return toErrorResult(err) + } + }, + }, + { + name: 'geodataset_list', + description: 'List geodatasets (uploaded vector datasets) and which missions use them.', + schema: {}, + handler: async () => { + try { + return toToolResult({ geodatasets: await client.geodatasetEntries() }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + { + name: 'geodataset_ingest', + description: 'Create or replace a geodataset from GeoJSON — inline `geojson` OR a `url` to fetch (max 20MB). Use it in a layer with type "vector" and url "geodatasets:".', + schema: { + name: z.string().describe('Geodataset name'), + geojson: z.record(z.any()).optional().describe('Inline GeoJSON FeatureCollection'), + url: z.string().optional().describe('URL of a GeoJSON file to fetch'), + }, + handler: async ({ name, geojson, url }: any) => { + try { + if (!geojson === !url) { + return toErrorResult(new MMGISError('Provide exactly one of geojson or url')) + } + let data = geojson + if (url) { + const res = await fetchFn(url) + if (!res.ok) throw new MMGISError(`Fetch failed (${res.status}) for ${url}`) + const len = Number(res.headers.get('content-length') || 0) + if (len > MAX_GEOJSON_BYTES) throw new MMGISError(`File too large (${len} bytes; max ${MAX_GEOJSON_BYTES})`) + const text = await res.text() + if (text.length > MAX_GEOJSON_BYTES) throw new MMGISError(`File too large (max ${MAX_GEOJSON_BYTES} bytes)`) + try { + data = JSON.parse(text) + } catch { + throw new MMGISError(`${url} is not valid JSON`) + } + } + if (!isFeatureCollection(data)) { + return toErrorResult(new MMGISError('GeoJSON must be a FeatureCollection with a features array')) + } + await client.geodatasetRecreate(name, data) + return toToolResult({ name, features: data.features.length, layerUrl: `geodatasets:${name}` }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + { + name: 'geodataset_delete', + description: 'DESTRUCTIVE: delete a geodataset and its data table. Requires confirm: true — without it returns a preview. Get the user\'s explicit yes first.', + schema: { name: z.string(), confirm: z.boolean().optional() }, + handler: async ({ name, confirm }: any) => { + try { + if (confirm !== true) { + return toToolResult({ needsConfirmation: true, wouldDelete: `Geodataset "${name}" and its feature table. Layers referencing geodatasets:${name} will break.` }) + } + return toToolResult(await client.geodatasetRemove(name)) + } catch (err) { + return toErrorResult(err) + } + }, + }, +``` + +Add `import { MMGISError } from '../mmgisClient.js'` if not present. Update `mcp/src/index.ts` only if the `makeAdminTools` call needs no change (it doesn't — `fetchFn` defaults). + +- [ ] **Step 4: Run tests + build** + +Run: `cd mcp && npx vitest run tests/admin.spec.ts && npm run build` +Expected: PASS; build clean. + +- [ ] **Step 5: Commit** + +```bash +git add mcp/src/tools/admin.ts mcp/tests/admin.spec.ts +git commit -m "Add mission and geodataset admin tools with confirmation gating" +``` + +--- + +### Task 5: User tools + minimal backend token support for signup (FLAGGED backend change) + +**Files:** +- Modify: `mcp/src/tools/admin.ts` (three user tools) +- Modify: `scripts/server.js` (expose a non-blocking token-annotation middleware) +- Modify: `API/Backend/Users/setup.js` (mount it) +- Modify: `API/Backend/Users/routes/users.js` (extend the signup gate) +- Test: `mcp/tests/admin.spec.ts` (extend) + +**Interfaces:** +- Consumes: `accountEntries`, `accountUpdate`, `userSignup` (Task 1). +- Produces: `user_list`, `user_create`, `user_set_permission` tools. Backend: `s.annotateLongTermToken` middleware — validates an `Authorization` long-term token if present and sets `req.isLongTermToken`/`req.tokenUserPermission`, ALWAYS calls `next()` (never rejects); signup's SuperAdmin gate additionally accepts `req.isLongTermToken === true && req.tokenUserPermission === '111'`. + +**⚠️ FLAG FOR STAKEHOLDER:** this task changes backend auth surface (annotation middleware on `/api/users` + widened signup gate). It is strictly additive — requests without an Authorization header behave exactly as before — and mirrors the pattern already accepted for `/configure/add`. + +- [ ] **Step 1: Write the failing MCP tests** — append to `mcp/tests/admin.spec.ts`: + +```ts + it('user_list returns account entries without passwords', async () => { + const client = { accountEntries: vi.fn(async () => [{ id: 1, username: 'admin', permission: '111' }]) } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + expect(parse(await t.user_list.handler({})).users).toEqual([{ id: 1, username: 'admin', permission: '111' }]) + }) + + it('user_create requires confirm, calls signup, and never echoes the password', async () => { + const client = { userSignup: vi.fn(async () => ({ status: 'success', username: 'alice' })) } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + const preview = parse(await t.user_create.handler({ username: 'alice', password: 'Str0ng!Pass' })) + expect(preview.needsConfirmation).toBe(true) + expect(client.userSignup).not.toHaveBeenCalled() + const res = await t.user_create.handler({ username: 'alice', password: 'Str0ng!Pass', confirm: true }) + expect(res.content[0].text).not.toContain('Str0ng!Pass') + expect(parse(res).username).toBe('alice') + expect(client.userSignup).toHaveBeenCalledWith('alice', 'Str0ng!Pass') + }) + + it('user_set_permission resolves username to id and requires confirm', async () => { + const client = { + accountEntries: vi.fn(async () => [{ id: 1, username: 'admin', permission: '111' }, { id: 2, username: 'bob', permission: '001' }]), + accountUpdate: vi.fn(async () => ({ status: 'success' })), + } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + const preview = parse(await t.user_set_permission.handler({ username: 'bob', permission: '110', missionsManaging: ['Demo'] })) + expect(preview.needsConfirmation).toBe(true) + await t.user_set_permission.handler({ username: 'bob', permission: '110', missionsManaging: ['Demo'], confirm: true }) + expect(client.accountUpdate).toHaveBeenCalledWith({ id: 2, permission: '110', missionsManaging: ['Demo'] }) + const unknown = await t.user_set_permission.handler({ username: 'nope', permission: '001', confirm: true }) + expect(unknown.isError).toBe(true) + }) +``` + +Update the tool-name list test again to include `user_create`, `user_list`, `user_set_permission` (full sorted set of 10 admin tools). + +- [ ] **Step 2: Run to verify failure** + +Run: `cd mcp && npx vitest run tests/admin.spec.ts` +Expected: FAIL. + +- [ ] **Step 3: Implement the three tools in `mcp/src/tools/admin.ts`** + +```ts + { + name: 'user_list', + description: 'List MMGIS user accounts (id, username, permission: 111=SuperAdmin, 110=Admin, 001=Viewer).', + schema: {}, + handler: async () => { + try { + return toToolResult({ users: await client.accountEntries() }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + { + name: 'user_create', + description: "Create a user account (created as Viewer '001'; use user_set_permission to promote to Admin '110'). Password needs 8+ chars with upper, lower, number, symbol. Requires confirm: true after the user agrees. Never repeat the password back in chat.", + schema: { + username: z.string(), + password: z.string().describe('8+ chars with upper, lower, number, symbol'), + confirm: z.boolean().optional(), + }, + handler: async ({ username, password, confirm }: any) => { + try { + if (confirm !== true) { + return toToolResult({ needsConfirmation: true, wouldCreate: `User "${username}" with Viewer (001) permission.` }) + } + const out = await client.userSignup(username, password) + return toToolResult({ username: out.username ?? username, created: true }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + { + name: 'user_set_permission', + description: "Change a user's permission: '110' (Admin, optionally with missionsManaging list) or '001' (Viewer). SuperAdmin (111) cannot be granted, and user id 1 cannot be changed (backend rules). Requires confirm: true.", + schema: { + username: z.string(), + permission: z.enum(['110', '001']), + missionsManaging: z.array(z.string()).optional().describe("Missions an Admin ('110') manages"), + confirm: z.boolean().optional(), + }, + handler: async ({ username, permission, missionsManaging, confirm }: any) => { + try { + if (confirm !== true) { + return toToolResult({ needsConfirmation: true, wouldChange: `Set "${username}" permission to ${permission}${missionsManaging ? ` managing [${missionsManaging.join(', ')}]` : ''}.` }) + } + const users = await client.accountEntries() + const user = users.find((u: any) => u.username === username) + if (!user) { + return toErrorResult(Object.assign(new Error(`Unknown user: ${username}`), { hint: `Users: ${users.map((u: any) => u.username).join(', ')}` })) + } + await client.accountUpdate({ id: user.id, permission, ...(missionsManaging ? { missionsManaging } : {}) }) + return toToolResult({ username, permission, ...(missionsManaging ? { missionsManaging } : {}) }) + } catch (err) { + return toErrorResult(err) + } + }, + }, +``` + +- [ ] **Step 4: Run MCP tests + build** + +Run: `cd mcp && npx vitest run tests/admin.spec.ts && npm run build` +Expected: PASS; build clean. + +- [ ] **Step 5: Backend — token annotation (read each file first, match local style)** + +(a) `scripts/server.js`: find where the shared `s` object gets `ensureAdmin` (search `ensureAdmin`). Nearby, add and expose a non-blocking annotator that reuses the existing `validateLongTermToken` (defined ~line 391): + +```js +function annotateLongTermToken(req, res, next) { + if (req.headers.authorization) { + validateLongTermToken( + req.headers.authorization, + (tokenData) => { + req.isLongTermToken = true; + req.tokenUserPermission = tokenData.permission; + req.tokenUserMissions = tokenData.missions_managing; + req.user = tokenData.username; + next(); + }, + () => next() + ); + } else next(); +} +``` + +Attach it wherever `s.ensureAdmin = ensureAdmin` (or equivalent) happens: `s.annotateLongTermToken = annotateLongTermToken`. (Check `validateLongTermToken`'s callback signature at its definition — success and failure callbacks — and match exactly.) + +(b) `API/Backend/Users/setup.js`: read it; add `s.annotateLongTermToken` into the middleware chain for the users router mount (before the router, after `checkHeadersCodeInjection`). + +(c) `API/Backend/Users/routes/users.js` signup gate (~lines 82-96): read the exact conditions; extend each `req.session.permission !== "111"` check that gates admin-only creation with `&& !(req.isLongTermToken === true && req.tokenUserPermission === "111")` so a SuperAdmin token passes. Touch ONLY the signup gate; leave `first_signup` and all other routes unchanged. + +No automated backend test harness exists for these routes (verified in the Phase 1 work) — verification is the live E2E in Task 7. Keep the change minimal and quote it fully in your report. + +- [ ] **Step 6: Commit** + +```bash +git add mcp/src/tools/admin.ts mcp/tests/admin.spec.ts scripts/server.js API/Backend/Users/setup.js API/Backend/Users/routes/users.js +git commit -m "Add user admin tools and honor SuperAdmin tokens in signup" +``` + +--- + +### Task 6: `view_reload` bridge command + +**Files:** +- Modify: `src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js` +- Modify: `src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js` +- Modify: `mcp/src/tools/view.ts` +- Test: `tests/unit/agentBridgeCommands.spec.js` (extend) + +**Interfaces:** +- Consumes: existing `executeCommand(command, args, deps)` switch; existing `makeViewTools(bridge)`. +- Produces: command `'reload'` → calls injected `deps.reload()` and returns `{ok: true, result: {reloading: true}}` (the ack races the page unload — the MCP tool treats a timeout after a successful send as acceptable); AgentBridge wires `reload: () => window.location.reload()` into deps; MCP tool `view_reload(mission)`. + +- [ ] **Step 1: Extend the frontend test** — add to `tests/unit/agentBridgeCommands.spec.js` (add `reload: vi.fn()` to `makeDeps()`'s returned object first): + +```js + it('reload calls the injected reload and reports', async () => { + const deps = makeDeps() + const res = await executeCommand('reload', {}, deps) + expect(res.ok).toBe(true) + expect(res.result).toEqual({ reloading: true }) + expect(deps.reload).toHaveBeenCalled() + }) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run tests/unit/agentBridgeCommands.spec.js` (repo root) +Expected: FAIL — Unknown command: reload. + +- [ ] **Step 3: Implement** — in `commands.js` add a case before `default`: + +```js + case 'reload': { + deps.reload() + return { ok: true, result: { reloading: true } } + } +``` + +In `AgentBridge.js`, add to the deps object passed to `executeCommand` (alongside Map_, L_, ...): `reload: () => window.location.reload(),` — send the ack BEFORE calling executeCommand for this command? No: keep flow unchanged (ack after execute); `window.location.reload()` does not interrupt the synchronous send that follows in the same tick, and if the ack is lost the MCP tool's message will time out — acceptable, documented in the tool description. + +In `mcp/src/tools/view.ts` add to the returned array: + +```ts + { + name: 'view_reload', + description: 'Reload an open browser session so non-layer config changes (basemap, page name, tools) take effect. A timeout after sending can mean the page reloaded before acking — treat that as success if view_get_state works afterwards.', + schema: { mission }, + handler: ({ mission }: any) => run(mission, 'reload', {}), + }, +``` + +- [ ] **Step 4: Run tests + builds** + +Run: `npx vitest run tests/unit/agentBridgeCommands.spec.js && cd mcp && npm run build && npm test` +Expected: all pass (frontend spec now 12 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/essence/MMGIS-Plugin-Components/AgentBridge tests/unit/agentBridgeCommands.spec.js mcp/src/tools/view.ts +git commit -m "Add view_reload bridge command for applying non-layer config changes" +``` + +--- + +### Task 7: System prompt, docs, sweep + live E2E + +**Files:** +- Modify: `chat/lib/agentLoop.js` (SYSTEM_PROMPT additions) +- Modify: `mcp/README.md` (tools + security notes) +- Modify: `chat/README.md` (what-you-can-do + E2E checklist additions) + +- [ ] **Step 1: Extend `SYSTEM_PROMPT` in `chat/lib/agentLoop.js`** — append these lines inside the template string (before the final "Be concise" line): + +``` +- Editing existing dashboards: prefer layer_add/layer_update/layer_remove and tool_toggle (these apply LIVE in open sessions). For anything else use mission_update_config with a JSON merge-patch (null deletes a key; arrays replace) — then call view_reload so an open session shows the change. +- DESTRUCTIVE tools (mission_delete, geodataset_delete, user_create, user_set_permission) return needsConfirmation first. Show the user exactly what will happen, get their explicit yes, then retry with confirm: true. Never set confirm on your own. +- Geodata: ingest GeoJSON with geodataset_ingest (inline for small data, url for hosted files), then add a layer with type "vector" and url "geodatasets:". +- User management: new users start as Viewer (001); promote with user_set_permission (110 Admin / 001 Viewer; SuperAdmin cannot be granted). Never repeat passwords back. +``` + +Run `cd chat && npm test` — the agentLoop tests assert the system prompt is message[0] but not its content, so all 25 should still pass. + +- [ ] **Step 2: Update `mcp/README.md`** — Tools section adds: + +```markdown +- `mission_update_config`, `layer_add`, `layer_update`, `layer_remove`, `tool_toggle` — live config editing (layer changes auto-apply in open sessions; others need one RELOAD click or `view_reload`) +- `mission_clone`, `mission_delete`†, `geodataset_list`, `geodataset_ingest`, `geodataset_delete`†, `user_list`, `user_create`†, `user_set_permission`† — admin operations († = requires `confirm: true` after a preview) +- `view_reload` — reload an open session to apply non-layer config changes +``` + +Security notes add: "`/api/configure/clone` and `/destroy` have no per-permission check upstream — ANY valid long-term token can invoke them (pre-existing MMGIS behavior); scope who gets tokens accordingly. `mission_clone` shells out to a `python` binary on the MMGIS server; hosts with only `python3` will see clone failures." + +- [ ] **Step 3: Update `chat/README.md`** — "What you can do" adds editing examples ("make the OSM layer 50% transparent", "rename the page to Flood Watch then reload the view", "upload this GeoJSON and add it as a layer", "delete the JSON Demo mission" → confirmation round-trip). Manual E2E checklist adds: + +```markdown +- [ ] layer_update from chat visibly changes an open dashboard WITHOUT reloading (e.g. opacity) +- [ ] mission_update_config + view_reload applies a basemap/page-name change +- [ ] geodataset_ingest (inline) → layer_add with geodatasets: renders the data +- [ ] mission_delete asks for confirmation in chat before acting +- [ ] user_create + user_set_permission work with the long-term token (exercises the flagged backend change) +- [ ] mission_clone (may fail if the MMGIS host lacks a `python` binary — record outcome) +``` + +- [ ] **Step 4: Full verification sweep** + +Run: `cd mcp && npm test && npm run build && cd ../chat && npm test && cd .. && npx vitest run` +Expected: all green. + +- [ ] **Step 5: Commit** + +```bash +git add chat/lib/agentLoop.js mcp/README.md chat/README.md +git commit -m "Teach the chat agent config editing and admin workflows" +``` + +- [ ] **Step 6 (controller): live E2E** — with the running deployment + chat: walk the new checklist items end-to-end; especially verify (a) live layer refresh without reload, (b) the flagged signup-token backend change, (c) whether `python` exists for clone. Fix deviations before closing. + +--- + +## Spec coverage map + +| Spec requirement | Task | +| --- | --- | +| `mission_update_config` (RFC 7386, backend validation, live refresh contract) | 2, 3 | +| `layer_add`/`layer_update`/`layer_remove` (find by name/uuid, layer-typed info → auto-apply) | 2, 3 | +| `tool_toggle` | 3 | +| `mission_clone`, `mission_delete` (+confirm) | 1, 4 | +| `geodataset_list`/`ingest` (inline+url, 20MB cap, FeatureCollection validation)/`delete` (+confirm) | 1, 4 | +| `user_list`, `user_create` (+confirm, password never echoed), `user_set_permission` (+confirm) | 1, 5 | +| Flagged minimal backend change for session-only signup | 5 (annotation middleware + gate extension) | +| forceClientUpdate live refresh + view_reload gap-closer | 1, 2, 3, 6 | +| System prompt confirmation protocol + workflows | 7 | +| Testing: unit merge-patch table, confirmation gating, ingest validation; live E2E checklist | 2-7 | +| Non-goals respected (no new endpoints beyond flagged guard; no UI attach; last-write-wins documented) | all | + +Spec deviations locked in by research (documented in Global Constraints): only layer-typed events auto-apply (hence `view_reload`); `user_set_permission` cannot grant 111 and cannot change user id 1; user creation lands as 001 then promote; `mission_clone` python dependency; clone/destroy lack upstream per-permission checks (README security note). diff --git a/docs/superpowers/specs/2026-07-22-agentic-mmgis-design.md b/docs/superpowers/specs/2026-07-22-agentic-mmgis-design.md new file mode 100644 index 000000000..d316f48a7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-agentic-mmgis-design.md @@ -0,0 +1,184 @@ +# Agentic MMGIS — Design + +**Date:** 2026-07-22 +**Status:** Approved by stakeholder (brainstorming session) +**Branch:** `feature/agentic-mmgis` + +## Purpose + +Let AI agents drive MMGIS and let users build MMGIS dashboards — and eventually +plugins — from natural language. Three capabilities, built in phases on one +umbrella architecture: + +1. **Drive MMGIS via an agent** — backend administration (missions, layers, + geodatasets) plus live control of the map in a connected browser session. +2. **NL → dashboard setup** — a user describes a dashboard ("air-quality + dashboard for the southeastern US"); the agent finds data, authors a mission + profile, and generates the mission config. +3. **NL → plugin creation** — the agent scaffolds complete new plugins from a + description; hot-reload on dev instances, PR review to reach shared + deployments. + +## Decisions Made + +| Question | Decision | +| --- | --- | +| Scope | All three capabilities, phased, one umbrella architecture | +| Agent surface | MCP server first; in-app chat can be layered on later reusing the same tools | +| Drive scope | Backend/config plane AND live browser control | +| Data sourcing for generated dashboards | Deployment-local geodatasets/layers first, plus external catalog search (STAC, NASA CMR) | +| Generated-plugin trust model | No sandbox. Hot-reload live on dev-mode instances (requester's own instance); promotion to shared deployments only via normal git/PR review | +| Milestone 1 | End-to-end NL dashboard demo touching all three subsystems shallowly | +| Integration approach | Approach A: standalone MCP server + WebSocket agent bridge (vs. embedding MCP in the Express backend, or driving via browser automation) | + +**Why Approach A:** it obeys the vision's decoupling principle (the agent +surface is an external service, not more monolith), the browser bridge dogfoods +the Plugin-Components system (spec 011), and every piece — REST client, bridge, +generators — is independently testable. The same MCP tools later power an +in-app chat client. + +## Architecture + +``` +┌─────────────────┐ MCP (stdio / HTTP) ┌──────────────────────┐ +│ Claude Code / │◄──────────────────────►│ mcp/ (new pkg) │ +│ Desktop / any │ │ MMGIS MCP Server │ +│ MCP client │ └──────┬───────┬───────┘ +└─────────────────┘ REST │ │ WebSocket + +token│ │ "agent-bridge" room + ┌────────▼───┐ ┌─▼──────────────────┐ + │ MMGIS API │ │ MMGIS frontend │ + │ (Express) │ │ AgentBridge │ + └────────────┘ │ Plugin-Component │ + └────────────────────┘ +``` + +New surface area: + +- **`mcp/`** — standalone TypeScript MCP server (own `package.json`, + `@modelcontextprotocol/sdk`). Transports: stdio (Claude Code/Desktop) and + streamable HTTP (remote clients). Configured with `MMGIS_URL` + + `MMGIS_TOKEN`. Runs beside any MMGIS deployment. +- **`AgentBridge` Plugin-Component** in `src/essence/` — subscribes to a new + `agent-bridge` WebSocket message type, executes whitelisted view commands, + reports results and view state. +- **Backend WebSocket routing** — one new `agent-bridge` case in + `API/Backend/APIs/Websocket.js`, mirroring the existing Draw-sync broadcast + pattern (authenticate → validate payload → relay within mission room → + rate-limit). + +## Phases + +- **Phase 1 — Drive + NL dashboard (milestone demo).** MCP tools for + missions/layers/geodatasets CRUD; dashboard generation via + `scripts/generate-mission-config.js`; STAC/CMR catalog search; ~5 browser + commands (`fly_to`, `toggle_layer`, `open_tool`, `set_time`, + `get_view_state`). Demo: "set up an air-quality dashboard for the + southeastern US" → mission appears → agent flies the map to it. +- **Phase 2 — Deep control + ingestion.** Richer browser control (draw, query, + screenshots for agent vision), data upload/ingestion tools, multi-session + targeting. +- **Phase 3 — NL plugin generation.** `plugin_scaffold` emits complete + Tool/Component plugins from templates; hot-reload on dev instances; + `plugin_promote` opens a branch/PR for shared deployments. + +Each phase ships independently; nothing in Phase 1 blocks on Phase 3 +decisions. + +## Components + +### 1. MCP server (`mcp/`) — four tool namespaces + +- **Admin plane** (`mission_*`, `layer_*`, `geodataset_*`): thin, validated + wrappers over the existing REST API (the endpoints Configure already calls). + No new backend endpoints in Phase 1. +- **Dashboard generation** (`dashboard_*`): `dashboard_generate(profile)` + validates a mission-profile JSON (the `mission-profiles/*.json` format), runs + the config generator, and installs the result as a mission. The natural + language work deliberately lives in the **client LLM, not our code**: tool + descriptions plus a `get_profile_schema` tool teach the model to author a + profile from the user's description. The server ships deterministic, + LLM-free, testable tools. +- **Catalog search** (`catalog_*`): `search_stac(query, bbox, datetime)` and + `search_cmr(...)` against configurable public endpoints, returning candidate + layers in a shape that plugs directly into a profile's layer list (tile URL + templates, GeoJSON assets). External services stay external — pure URL/API + integration, per the vision. +- **Browser control** (`view_*`): publish commands onto the agent bridge and + await acknowledgment. + +### 2. AgentBridge Plugin-Component + +A small component (`init()` only, per spec 011) that joins the `agent-bridge` +room for its mission. It executes a fixed whitelist of commands against +internal APIs (`Map_`, `L_`, `ToolController_`) — never evaluates payload code +— and answers each command with `{ok/error, viewState}`. It announces session +presence so the MCP server can report connected browsers per mission. + +### 3. WebSocket routing (backend) + +New `agent-bridge` message type in `Websocket.js` following the Draw pattern: +authenticate, validate message shape, relay between MCP server connections and +browser sessions in the same mission room, rate-limited (constitution VII). + +### 4. Plugin generator (Phase 3) + +`plugin_scaffold(spec)` renders a complete plugin directory from templates +(existing Tool/Component skeletons + SDK conventions) including `config.json`, +entry module, and a smoke test. Dev-mode webpack hot-reload picks it up live; +`plugin_promote` creates a branch + commit for PR review. Templates are code we +write and test once; the agent only fills declared slots. + +## Milestone Data Flow + +1. Claude calls `catalog_search` → candidate datasets with tile/asset URLs. +2. Claude authors a mission profile (guided by `get_profile_schema` and tool + docs) → `dashboard_generate` validates it, runs the config generator, + creates/updates the mission via REST. +3. Claude calls `view_fly_to` / `view_toggle_layer` → MCP server publishes to + the `agent-bridge` room → AgentBridge executes → ack + fresh view state + returns to the model. + +## Error Handling + +- Every MCP tool returns structured errors (`{error, detail, hint}`) — never + stack traces — so the model can self-correct (e.g., "profile invalid: layer 3 + missing `url`"). +- Profile validation happens before any mission is touched; generation is + atomic (write to temp, install on success). +- Browser commands time out (5s); "no connected sessions" is distinguished from + "command failed". +- Catalog searches degrade gracefully: endpoint down → tool reports it and + suggests deployment-local data. + +## Security + +- MCP server authenticates to MMGIS with a long-lived API token (existing token + support in `User.js`); it holds the privileges of the issuing account. +- The agent bridge carries only whitelisted command names + JSON args; the + frontend component validates against a schema before executing. No code, no + selectors, no eval. +- WebSocket messages authenticated and rate-limited per constitution VII. +- Generated plugins: hot-reload only on dev-mode instances; shared deployments + only via reviewed PRs. A sandboxed plugin runtime is explicitly out of scope + and only reconsidered if an unreviewed third-party marketplace install flow + is ever wanted. + +## Testing + +- **Unit (Jest, existing setup):** each MCP tool against a mocked REST API; + profile validation; bridge command schema validation; plugin template + rendering. +- **Integration:** MCP server against a real dev deployment (the + `mmgis-deployment` skill's docker setup) — create mission, generate + dashboard, assert config in DB. +- **E2E (Playwright; CI workflow exists):** connect a browser, send + `view_fly_to` through the real bridge, assert the map moved. +- **Coverage target:** 80% per constitution. + +## Out of Scope / Non-Goals + +- In-app chat UI (future; will reuse these MCP tools). +- Sandboxed plugin runtime. +- Operating external data services (STAC/CMR/TiTiler) — integration only. +- Unreviewed plugin installation into shared/production deployments. diff --git a/docs/superpowers/specs/2026-07-24-chat-ui-design.md b/docs/superpowers/specs/2026-07-24-chat-ui-design.md new file mode 100644 index 000000000..aed4a6928 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-chat-ui-design.md @@ -0,0 +1,146 @@ +# MMGIS Chat UI — Design + +**Date:** 2026-07-24 +**Status:** Approved approach (A) by stakeholder; spec pending review +**Branch:** `feature/agentic-mmgis` +**Purpose:** A standalone chat web app the stakeholder uses to *visually test* the +agentic MMGIS capabilities with their own OpenAI key — type a request, watch the +MCP tools fire, open the resulting dashboard, and see live view control. + +## Decisions Made + +| Question | Decision | +| --- | --- | +| Placement | Standalone page (own port), opened beside the MMGIS tab — not embedded in MMGIS yet | +| Location | Self-contained `chat/` folder in this repo, with its own `package.json` and `.env`; designed to be lifted out later as its own deployable | +| OpenAI key | Lives in `chat/.env` (`OPENAI_API_KEY`), server-side only, gitignored; `.env.example` committed | +| Tool execution | Approach A: the chat backend is an **MCP client** of the existing `mcp/dist/index.js` over stdio — same 13 tools, auto-discovered, zero duplicated logic | +| Frontend | Single static page (vanilla HTML/CSS/JS, no build step), SSE streaming | +| Primary user | The stakeholder testing the feature; demo-grade polish, not production chat | + +## Architecture + +``` +Browser (chat UI, static) chat/ server (Express, Node 20) mcp/dist/index.js +┌──────────────────────┐ SSE ┌──────────────────────────────┐ stdio ┌──────────────┐ +│ conversation state │◄──────│ POST /api/chat │◄───────►│ MMGIS MCP │ +│ tool-call cards │──────►│ agent loop: OpenAI chat │ MCP │ server │ +│ "Open dashboard →" │ fetch │ completions + function calls │ │ (13 tools) │ +└──────────────────────┘ │ GET /api/health, /api/tools │ └──────┬───────┘ + └──────────────┬───────────────┘ │ REST+WS + OpenAI API (key from .env) MMGIS server +``` + +- The chat server spawns the MCP server once at startup (`StdioClientTransport`, + command/env configurable) and keeps the client connected; `listTools()` output is + converted to OpenAI function schemas (name, description, JSON-schema parameters — + the MCP SDK already serializes zod shapes to JSON schema). +- Conversation state lives entirely in the browser (a `messages` array resent per + request). The server is stateless per request — no sessions, no DB. + +## Components + +### `chat/server.js` — Express app +- `GET /` serves `public/`. +- `GET /api/health` → `{ok, model, mcpConnected, toolCount}`. +- `GET /api/tools` → the discovered tool list (names + descriptions) so the UI can + render a capabilities sidebar. +- `POST /api/chat` body `{messages: [...]}` → SSE stream of events (below). Runs the + agent loop: call OpenAI with tools; on `tool_calls`, execute each via the MCP + client, append tool results, loop (max 15 iterations as a runaway guard); stream + assistant text deltas as they arrive. + +### `chat/lib/mcpBridge.js` +- `connect(config)` → spawns/attaches the MCP client; `getOpenAiTools()` → cached + OpenAI `tools` array; `callTool(name, args)` → `{text, isError}`. +- Isolated so tests can inject a fake bridge. + +### `chat/lib/agentLoop.js` +- `runAgentLoop({messages, openai, bridge, model, onEvent})` — pure orchestration, + no Express/SSE knowledge; emits events via `onEvent`. Unit-testable with mocked + OpenAI + bridge. + +### `chat/public/` — the UI +- `index.html`, `app.js`, `style.css`. Chat transcript; streaming assistant text; + each tool call rendered as a collapsible card (name, args, result JSON, error + styling for `isError`); any `url` field in a successful tool result becomes an + "Open dashboard →" button (`target="_blank"`); status strip showing model + + MCP connection from `/api/health`; conversation kept in `localStorage` so a + reload doesn't lose it; "New chat" button clears it. + +### SSE event protocol (one `data:` JSON per event) +- `{type: 'text', delta}` — assistant token(s) +- `{type: 'tool_call', id, name, args}` — model requested a tool +- `{type: 'tool_result', id, name, result, isError}` — bridge answered +- `{type: 'done', usage?}` — turn complete +- `{type: 'error', message}` — fatal turn error (OpenAI/bridge failure) + +### System prompt (server-side constant) +Teaches the workflow: call `dashboard_profile_schema` + `dashboard_tool_options` +before generating; find data via `catalog_*`; always report the mission URL after +`dashboard_generate`; use `view_*` to drive an open browser session; mission names +must avoid punctuation; surface tool `hint`s to the user when self-correction fails. + +## Configuration (`chat/.env`) + +| Var | Default | Purpose | +| --- | --- | --- | +| `OPENAI_API_KEY` | (required) | The stakeholder's key; never sent to the browser | +| `OPENAI_MODEL` | `gpt-4o` | Chat model | +| `CHAT_PORT` | `8895` | Chat app port | +| `MCP_COMMAND` | `node` | MCP server launcher | +| `MCP_ARGS` | `../mcp/dist/index.js` | Relative to `chat/` | +| `MMGIS_URL` / `MMGIS_TOKEN` / `MAPBOX_TOKEN` etc. | — | Passed through into the MCP server's env | + +`chat/.gitignore` covers `.env` and `node_modules/`; `chat/.env.example` documents +every var. Root `.gitignore` needs no change (chat/.env handled locally). + +## Error Handling + +- OpenAI failure (bad key, rate limit) → SSE `error` event → red bubble in chat with + the OpenAI message; conversation preserved so the user can retry. +- MCP tool errors are already `{error, hint}` content — passed through as normal + `tool_result`s (with `isError`) so the model self-corrects visibly in the + transcript; the card renders red. +- MCP process death → bridge reports disconnected on `/api/health`; `/api/chat` + returns an `error` event advising restart; server attempts one reconnect per + request. +- Loop guard: after 15 tool iterations the server injects a final "stop and + summarize" turn and closes the stream. + +## Testing + +- **Unit (vitest, `chat/tests/`)**: `agentLoop` with a scripted fake OpenAI client + (returns tool_calls then text) + fake bridge — asserts event sequence, loop guard, + error paths. `mcpBridge` schema conversion with a fake MCP client. +- **Manual E2E**: real key + running MMGIS deployment; script mirrors the existing + demo — "create an air-quality dashboard over Atlanta", open URL, "fly to + Huntsville", watch the open dashboard move. + +## JSON Config Round-Trip (stakeholder addition, 2026-07-24) + +The stakeholder must be able to *see* the generated mission config JSON and to +*create a dashboard from raw JSON* they edit or paste. Three pieces: + +1. **`dashboard_generate` gains `returnConfig: boolean`** — when true, the tool + result includes the full generated config JSON (so it renders in the chat's + tool-result card and can be copied out). +2. **New MCP tool `dashboard_create_from_config`** — inputs `{missionName, + config, updateExisting?}` where `config` is a complete MMGIS mission config + object. Applies the same mission-name preflight, `{{MAPBOX_TOKEN}}` + resolution, and AgentBridge component injection (only when `config.components` + is absent) as `dashboard_generate`, then installs via the same add→upsert + path (shared helper — no duplicated install logic). +3. **Chat UI JSON drawer** — a collapsible panel with a mission-name input and a + JSON textarea; "Create dashboard from JSON" sends a structured chat message + instructing the model to call `dashboard_create_from_config` with exactly that + JSON, so the action still flows visibly through the agent loop like every + other operation. + +## Non-Goals + +- Not embedded in MMGIS (future: same agent loop behind an in-app panel). +- No auth/multi-user/persistence beyond localStorage; single-operator test harness. +- No Anthropic/other-provider support in v1 (env-swappable later; loop is + provider-thin by design). +- No streaming of tool *results* token-by-token — results arrive whole. diff --git a/docs/superpowers/specs/2026-07-25-chat-full-config-design.md b/docs/superpowers/specs/2026-07-25-chat-full-config-design.md new file mode 100644 index 000000000..d4da8c962 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-chat-full-config-design.md @@ -0,0 +1,115 @@ +# Chat-Driven Full Configuration — Design + +**Date:** 2026-07-25 +**Status:** Approved by stakeholder (brainstorming session) +**Branch:** `feature/agentic-mmgis` +**Purpose:** Configure everything in MMGIS from the chat: incrementally edit +existing dashboards (with open sessions refreshing live), and perform admin +operations (clone/delete missions, manage geodatasets and users) — all as +visible, auditable tool calls. + +## Decisions Made + +| Question | Decision | +| --- | --- | +| Scope | Lean editing core (merge-patch + layer tools + live refresh) PLUS admin operations (mission clone/delete, geodatasets, users) | +| Approach | A: extend the existing MCP server (`mcp/src/tools/edit.ts`, `admin.ts`) over MMGIS's existing REST endpoints; no backend changes expected; chat app untouched (tools auto-appear) | +| Live refresh | Every config mutation passes `forceClientUpdate: true` through `/api/configure/upsert`, riding MMGIS's existing websocket broadcast so open sessions reload config live (AgentBridge not involved) | +| Destructive-op safety | `confirm: true` argument required; without it the tool returns `{needsConfirmation, wouldDelete}` preview; system prompt instructs the model to get the user's explicit yes in chat first | + +## New Tools + +### Editing (`mcp/src/tools/edit.ts`) — all follow get → modify → upsert(forceClientUpdate) + +1. **`mission_update_config(missionName, patch)`** — universal RFC 7386 JSON + merge-patch applied client-side in the MCP server: fetch current config + (`GET /api/configure/get?full=true`), deep-merge (objects merge recursively; + `null` deletes a key; arrays and scalars replace), `POST /upsert` with + `forceClientUpdate: true`. Backend `validate()` + `populateUUIDs` run + server-side, so invalid patches return real error messages the model can + self-correct from. +2. **`layer_add(missionName, layer, position?)`** — insert a layer entry + (uuid minted if absent) into `config.layers` at `position` (default: end). +3. **`layer_update(missionName, layer, patch)`** — find layer by name or uuid, + merge-patch just that entry. +4. **`layer_remove(missionName, layer)`** — remove by name or uuid. +5. **`tool_toggle(missionName, toolName, on)`** — set a tool's `on` flag in + `config.tools`. + +### Admin (`mcp/src/tools/admin.ts`, extending the existing file) — existing REST endpoints + +6. **`mission_clone(fromMission, toMission)`** → `POST /api/configure/clone`. +7. **`mission_delete(missionName, confirm?)`** → `POST /api/configure/destroy`. +8. **`geodataset_list()`** → `POST /api/geodatasets/entries`. +9. **`geodataset_ingest(name, geojson? | url?)`** → `POST /api/geodatasets/recreate/:name`; + exactly one of inline GeoJSON or a URL the MCP server fetches itself + (20 MB response cap; content must parse as a GeoJSON FeatureCollection). +10. **`geodataset_delete(name, confirm?)`** → `DELETE /api/geodatasets/remove/:name`. +11. **`user_list()`**, **`user_create(username, password, permission?, confirm?)`**, + **`user_set_permission(username, permission, confirm?)`** → Users routes. + Exact request/response shapes are verified against `API/Backend/Users` + during planning. If any admin route proves session-only (as + `/configure/add` was), extend its guard for long-term SuperAdmin tokens the + same minimal, pattern-matched way — each such backend change is explicitly + flagged for stakeholder review. + +`MmgisClient` gains corresponding methods; upsert gains a `forceClientUpdate` +parameter (default false to preserve existing callers' behavior; edit tools +pass true). + +## Safety Model + +- Destructive tools: `mission_delete`, `geodataset_delete`, `user_create`, + `user_set_permission`. Without `confirm: true` they return + `{needsConfirmation: true, wouldDelete/wouldChange: }` and do + nothing. The chat system prompt adds: present the preview to the user, get an + explicit yes, then retry with `confirm: true`. The confirmation loop is + visible in the transcript. +- All operations run with the long-term token's privileges (SuperAdmin in the + test deployment) — parity with what the Configure page allows that operator. +- `geodataset_ingest` URL fetches happen in the MCP server with a 20 MB cap and + FeatureCollection validation before anything is sent to MMGIS. +- Passwords for `user_create`: generated by the model/user in chat and passed + through; the tool result echoes the username but never the password. + +## System Prompt Additions (chat) + +Editing workflow (prefer `layer_*`/`tool_toggle` for common edits; +`mission_update_config` for everything else; changes appear live in open +sessions), destructive-op confirmation protocol, and geodataset ingest guidance +(inline for small data, URL for hosted files). + +## Error Handling + +- Merge-patch conflicts surface as backend `validate()` failures with MMGIS's + own messages, passed through as `{error, hint}`. +- `layer_update`/`layer_remove` on an unknown layer → error listing available + layer names. +- Version races (two edits in flight) are tolerated: upsert always writes a new + version on top of latest; last write wins — acceptable for a single-operator + harness, noted in docs. +- URL ingest failures (unreachable, too large, not GeoJSON) → structured error + before MMGIS is touched. + +## Testing + +- Unit (vitest, mocked REST): merge-patch semantics table (nested merge, + null-delete, array replace, scalar replace); layer find-by-name-or-uuid; + confirmation gating (no client call without confirm); ingest validation + (inline vs url, size cap, non-GeoJSON rejection). +- Integration: merge-patch applied to a real generator-produced config; + round-trip through a fake client asserting the exact upsert body incl. + `forceClientUpdate`. +- Live E2E script (extends `chat/README.md` checklist): edit basemap from + chat → open dashboard refreshes without manual reload; clone a mission; + ingest a small GeoJSON and add it as a layer; delete it with the + confirmation round-trip; list users. + +## Non-Goals + +- No new backend endpoints (only minimal token-guard extensions if an admin + route proves session-only, individually flagged). +- No file-attachment upload in the chat UI (inline GeoJSON or URL only; UI + attach is a future increment). +- No multi-operator conflict resolution (last-write-wins documented). +- No password reset/delete-user flows in v1 (create + permission only). diff --git a/mcp/.gitignore b/mcp/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/mcp/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 000000000..1aa339df4 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,107 @@ +# MMGIS MCP Server + +Lets AI agents (any MCP client — Claude Code, Claude Desktop, ...) drive MMGIS: +administer missions, generate dashboards from natural language, search STAC +catalogs for data layers, and control a live browser session. + +## Setup + +1. `cd mcp && npm install && npm run build` +2. In the MMGIS `.env`, set `ENABLE_MMGIS_WEBSOCKETS=true` (needed for + browser control) and start MMGIS (`npm start`). +3. Generate an MMGIS API token. In MMGIS, log in as an admin, open the + **Configure** page, and use the **API Tokens** tab — name the token (e.g. + `mcp`), generate, and copy it. + + This is not a shared secret: each person mints their own, and the token + inherits that account's mission permissions (creating missions requires a + SuperAdmin token). See + https://nasa-ammos.github.io/MMGIS/apis/configure#api-tokens. + + The equivalent API call, if you prefer the console, is + `POST /api/longtermtoken/generate` with `{"name": "mcp", "period": "never"}` + — it must be made from a logged-in admin **session**, since tokens cannot + mint tokens. + +4. `export MMGIS_TOKEN=` — the repo `.mcp.json` picks it up, or + register manually: `claude mcp add mmgis -- node mcp/dist/index.js`. + +## Environment variables + +| Variable | Default | Purpose | +| --- | --- | --- | +| `MMGIS_URL` | `http://localhost:8888` | Base URL of your running MMGIS (match its `PORT`; include `ROOT_PATH` if set) | +| `MMGIS_TOKEN` | (required) | MMGIS API token from Configure → API Tokens, sent as `Authorization: Bearer ...` | +| `MMGIS_WS_URL` | derived from `MMGIS_URL` | Websocket endpoint (`ws://host:port/`) | +| `MMGIS_REPO_ROOT` | auto (this checkout) | MMGIS repo containing `scripts/generate-mission-config.js` | +| `MAPBOX_TOKEN` | empty | Substituted into generated configs' basemap | +| `STAC_CATALOGS` | veda + earth-search | JSON object `{name: stacApiUrl}` | +| `TITILER_URL` | `https://titiler.xyz` | TiTiler used for `catalog_item_to_layer` tile URLs | + +## Tools + +- `mission_list`, `mission_get` — admin plane +- `dashboard_profile_schema`, `dashboard_tool_options`, `dashboard_generate` (supports `returnConfig` to get the full config JSON back), `dashboard_create_from_config` (install a dashboard from raw config JSON) — NL → dashboard +- `catalog_collections`, `catalog_search`, `catalog_item_to_layer` — STAC data discovery +- `view_fly_to`, `view_toggle_layer`, `view_open_tool`, `view_set_time`, `view_get_state` — live browser control (requires an open browser session on the mission; `dashboard_generate` enables the AgentBridge component automatically) +- `mission_update_config`, `layer_add`, `layer_update`, `layer_remove`, `tool_toggle` — live config editing (in modern mode, the AgentBridge component auto-reloads open sessions on config changes; classic mode applies via MMGIS's native update flow; `view_reload` remains a manual fallback) +- `mission_clone`, `mission_delete`†, `geodataset_list`, `geodataset_ingest`, `geodataset_delete`†, `user_list`, `user_create`†, `user_set_permission`† — admin operations († = requires `confirm: true` after a preview) +- `view_reload` — reload an open session to apply non-layer config changes + +## Demo (end-to-end) + +Ask your MCP client: + +> Set up an MMGIS dashboard called "Air Quality Atlanta" showing NO2 data +> over the southeastern US, then fly the view to Atlanta. + +Expected flow: `catalog_collections`(keyword no2) → `catalog_search` → +`catalog_item_to_layer` → `dashboard_generate` → open the returned URL in a +browser → `view_fly_to`. + +## Manual E2E checklist + +- [ ] `mission_list` returns the deployment's missions +- [ ] `dashboard_generate` creates a mission that loads in the browser +- [ ] With the mission open in a browser: `view_get_state` returns the mission name +- [ ] `view_fly_to` visibly moves the map +- [ ] `view_toggle_layer` flips a layer on/off (check LayerManager) +- [ ] `view_open_tool` opens a tool panel in both classic missions (exclusive + `ToolController_` panel) and modern missions (`msv.mode: "modern"`, + shown/loaded via `window.mmgisAPI`); an unknown tool name returns + `ok: false` rather than a false success +- [ ] `view_*` with no browser open returns the "No browser session" hint + +## Security notes + +- Bridge commands are view-only and whitelist-validated in the browser + (`src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js`). +- The MMGIS websocket relay (`API/websocket.js`) is a single unauthenticated + broadcast: it forwards every frame to every connected client, with no + per-mission routing at the relay layer. Mission scoping happens only + client-side (the browser and `BridgeClient` both drop frames whose + `body.mission` doesn't match). Practically, this means **any** websocket + peer on the relay can both issue view commands for *any* mission and read + every mission's view-state acks (mission name, layer visibility, current + time) — restrict who can reach the relay accordingly; do not expose it + publicly on deployments where that matters (Phase 2 hardening candidate). +- If more than one browser session has the same mission open, all of them + receive and execute every command for that mission; `BridgeClient` resolves + on whichever session's ack arrives first and ignores the rest. +- Each AgentBridge session also broadcasts a `{kind: 'presence', sessionId}` + frame on connect. Nothing currently consumes it server- or MCP-side — it's + reserved for a future session-listing tool (Phase 2). +- Same relay-hardening caveat family: because the relay is unauthenticated + and forwards every frame, any websocket peer can forge a + `{forceClientUpdate, info, body}` config-mutation frame and force-reload + every open **modern**-mode session of any mission (AgentBridge treats it as + a legitimate save broadcast) — restrict relay access accordingly (Phase 2 + hardening candidate). +- `/api/configure/clone` and `/destroy` have no per-permission check upstream — ANY valid long-term token can invoke them (pre-existing MMGIS behavior); scope who gets tokens accordingly. `mission_clone` shells out to a `python` binary on the MMGIS server; hosts with only `python3` will see clone failures. +- `/api/accounts/entries` and `/api/accounts/update` are likewise reachable + by ANY valid long-term token (pre-existing MMGIS permission-less token + path, not something this MCP server adds) — so any token holder can list + every user account and change permissions via `user_list` / + `user_set_permission`'s underlying endpoints. Scope token issuance + accordingly; a token is effectively as powerful as an admin session for + these routes. diff --git a/mcp/package-lock.json b/mcp/package-lock.json new file mode 100644 index 000000000..0cd45c841 --- /dev/null +++ b/mcp/package-lock.json @@ -0,0 +1,2812 @@ +{ + "name": "@mmgis/mcp-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@mmgis/mcp-server", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "ws": "^8.18.0", + "zod": "^3.24.0" + }, + "bin": { + "mmgis-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/ws": "^8.5.10", + "tsx": "^4.19.0", + "typescript": "^5.5.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/mcp/package.json b/mcp/package.json new file mode 100644 index 000000000..264cc7fd5 --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,25 @@ +{ + "name": "@mmgis/mcp-server", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { "mmgis-mcp": "dist/index.js" }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx src/index.ts", + "test": "vitest run" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "ws": "^8.18.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/ws": "^8.5.10", + "tsx": "^4.19.0", + "typescript": "^5.5.0", + "vitest": "^3.0.0" + } +} diff --git a/mcp/src/bridge.ts b/mcp/src/bridge.ts new file mode 100644 index 000000000..35f51efed --- /dev/null +++ b/mcp/src/bridge.ts @@ -0,0 +1,99 @@ +import WebSocket from 'ws' +import { randomUUID } from 'node:crypto' +import { MMGISError } from './mmgisClient.js' + +export class BridgeClient { + private ws: WebSocket | null = null + private connecting: Promise | null = null + + constructor(private wsUrl: string, private timeoutMs = 5000) {} + + private connect(): Promise { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + return Promise.resolve(this.ws) + } + if (this.connecting) { + return this.connecting + } + this.connecting = new Promise((resolve, reject) => { + const ws = new WebSocket(this.wsUrl) + ws.once('open', () => { + this.ws = ws + this.connecting = null + resolve(ws) + // The `once('error', ...)` below only guards the connect phase — + // it's still attached (it hasn't fired) but a socket that errors + // post-open would otherwise leave us holding a dead `this.ws` + // forever. Keep a persistent handler so a later socket error + // can't crash the process (EventEmitter throws on an unhandled + // 'error' with zero listeners) and so the next sendCommand() + // reconnects instead of reusing the broken socket. + ws.on('error', () => { + // Only clear state that still belongs to this socket — if a + // newer reconnect has already replaced `this.ws`/`this.connecting` + // (e.g. this ws was closed and connect() was called again before + // this stale error fired), leave that newer in-flight state alone. + const wasCurrent = this.ws === ws + if (wasCurrent) this.ws = null + if (wasCurrent && this.connecting) this.connecting = null + }) + }) + ws.once('error', (err) => { + this.connecting = null + reject( + new MMGISError( + `Could not connect to the MMGIS websocket at ${this.wsUrl}: ${err.message}`, + 'Set ENABLE_MMGIS_WEBSOCKETS=true in the MMGIS .env and check MMGIS_WS_URL.' + ) + ) + }) + }) + return this.connecting + } + + async sendCommand(mission: string, command: string, args: object): Promise { + const ws = await this.connect() + const id = randomUUID() + const frame = JSON.stringify({ + type: 'agent-bridge', + body: { mission }, + info: { type: 'agentBridge' }, + agent: { kind: 'command', id, command, args }, + }) + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup() + reject( + new MMGISError( + `No browser session responded for mission "${mission}" within ${this.timeoutMs}ms`, + 'Open the mission in a browser — the AgentBridge component must be enabled in its config (dashboard_generate does this automatically).' + ) + ) + }, this.timeoutMs) + const onMessage = (data: WebSocket.RawData) => { + try { + const parsed = JSON.parse(data.toString()) + if (parsed?.type === 'agent-bridge' && parsed.agent?.kind === 'ack' && parsed.agent.id === id) { + cleanup() + if (parsed.agent.ok) resolve(parsed.agent.result) + else reject(new MMGISError(parsed.agent.error || 'Command failed in the browser')) + } + } catch { + // non-JSON or unrelated frame — ignore + } + } + const cleanup = () => { + clearTimeout(timer) + ws.off('message', onMessage) + } + ws.on('message', onMessage) + ws.send(frame) + }) + } + + close() { + this.ws?.close() + this.ws = null + this.connecting = null + } +} diff --git a/mcp/src/config.ts b/mcp/src/config.ts new file mode 100644 index 000000000..ba2fbc5ff --- /dev/null +++ b/mcp/src/config.ts @@ -0,0 +1,87 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +export interface McpConfig { + mmgisUrl: string + mmgisToken: string + wsUrl: string + repoRoot: string + mapboxToken: string + stacCatalogs: Record + titilerUrl: string + stacTilers: Record +} + +const DEFAULT_STAC_CATALOGS: Record = { + veda: 'https://openveda.cloud/api/stac', + 'earth-search': 'https://earth-search.aws.element84.com/v1', +} + +// Catalogs whose imagery needs their OWN tile server (protected buckets a +// public TiTiler cannot read — verified live: titiler.xyz gets AccessDenied +// on VEDA items while openveda.cloud/api/raster serves them fine). +const DEFAULT_STAC_TILERS: Record = { + veda: 'https://openveda.cloud/api/raster', +} + +const STAC_CATALOGS_SHAPE_ERROR = + 'STAC_CATALOGS must be a JSON object mapping catalog name (string) to URL (string), ' + + 'e.g. {"veda": "https://openveda.cloud/api/stac"}' + +// JSON.parse alone doesn't guarantee the *shape* we depend on elsewhere +// (stac.ts indexes it as Record) — an array or an object with +// non-string values would parse fine but blow up downstream in a confusing way. +function assertStacCatalogsShape(value: unknown): asserts value is Record { + const isPlainObject = typeof value === 'object' && value !== null && !Array.isArray(value) + if (!isPlainObject || Object.values(value as Record).some((v) => typeof v !== 'string')) { + throw new Error(STAC_CATALOGS_SHAPE_ERROR) + } +} + +// Shared parse+validate for the STAC_CATALOGS/STAC_TILERS env vars: both are +// optional JSON objects mapping a catalog name to a URL string. +function parseJsonMap( + envValue: string | undefined, + defaults: Record, + errorMessage: string +): Record { + if (!envValue) return defaults + let parsed: unknown + try { + parsed = JSON.parse(envValue) + } catch { + throw new Error(errorMessage) + } + assertStacCatalogsShape(parsed) + return parsed +} + +export function loadConfig(env: Record = process.env): McpConfig { + if (!env.MMGIS_TOKEN) { + throw new Error( + 'MMGIS_TOKEN is required. Mint a long-term token: log into MMGIS as an admin, then POST /api/longtermtoken/generate (see mcp/README.md).' + ) + } + const mmgisUrl = (env.MMGIS_URL || 'http://localhost:8888').replace(/\/+$/, '') + // MMGIS's WS upgrade only accepts path (WEBSOCKET_ROOT_PATH || ROOT_PATH || '') + '/' + const rawWsUrl = env.MMGIS_WS_URL || mmgisUrl.replace(/^http/, 'ws') + '/' + const wsUrl = rawWsUrl.replace(/\/+$/, '') + '/' + // mcp/src (dev) and mcp/dist (built) are both one level below mcp/ + const defaultRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') + const stacCatalogs = parseJsonMap(env.STAC_CATALOGS, DEFAULT_STAC_CATALOGS, STAC_CATALOGS_SHAPE_ERROR) + const stacTilers = parseJsonMap( + env.STAC_TILERS, + DEFAULT_STAC_TILERS, + 'STAC_TILERS must be a JSON object mapping catalog name to tiler URL' + ) + return { + mmgisUrl, + mmgisToken: env.MMGIS_TOKEN, + wsUrl, + repoRoot: env.MMGIS_REPO_ROOT || defaultRoot, + mapboxToken: env.MAPBOX_TOKEN || '', + stacCatalogs, + titilerUrl: (env.TITILER_URL || 'https://titiler.xyz').replace(/\/+$/, ''), + stacTilers, + } +} diff --git a/mcp/src/configEdit.ts b/mcp/src/configEdit.ts new file mode 100644 index 000000000..db4baf45b --- /dev/null +++ b/mcp/src/configEdit.ts @@ -0,0 +1,57 @@ +import type { MmgisClient } from './mmgisClient.js' + +function isPlainObject(v: any): boolean { + return v != null && typeof v === 'object' && !Array.isArray(v) +} + +// RFC 7386 JSON Merge Patch. Returns a new value; never mutates `target`. +export function mergePatch(target: any, patch: any): any { + if (!isPlainObject(patch)) return patch + const base = isPlainObject(target) ? target : {} + const out: any = { ...base } + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete out[key] + else out[key] = mergePatch(base[key], value) + } + return out +} + +export interface EditInfo { + type: string + layerName?: string | string[] +} + +export async function editConfig( + client: MmgisClient, + missionName: string, + mutate: (config: any) => { info?: EditInfo } | void +): Promise<{ mission: string; version: number }> { + const current = await client.getMission(missionName) + const config = JSON.parse(JSON.stringify(current.config)) + const result = mutate(config) || {} + return await client.upsertMission(missionName, config, { + forceClientUpdate: true, + info: result.info ?? { type: 'upsert' }, + }) +} + +export function findLayerIndex(config: any, nameOrUuid: string): number { + const layers = Array.isArray(config?.layers) ? config.layers : [] + return layers.findIndex((l: any) => l?.name === nameOrUuid || l?.uuid === nameOrUuid) +} + +export function layerNames(config: any): string { + return (config?.layers ?? []).map((l: any) => l.name).join(', ') || '(none)' +} + +// Looks up a layer by name/uuid or throws the shared "Unknown layer" error +// (with the available-layer-names hint) tool handlers rely on. +export function requireLayerIndex(config: any, layer: string): number { + const idx = findLayerIndex(config, layer) + if (idx === -1) { + throw Object.assign(new Error(`Unknown layer: ${layer}`), { + hint: `Available layers: ${layerNames(config)}`, + }) + } + return idx +} diff --git a/mcp/src/generator.ts b/mcp/src/generator.ts new file mode 100644 index 000000000..8df566360 --- /dev/null +++ b/mcp/src/generator.ts @@ -0,0 +1,75 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { MMGISError } from './mmgisClient.js' + +const execFileAsync = promisify(execFile) + +export async function generateConfig(profile: any, repoRoot: string): Promise { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mmgis-mcp-')) + const profilePath = path.join(tmpDir, 'profile.json') + try { + fs.writeFileSync(profilePath, JSON.stringify(profile, null, 2)) + const { stdout } = await execFileAsync( + process.execPath, + ['scripts/generate-mission-config.js', profilePath, '--stdout'], + { cwd: repoRoot, maxBuffer: 32 * 1024 * 1024 } + ) + return JSON.parse(stdout) + } catch (err: any) { + if (err instanceof SyntaxError) { + throw new MMGISError('Config generator produced unparseable output', 'Run the generator manually to debug.') + } + const detail = String(err?.stderr || err?.message || err).trim() + throw new MMGISError(`Config generation failed: ${detail}`, 'Fix the profile fields named in the error and retry.') + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } +} + +export function resolvePlaceholders(config: any, mapboxToken: string): any { + // Tokens are URL-safe (alphanumeric + dots); plain string replace is safe here + return JSON.parse(JSON.stringify(config).split('{{MAPBOX_TOKEN}}').join(mapboxToken)) +} + +// Substitutes {{MAPBOX_TOKEN}} (single stringify, reused for both the +// placeholder check and the replace) and, when the config needed a token +// that isn't configured, swaps a mapbox basemap for the token-free MapLibre +// demo style so the map still renders. +export function finalizeBasemap(config: any, mapboxToken: string): { config: any; warnings: string[] } { + const serialized = JSON.stringify(config) + const neededMapboxToken = serialized.includes('{{MAPBOX_TOKEN}}') + const resolved = JSON.parse(serialized.split('{{MAPBOX_TOKEN}}').join(mapboxToken)) + const warnings: string[] = [] + if (neededMapboxToken && mapboxToken === '' && resolved?.msv?.basemap?.provider === 'mapbox') { + resolved.msv.basemap = { + provider: 'maplibre', + style: 'https://demotiles.maplibre.org/style.json', + } + warnings.push('MAPBOX_TOKEN is not set — using the free MapLibre demo basemap instead') + } + return { config: resolved, warnings } +} + +export function loadMinimalProfile(repoRoot: string): any { + return JSON.parse(fs.readFileSync(path.join(repoRoot, 'mission-profiles', 'minimal.json'), 'utf8')) +} + +// listAvailableTools shells out to the generator to build a full-tools probe +// config — that's deterministic per repoRoot within a process, so cache it. +const availableToolsCache = new Map() + +export async function listAvailableTools(repoRoot: string): Promise { + const cached = availableToolsCache.get(repoRoot) + if (cached) return cached + const probe = JSON.parse(JSON.stringify(loadMinimalProfile(repoRoot))) + probe.tools = 'all' + probe.on = [] + delete probe.output + const config = await generateConfig(probe, repoRoot) + const names = config.tools.map((t: { name: string }) => t.name) + availableToolsCache.set(repoRoot, names) + return names +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts new file mode 100644 index 000000000..a3b745dc1 --- /dev/null +++ b/mcp/src/index.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env node +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { loadConfig } from './config.js' +import { MmgisClient } from './mmgisClient.js' +import { makeAdminTools } from './tools/admin.js' +import { makeDashboardTools } from './tools/dashboard.js' +import { makeCatalogTools } from './tools/catalog.js' +import { BridgeClient } from './bridge.js' +import { makeViewTools } from './tools/view.js' +import { makeEditTools } from './tools/edit.js' +import { buildServer } from './server.js' + +async function main() { + const cfg = loadConfig() + const client = new MmgisClient(cfg.mmgisUrl, cfg.mmgisToken) + const bridge = new BridgeClient(cfg.wsUrl) + const server = buildServer({ + tools: [ + ...makeAdminTools(client), + ...makeDashboardTools(client, cfg), + ...makeCatalogTools(cfg), + ...makeViewTools(bridge), + ...makeEditTools(client), + ], + }) + await server.connect(new StdioServerTransport()) + // stdio server runs until the client disconnects +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/mcp/src/mmgisClient.ts b/mcp/src/mmgisClient.ts new file mode 100644 index 000000000..79be7ac65 --- /dev/null +++ b/mcp/src/mmgisClient.ts @@ -0,0 +1,116 @@ +export class MMGISError extends Error { + constructor(message: string, public readonly hint?: string) { + super(message) + this.name = 'MMGISError' + } +} + +export class MmgisClient { + constructor( + private baseUrl: string, + private token: string, + private fetchFn: typeof fetch = fetch + ) {} + + private async request(method: 'GET' | 'POST' | 'DELETE', apiPath: string, body?: unknown): Promise { + let res + try { + res = await this.fetchFn(`${this.baseUrl}${apiPath}`, { + method, + headers: { + Authorization: `Bearer ${this.token}`, + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + } catch (err) { + throw new MMGISError( + `Could not reach MMGIS at ${this.baseUrl}: ${(err as Error).message}`, + 'Check MMGIS_URL and that the MMGIS server is running.' + ) + } + if (!res.ok) { + throw new MMGISError( + `MMGIS responded ${res.status} for ${apiPath}`, + 'Check MMGIS_URL and that MMGIS_TOKEN is a valid, unexpired long-term token.' + ) + } + let json + try { + json = await res.json() + } catch (err) { + throw new MMGISError( + `MMGIS returned a non-JSON response for ${apiPath}`, + 'Check MMGIS_URL — it may be pointing at a proxy, login page, or the wrong port instead of the MMGIS API.' + ) + } + if (json && json.status === 'failure') { + throw new MMGISError(json.message || `MMGIS reported failure for ${apiPath}`) + } + return json + } + + async listMissions(): Promise { + const json = await this.request('GET', '/api/configure/missions') + return json.missions + } + + async getMission(mission: string): Promise<{ mission: string; config: any; version: number }> { + return await this.request('GET', `/api/configure/get?mission=${encodeURIComponent(mission)}&full=true`) + } + + async addMission(mission: string, config: any): Promise<{ mission: string; version: number }> { + return await this.request('POST', '/api/configure/add', { mission, config, makedir: true }) + } + + async upsertMission( + mission: string, + config: any, + opts?: { forceClientUpdate?: boolean; info?: { type: string; layerName?: string | string[] } } + ): Promise<{ mission: string; version: number }> { + return await this.request('POST', '/api/configure/upsert', { + mission, + config, + ...(opts?.forceClientUpdate !== undefined ? { forceClientUpdate: opts.forceClientUpdate } : {}), + ...(opts?.info ? { info: opts.info } : {}), + }) + } + + async cloneMission(existingMission: string, cloneMission: string): Promise { + return await this.request('POST', '/api/configure/clone', { existingMission, cloneMission }) + } + + async destroyMission(mission: string): Promise<{ message: string }> { + return await this.request('POST', '/api/configure/destroy', { mission }) + } + + async geodatasetEntries(): Promise { + const json = await this.request('POST', '/api/geodatasets/entries', {}) + return json.body?.entries ?? [] + } + + async geodatasetRecreate(name: string, geojson: any): Promise { + return await this.request('POST', `/api/geodatasets/recreate/${encodeURIComponent(name)}`, geojson) + } + + async geodatasetRemove(name: string): Promise<{ message: string }> { + return await this.request('DELETE', `/api/geodatasets/remove/${encodeURIComponent(name)}`) + } + + async accountEntries(): Promise { + const json = await this.request('GET', '/api/accounts/entries') + return json.body?.entries ?? [] + } + + async accountUpdate(input: { id: number; permission?: '110' | '001'; missionsManaging?: string[] }): Promise { + return await this.request('POST', '/api/accounts/update', { + id: input.id, + ...(input.permission ? { permission: input.permission } : {}), + ...(input.missionsManaging ? { missions_managing: input.missionsManaging } : {}), + }) + } + + async userSignup(username: string, password: string): Promise { + return await this.request('POST', '/api/users/signup', { username, password, skipLogin: true }) + } +} diff --git a/mcp/src/profileBuilder.ts b/mcp/src/profileBuilder.ts new file mode 100644 index 000000000..879da3cbb --- /dev/null +++ b/mcp/src/profileBuilder.ts @@ -0,0 +1,56 @@ +import { randomUUID } from 'node:crypto' +import { loadMinimalProfile } from './generator.js' + +export interface DashboardSpec { + missionName: string + layers?: any[] + view?: { lat: number; lon: number; zoom: number } + tools?: string[] + on?: string[] + time?: Record + overrides?: Record }> + pageName?: string +} + +// Mission-config entry that enables the AgentBridge browser component (Task 7) +export const AGENT_BRIDGE_COMPONENT = { + name: 'AgentBridge', + js: 'AgentBridge', + on: true, + variables: {}, +} + +// Injected after generation: `components` is not a template key, and +// /api/configure/add does not run backend validation. +export function ensureAgentBridgeComponent(config: any): void { + if (!Array.isArray(config.components)) { + config.components = [AGENT_BRIDGE_COMPONENT] + } +} + +export function buildProfile(spec: DashboardSpec, repoRoot: string): any { + const minimal = loadMinimalProfile(repoRoot) + const profile = JSON.parse(JSON.stringify(minimal)) + profile.name = `agent-${spec.missionName}` + profile.description = 'Generated by the MMGIS MCP server' + delete profile.output + profile.tools = Array.from(new Set([...minimal.tools, ...(spec.tools || [])])) + profile.on = Array.from(new Set([...minimal.on, ...(spec.on || [])])) + profile.overrides = spec.overrides || {} + + const scaffold = profile.scaffold + scaffold.msv.mission = spec.missionName + scaffold.msv.missionFolderName = spec.missionName + if (spec.view) { + scaffold.msv.view = [String(spec.view.lat), String(spec.view.lon), String(spec.view.zoom)] + } + if (spec.pageName) scaffold.look.pagename = spec.pageName + if (spec.time) scaffold.time = spec.time + scaffold.layers = (spec.layers || []).map((l) => ({ + uuid: randomUUID(), + sublayers: [], + visibility: true, + ...l, + })) + return profile +} diff --git a/mcp/src/server.ts b/mcp/src/server.ts new file mode 100644 index 000000000..5e3bef68f --- /dev/null +++ b/mcp/src/server.ts @@ -0,0 +1,12 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { ToolDef } from './tools/result.js' + +export function buildServer(deps: { tools: ToolDef[] }): McpServer { + const server = new McpServer({ name: 'mmgis', version: '0.1.0' }) + for (const t of deps.tools) { + // `server.tool(name, description, schema, handler)` is deprecated in the installed + // @modelcontextprotocol/sdk version; use the `registerTool` config-object form instead. + server.registerTool(t.name, { description: t.description, inputSchema: t.schema }, t.handler) + } + return server +} diff --git a/mcp/src/stac.ts b/mcp/src/stac.ts new file mode 100644 index 000000000..60fb2d9b1 --- /dev/null +++ b/mcp/src/stac.ts @@ -0,0 +1,184 @@ +import { MMGISError } from './mmgisClient.js' + +export interface StacItemSummary { + id: string + collection: string + datetime: string | null + bbox: number[] | null + selfHref: string | null + assets: { key: string; title?: string; type?: string; href: string }[] + // Name of the configured catalog (see McpConfig.stacCatalogs) this item + // came from, when searchStac was called with one — lets tilerForItem + // pick a dedicated tiler without re-deriving it from selfHref's origin. + catalogName?: string +} + +async function stacFetch(url: string, init: RequestInit | undefined, fetchFn: typeof fetch): Promise { + let res + try { + res = await fetchFn(url, init) + } catch (err) { + throw new MMGISError( + `Could not reach STAC catalog at ${url}: ${(err as Error).message}`, + 'The catalog may be down — try another configured catalog, or use layers already in the deployment.' + ) + } + if (!res.ok) throw new MMGISError( + `STAC catalog responded ${res.status} for ${url}`, + 'The catalog may be down — try another configured catalog, or use layers already in the deployment.' + ) + return await res.json() +} + +function summarizeItem(feature: any): StacItemSummary { + return { + id: feature.id, + collection: feature.collection, + datetime: feature.properties?.datetime ?? null, + bbox: feature.bbox ?? null, + selfHref: (feature.links || []).find((l: any) => l.rel === 'self')?.href ?? null, + assets: Object.entries(feature.assets || {}).map(([key, a]: [string, any]) => ({ + key, + ...(a.title ? { title: a.title } : {}), + ...(a.type ? { type: a.type } : {}), + href: a.href, + })), + } +} + +export async function searchStac( + catalogUrl: string, + params: { bbox?: number[]; datetime?: string; collections?: string[]; limit?: number }, + fetchFn: typeof fetch = fetch, + catalogName?: string +): Promise { + const body: Record = { limit: params.limit ?? 10 } + if (params.bbox) body.bbox = params.bbox + if (params.datetime) body.datetime = params.datetime + if (params.collections) body.collections = params.collections + const json = await stacFetch( + `${catalogUrl.replace(/\/+$/, '')}/search`, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, + fetchFn + ) + const items = (json.features || []).map(summarizeItem) + if (catalogName) { + for (const item of items) item.catalogName = catalogName + } + return items +} + +// VEDA alone spans 25 pages at 10 collections/page (verified live 2026-07-25) +const MAX_COLLECTION_PAGES = 50 + +type StacCollection = { id: string; title?: string; description?: string; temporalExtent?: unknown; spatialExtent?: unknown } + +// Per-process cache of each catalog's unfiltered collection list — repeated +// catalog_collections calls (e.g. a keyword miss re-scanning availableCollections) +// shouldn't re-page through a 25-page catalog every time. +const COLLECTIONS_CACHE_TTL_MS = 5 * 60 * 1000 +const collectionsCache = new Map() + +async function fetchAllCollections(catalogUrl: string, fetchFn: typeof fetch): Promise { + const cached = collectionsCache.get(catalogUrl) + if (cached && Date.now() - cached.at < COLLECTIONS_CACHE_TTL_MS) { + return cached.collections + } + const rawCollections: any[] = [] + let url = `${catalogUrl.replace(/\/+$/, '')}/collections` + for (let page = 0; page < MAX_COLLECTION_PAGES; page++) { + const json = await stacFetch(url, undefined, fetchFn) + rawCollections.push(...(json.collections || [])) + const nextHref = (json.links || []).find((l: any) => l.rel === 'next')?.href + if (!nextHref) break + url = new URL(nextHref, url).toString() + } + const collections = rawCollections.map((c: any) => { + const temporal = c.extent?.temporal?.interval?.[0] + const spatial = c.extent?.spatial?.bbox?.[0] + return { + id: c.id, + ...(c.title ? { title: c.title } : {}), + ...(c.description ? { description: c.description } : {}), + // Coverage helps distinguish event-specific snapshots (one day, + // small bbox) from ongoing/global datasets when presenting options. + ...(temporal ? { temporalExtent: temporal } : {}), + ...(spatial ? { spatialExtent: spatial } : {}), + } + }) + collectionsCache.set(catalogUrl, { at: Date.now(), collections }) + return collections +} + +// Test-only escape hatch — the cache is otherwise process-lifetime. +export function clearCollectionsCache(): void { + collectionsCache.clear() +} + +export function filterCollections(collections: StacCollection[], keyword: string): StacCollection[] { + const k = keyword.toLowerCase() + return collections.filter((c) => [c.id, c.title, c.description].some((s) => s && s.toLowerCase().includes(k))) +} + +export async function searchCollections( + catalogUrl: string, + keyword?: string, + fetchFn: typeof fetch = fetch +): Promise { + const collections = await fetchAllCollections(catalogUrl, fetchFn) + return keyword ? filterCollections(collections, keyword) : collections +} + +export function stacItemToTileLayer( + item: StacItemSummary, + opts: { + name: string + titilerUrl: string + asset?: string + rescale?: string + colormap?: string + // 'item-path' targets titiler-pgstac deployments (eoAPI, e.g. VEDA's + // /api/raster) which have no /stac/tiles?url= route; 'stac-url' is + // plain TiTiler's generic endpoint. + urlStyle?: 'stac-url' | 'item-path' + } +): any { + if (!item.selfHref) { + throw new MMGISError(`STAC item ${item.id} has no self link; cannot build a tile URL`) + } + const asset = opts.asset || item.assets[0]?.key + if (!asset) throw new MMGISError(`STAC item ${item.id} has no assets`) + let url: string + if (opts.urlStyle === 'item-path') { + if (!item.collection) { + throw new MMGISError(`STAC item ${item.id} has no collection; cannot build an item-path tile URL`) + } + url = + `${opts.titilerUrl}/collections/${encodeURIComponent(item.collection)}` + + `/items/${encodeURIComponent(item.id)}/tiles/WebMercatorQuad/{z}/{x}/{y}.png` + + `?assets=${encodeURIComponent(asset)}` + } else { + url = + `${opts.titilerUrl}/stac/tiles/WebMercatorQuad/{z}/{x}/{y}.png` + + `?url=${encodeURIComponent(item.selfHref)}&assets=${encodeURIComponent(asset)}` + } + if (opts.rescale) url += `&rescale=${encodeURIComponent(opts.rescale)}` + if (opts.colormap) url += `&colormap_name=${encodeURIComponent(opts.colormap)}` + return { + name: opts.name, + type: 'TileLayer', + sourceType: 'url', + url, + tileformat: 'wmts', + controlled: false, + visibility: true, + initialOpacity: 1, + minZoom: 0, + maxNativeZoom: 18, + maxZoom: 22, + ...(item.bbox ? { boundingBox: item.bbox } : {}), + style: { brightness: 1, contrast: 1, saturation: 1, blend: 'none' }, + time: { enabled: false }, + variables: {}, + } +} diff --git a/mcp/src/tools/admin.ts b/mcp/src/tools/admin.ts new file mode 100644 index 000000000..2cba6a000 --- /dev/null +++ b/mcp/src/tools/admin.ts @@ -0,0 +1,192 @@ +import { z } from 'zod' +import type { MmgisClient } from '../mmgisClient.js' +import { MMGISError } from '../mmgisClient.js' +import { type ToolDef, toToolResult, toErrorResult, wrap } from './result.js' + +const MAX_GEOJSON_BYTES = 20 * 1024 * 1024 + +const CONFIRM_RETRY_HINT = 'After the user explicitly agrees, retry the SAME call with confirm: true.' + +function isFeatureCollection(v: any): boolean { + return v != null && v.type === 'FeatureCollection' && Array.isArray(v.features) +} + +function confirmationNeeded(preview: Record) { + return toToolResult({ needsConfirmation: true, ...preview, hint: CONFIRM_RETRY_HINT }) +} + +export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetch): ToolDef[] { + return [ + { + name: 'mission_list', + description: 'List all mission (dashboard) names in this MMGIS deployment.', + schema: {}, + handler: () => + wrap(async () => toToolResult({ missions: await client.listMissions() })), + }, + { + name: 'mission_get', + description: "Get a mission's full configuration JSON and current version.", + schema: { mission: z.string().describe('Mission name (see mission_list)') }, + handler: ({ mission }: { mission: string }) => + wrap(async () => { + const out = await client.getMission(mission) + return toToolResult({ mission: out.mission, version: out.version, config: out.config }) + }), + }, + { + name: 'mission_clone', + description: 'Clone an existing mission (dashboard) to a new name.', + schema: { + fromMission: z.string().describe('Existing mission to copy'), + toMission: z.string().describe('Name for the new mission'), + }, + handler: ({ fromMission, toMission }: any) => + wrap(async () => { + const out = await client.cloneMission(fromMission, toMission) + return toToolResult({ mission: toMission, ...out }) + }), + }, + { + name: 'mission_delete', + description: 'DESTRUCTIVE: delete a mission and all its config versions. Requires confirm: true — without it returns a preview. Always show the preview to the user and get their explicit yes first.', + schema: { + mission: z.string(), + confirm: z.boolean().optional().describe('Must be true to actually delete'), + }, + handler: ({ mission, confirm }: any) => + wrap(async () => { + const missions = await client.listMissions() + if (!missions.includes(mission)) { + return toErrorResult( + Object.assign(new Error(`Unknown mission: ${mission}`), { + hint: `Missions: ${missions.join(', ') || '(none)'}`, + }) + ) + } + if (confirm !== true) { + return confirmationNeeded({ + wouldDelete: `Mission "${mission}" and every config version of it (the Missions/ folder is renamed, not erased).`, + }) + } + return toToolResult(await client.destroyMission(mission)) + }), + }, + { + name: 'geodataset_list', + description: 'List geodatasets (uploaded vector datasets) and which missions use them.', + schema: {}, + handler: () => + wrap(async () => toToolResult({ geodatasets: await client.geodatasetEntries() })), + }, + { + name: 'geodataset_ingest', + description: 'Create or replace a geodataset from GeoJSON — inline `geojson` OR a `url` to fetch (max 20MB). Use it in a layer with type "vector" and url "geodatasets:".', + schema: { + name: z.string().describe('Geodataset name'), + geojson: z.record(z.any()).optional().describe('Inline GeoJSON FeatureCollection'), + url: z.string().optional().describe('URL of a GeoJSON file to fetch'), + confirm: z.boolean().optional().describe('Must be true to overwrite an existing geodataset'), + }, + handler: ({ name, geojson, url, confirm }: any) => + wrap(async () => { + if (!geojson === !url) { + return toErrorResult(new MMGISError('Provide exactly one of geojson or url')) + } + if (confirm !== true) { + const existing = await client.geodatasetEntries() + const match = (existing as any[]).find((e: any) => e.name === name) + if (match) { + return confirmationNeeded({ + wouldReplace: `Geodataset "${name}" already exists with ${match.num_features} features — ingesting replaces ALL of them.`, + }) + } + } + let data = geojson + if (url) { + const res = await fetchFn(url) + if (!res.ok) throw new MMGISError(`Fetch failed (${res.status}) for ${url}`) + const len = Number(res.headers.get('content-length') || 0) + if (len > MAX_GEOJSON_BYTES) throw new MMGISError(`File too large (${len} bytes; max ${MAX_GEOJSON_BYTES})`) + const text = await res.text() + if (text.length > MAX_GEOJSON_BYTES) throw new MMGISError(`File too large (max ${MAX_GEOJSON_BYTES} bytes)`) + try { + data = JSON.parse(text) + } catch { + throw new MMGISError(`${url} is not valid JSON`) + } + } + if (!isFeatureCollection(data)) { + return toErrorResult(new MMGISError('GeoJSON must be a FeatureCollection with a features array')) + } + await client.geodatasetRecreate(name, data) + return toToolResult({ name, features: data.features.length, layerUrl: `geodatasets:${name}` }) + }), + }, + { + name: 'geodataset_delete', + description: 'DESTRUCTIVE: delete a geodataset and its data table. Requires confirm: true — without it returns a preview. Get the user\'s explicit yes first.', + schema: { name: z.string(), confirm: z.boolean().optional() }, + handler: ({ name, confirm }: any) => + wrap(async () => { + if (confirm !== true) { + return confirmationNeeded({ + wouldDelete: `Geodataset "${name}" and its feature table. Layers referencing geodatasets:${name} will break.`, + }) + } + return toToolResult(await client.geodatasetRemove(name)) + }), + }, + { + name: 'user_list', + description: 'List MMGIS user accounts (id, username, permission: 111=SuperAdmin, 110=Admin, 001=Viewer).', + schema: {}, + handler: () => + wrap(async () => toToolResult({ users: await client.accountEntries() })), + }, + { + name: 'user_create', + description: "Create a user account (created as Viewer '001'; use user_set_permission to promote to Admin '110'). Password needs 8+ chars with upper, lower, number, symbol. Requires confirm: true after the user agrees. Never repeat the password back in chat.", + schema: { + username: z.string(), + password: z.string().describe('8+ chars with upper, lower, number, symbol'), + confirm: z.boolean().optional(), + }, + handler: ({ username, password, confirm }: any) => + wrap(async () => { + if (confirm !== true) { + return confirmationNeeded({ + wouldCreate: `User "${username}" with Viewer (001) permission.`, + }) + } + const out = await client.userSignup(username, password) + return toToolResult({ username: out.username ?? username, created: true }) + }), + }, + { + name: 'user_set_permission', + description: "Change a user's permission: '110' (Admin, optionally with missionsManaging list) or '001' (Viewer). SuperAdmin (111) cannot be granted, and user id 1 cannot be changed (backend rules). Requires confirm: true.", + schema: { + username: z.string(), + permission: z.enum(['110', '001']), + missionsManaging: z.array(z.string()).optional().describe("Missions an Admin ('110') manages"), + confirm: z.boolean().optional(), + }, + handler: ({ username, permission, missionsManaging, confirm }: any) => + wrap(async () => { + if (confirm !== true) { + return confirmationNeeded({ + wouldChange: `Set "${username}" permission to ${permission}${missionsManaging ? ` managing [${missionsManaging.join(', ')}]` : ''}.`, + }) + } + const users = await client.accountEntries() + const user = users.find((u: any) => u.username === username) + if (!user) { + return toErrorResult(Object.assign(new Error(`Unknown user: ${username}`), { hint: `Users: ${users.map((u: any) => u.username).join(', ')}` })) + } + await client.accountUpdate({ id: user.id, permission, ...(missionsManaging ? { missionsManaging } : {}) }) + return toToolResult({ username, permission, ...(missionsManaging ? { missionsManaging } : {}) }) + }), + }, + ] +} diff --git a/mcp/src/tools/catalog.ts b/mcp/src/tools/catalog.ts new file mode 100644 index 000000000..40047f7bb --- /dev/null +++ b/mcp/src/tools/catalog.ts @@ -0,0 +1,123 @@ +import { z } from 'zod' +import type { McpConfig } from '../config.js' +import { searchStac, searchCollections, filterCollections, stacItemToTileLayer } from '../stac.js' +import { MMGISError } from '../mmgisClient.js' +import { type ToolDef, toToolResult, toErrorResult, wrap } from './result.js' + +// Some catalogs (e.g. VEDA) keep imagery in protected buckets only their own +// tile server can read — prefer a direct catalogName -> tiler lookup (set +// when the item came from a search against a configured catalog name), then +// fall back to matching the item's self link against a catalog origin. +export function tilerForItem( + cfg: McpConfig, + selfHref: string | null, + catalogName?: string +): { titilerUrl: string; urlStyle: 'stac-url' | 'item-path' } { + if (catalogName && cfg.stacTilers[catalogName]) { + // Dedicated tilers are titiler-pgstac (eoAPI) deployments — they + // serve items by path, not by a ?url= parameter. + return { titilerUrl: cfg.stacTilers[catalogName].replace(/\/+$/, ''), urlStyle: 'item-path' } + } + if (selfHref) { + for (const [name, catalogUrl] of Object.entries(cfg.stacCatalogs)) { + try { + if (new URL(selfHref).origin === new URL(catalogUrl).origin && cfg.stacTilers[name]) { + return { titilerUrl: cfg.stacTilers[name].replace(/\/+$/, ''), urlStyle: 'item-path' } + } + } catch { + // unparseable URL — fall through to the generic tiler + } + } + } + return { titilerUrl: cfg.titilerUrl, urlStyle: 'stac-url' } +} + +function resolveCatalog(cfg: McpConfig, catalog: string): string { + if (/^https?:\/\//.test(catalog)) return catalog + const url = cfg.stacCatalogs[catalog] + if (!url) { + throw new MMGISError( + `Unknown catalog "${catalog}"`, + `Configured catalogs: ${Object.keys(cfg.stacCatalogs).join(', ')} — or pass a full STAC API URL.` + ) + } + return url +} + +// The `catalog` input names a configured catalog (dashboard_profile_schema- +// style short name) unless it's already a full STAC API URL. +function resolveCatalogName(catalog: string): string | undefined { + return /^https?:\/\//.test(catalog) ? undefined : catalog +} + +export function makeCatalogTools(cfg: McpConfig, fetchFn: typeof fetch = fetch): ToolDef[] { + return [ + { + name: 'catalog_collections', + description: + 'List/search dataset collections in a STAC catalog. Use to find data for a dashboard. Dataset ids are technical (e.g. no2-monthly, not "air quality") — try measurement-specific keywords, and on zero matches scan the returned availableCollections list for real names.', + schema: { + catalog: z.string().describe(`Catalog name (${Object.keys(cfg.stacCatalogs).join(', ')}) or a STAC API URL`), + keyword: z.string().optional().describe('Filter by keyword, e.g. "no2", "fire", "flood"'), + }, + handler: ({ catalog, keyword }: { catalog: string; keyword?: string }) => + wrap(async () => { + const url = resolveCatalog(cfg, catalog) + const all = await searchCollections(url, undefined, fetchFn) + const collections = keyword ? filterCollections(all, keyword) : all + if (keyword && collections.length === 0) { + return toToolResult({ + collections: [], + hint: `No collections matched "${keyword}". Dataset names are technical — scan availableCollections below for related measurements (e.g. air quality: no2, so2, pm, aerosol; fire: burn, thermal; flood: water, inundation) and re-search with a matching id fragment.`, + availableCollections: all.slice(0, 400).map((c) => c.id), + totalCollections: all.length, + }) + } + return toToolResult({ collections }) + }), + }, + { + name: 'catalog_search', + description: 'Search a STAC catalog for items (scenes/granules) by collection, bbox, and datetime.', + schema: { + catalog: z.string().describe('Catalog name or STAC API URL'), + collections: z.array(z.string()).optional(), + bbox: z.array(z.number()).length(4).optional().describe('[west, south, east, north]'), + datetime: z.string().optional().describe('RFC3339 interval, e.g. "2026-01-01T00:00:00Z/2026-06-30T23:59:59Z"'), + limit: z.number().optional(), + }, + handler: ({ catalog, ...params }: any) => + wrap(async () => { + const catalogName = resolveCatalogName(catalog) + const items = await searchStac(resolveCatalog(cfg, catalog), params, undefined, catalogName) + return toToolResult({ items }) + }), + }, + { + name: 'catalog_item_to_layer', + description: + 'Convert a STAC item (from catalog_search) into an MMGIS TileLayer entry for dashboard_generate, rendered through TiTiler.', + schema: { + item: z.record(z.any()).describe('A StacItemSummary object exactly as returned by catalog_search'), + name: z.string().describe('Display name for the layer'), + asset: z.string().optional().describe('Asset key to render (defaults to the first asset)'), + rescale: z.string().optional().describe('e.g. "0,255"'), + colormap: z.string().optional().describe('e.g. "viridis"'), + }, + handler: ({ item, name, asset, rescale, colormap }: any) => + wrap(async () => { + const tiler = tilerForItem(cfg, item?.selfHref ?? null, item?.catalogName) + return toToolResult({ + layer: stacItemToTileLayer(item, { + name, + titilerUrl: tiler.titilerUrl, + urlStyle: tiler.urlStyle, + asset, + rescale, + colormap, + }), + }) + }), + }, + ] +} diff --git a/mcp/src/tools/dashboard.ts b/mcp/src/tools/dashboard.ts new file mode 100644 index 000000000..3e9f5531d --- /dev/null +++ b/mcp/src/tools/dashboard.ts @@ -0,0 +1,180 @@ +import { z } from 'zod' +import type { MmgisClient } from '../mmgisClient.js' +import type { McpConfig } from '../config.js' +import { buildProfile, ensureAgentBridgeComponent, type DashboardSpec } from '../profileBuilder.js' +import { generateConfig, finalizeBasemap, listAvailableTools } from '../generator.js' +import { type ToolDef, toToolResult, toErrorResult, wrap } from './result.js' + +const LAYER_EXAMPLES = { + tile: { + name: 'Sentinel-2 True Color', + type: 'TileLayer', + sourceType: 'url', + url: 'https://example.com/tiles/WebMercatorQuad/{z}/{x}/{y}@1x.png', + tileformat: 'wmts', + controlled: false, + initialOpacity: 1, + minZoom: 0, + maxNativeZoom: 18, + maxZoom: 22, + boundingBox: [-88.1, 36.0, -86.8, 37.1], + style: { brightness: 1, contrast: 1, saturation: 1, blend: 'none' }, + time: { enabled: false }, + variables: {}, + }, + geojson: { + name: 'Monitoring Stations', + type: 'GeoJsonLayer', + sourceType: 'url', + url: 'https://example.com/stations.geojson', + controlled: false, + initialOpacity: 1, + visibility: true, + style: {}, + variables: {}, + }, +} + +// Mirrors the mission-name validation in API/Backend/Config/routes/configs.js's +// `add()` handler (~line 265: "Bad mission name."). Checked here too so a bad +// name fails fast, before the (expensive) profile-build + config-generation +// work, instead of surfacing only after /api/configure/add rejects it. +const MISSION_NAME_FORBIDDEN_CHARS = /[`~!@#$%^&*()|+\-=?;:'",.<>{}[\]\\/]/gi +const MISSION_NAME_RULE = + 'Mission names must be non-empty, must not start with a digit, must not contain "../" or "..\\\\", ' + + 'and must not contain any of: ` ~ ! @ # $ % ^ & * ( ) | + - = ? ; : \' " , . < > { } [ ] \\ /' + +function validateMissionName(missionName: string): { message: string; hint: string } | null { + const isInvalid = + missionName !== missionName.replace(MISSION_NAME_FORBIDDEN_CHARS, '') || + missionName.length === 0 || + !isNaN(missionName[0] as any) || + missionName.includes('../') || + missionName.includes('..\\') + if (!isInvalid) return null + return { + message: `Invalid mission name: "${missionName}"`, + hint: MISSION_NAME_RULE, + } +} + +async function installMission( + client: MmgisClient, + missionName: string, + config: any, + updateExisting: boolean | undefined +): Promise<{ mission: string; version: number }> { + try { + return await client.addMission(missionName, config) + } catch (err: any) { + if (/already exists/i.test(err?.message || '')) { + if (updateExisting) return await client.upsertMission(missionName, config) + err.hint = 'Pass updateExisting: true to replace the existing mission config.' + } + throw err + } +} + +const dashboardGenerateSchema = { + mission: z.string().describe(`Name for the new mission/dashboard. ${MISSION_NAME_RULE}`), + layers: z + .array(z.record(z.any())) + .optional() + .describe('MMGIS layer entries (see dashboard_profile_schema layerExamples). uuids are minted automatically.'), + view: z + .object({ lat: z.number(), lon: z.number(), zoom: z.number() }) + .optional() + .describe('Initial map view'), + tools: z.array(z.string()).optional().describe('Extra tools beyond Title+LayerManager (see dashboard_tool_options)'), + on: z.array(z.string()).optional().describe('Tools that start opened'), + time: z.record(z.any()).optional().describe('Time config, e.g. {"enabled": true}'), + overrides: z.record(z.object({ variables: z.record(z.any()) })).optional(), + pageName: z.string().optional().describe('Browser page title / branding'), + updateExisting: z.boolean().optional().describe('If the mission exists, replace its config (new version)'), + returnConfig: z + .boolean() + .optional() + .describe('Include the full generated mission config JSON in the result'), +} + +export function makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef[] { + return [ + { + name: 'dashboard_profile_schema', + description: + 'Get the input schema and layer-entry examples for dashboard_generate. Call this before generating a dashboard.', + schema: {}, + handler: async () => + toToolResult({ + spec: { + mission: `string (required). ${MISSION_NAME_RULE}`, + layers: 'array of MMGIS layer entries — see layerExamples', + view: '{lat, lon, zoom} initial map view', + tools: 'string[] extra tools (dashboard_tool_options lists valid names)', + on: 'string[] tools opened at start', + time: 'object, e.g. {"enabled": true} for time-enabled layers', + overrides: '{ToolName: {variables: {...}}} per-tool settings', + pageName: 'string page title', + updateExisting: 'boolean — replace config if mission exists', + }, + layerExamples: LAYER_EXAMPLES, + }), + }, + { + name: 'dashboard_tool_options', + description: 'List tool names that dashboard_generate can include in a dashboard.', + schema: {}, + handler: () => + wrap(async () => toToolResult({ tools: await listAvailableTools(cfg.repoRoot) })), + }, + { + name: 'dashboard_generate', + description: + 'Generate a complete MMGIS mission (dashboard) from a description of layers, view, and tools, and install it. Returns the mission URL.', + schema: dashboardGenerateSchema, + handler: (args: Omit & { mission: string; updateExisting?: boolean; returnConfig?: boolean }) => + wrap(async () => { + const nameError = validateMissionName(args.mission) + if (nameError) return toErrorResult(nameError) + const profile = buildProfile({ ...args, missionName: args.mission }, cfg.repoRoot) + const generated = await generateConfig(profile, cfg.repoRoot) + // Without a Mapbox token the default basemap renders black; fall + // back to the token-free MapLibre demo style so maps always show. + const { config, warnings } = finalizeBasemap(generated, cfg.mapboxToken) + ensureAgentBridgeComponent(config) + const out = await installMission(client, args.mission, config, args.updateExisting) + return toToolResult({ + mission: out.mission, + version: out.version, + url: `${cfg.mmgisUrl}/?mission=${encodeURIComponent(args.mission)}`, + ...(warnings.length > 0 ? { warnings } : {}), + ...(args.returnConfig ? { config } : {}), + }) + }), + }, + { + name: 'dashboard_create_from_config', + description: + 'Install an MMGIS mission (dashboard) from a complete raw mission config JSON — use when the user provides or edits config JSON directly. Returns the mission URL.', + schema: { + mission: z.string().describe(`Name for the mission. ${MISSION_NAME_RULE}`), + config: z.record(z.any()).describe('Complete MMGIS mission config object (e.g. from dashboard_generate with returnConfig)'), + updateExisting: z.boolean().optional().describe('If the mission exists, replace its config (new version)'), + }, + handler: (args: { mission: string; config: any; updateExisting?: boolean }) => + wrap(async () => { + const nameError = validateMissionName(args.mission) + if (nameError) return toErrorResult(nameError) + const { config, warnings } = finalizeBasemap(args.config, cfg.mapboxToken) + ensureAgentBridgeComponent(config) + const out = await installMission(client, args.mission, config, args.updateExisting) + return toToolResult({ + mission: out.mission, + version: out.version, + url: `${cfg.mmgisUrl}/?mission=${encodeURIComponent(args.mission)}`, + ...(warnings.length > 0 ? { warnings } : {}), + }) + }), + }, + ] +} diff --git a/mcp/src/tools/edit.ts b/mcp/src/tools/edit.ts new file mode 100644 index 000000000..975b17c66 --- /dev/null +++ b/mcp/src/tools/edit.ts @@ -0,0 +1,115 @@ +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import type { MmgisClient } from '../mmgisClient.js' +import { mergePatch, editConfig, requireLayerIndex } from '../configEdit.js' +import { type ToolDef, toToolResult, toErrorResult, wrap } from './result.js' + +const LIVE_NOTE = + 'Change saved. Classic sessions apply it live; modern sessions auto-reload within about a second (AgentBridge).' +const RELOAD_NOTE = + 'Change saved. Modern sessions auto-reload within about a second (AgentBridge); classic sessions show a RELOAD button. view_reload is a manual fallback.' + +export function makeEditTools(client: MmgisClient): ToolDef[] { + const mission = z.string().describe('Mission to edit (see mission_list)') + return [ + { + name: 'mission_update_config', + description: + 'Edit ANY part of a mission config with an RFC 7386 JSON merge-patch (objects merge, null deletes a key, arrays replace). Backend validation runs server-side. Prefer layer_*/tool_toggle for common edits.', + schema: { + mission, + patch: z.record(z.any()).describe('Merge patch, e.g. {"look": {"pagename": "New Name"}} or {"msv": {"basemap": {...}}}'), + }, + handler: ({ mission, patch }: any) => + wrap(async () => { + const out = await editConfig(client, mission, (config) => { + const merged = mergePatch(config, patch) + for (const key of Object.keys(config)) delete config[key] + Object.assign(config, merged) + }) + return toToolResult({ ...out, refresh: RELOAD_NOTE }) + }), + }, + { + name: 'layer_add', + description: 'Add a layer entry to a mission. Applies live in open sessions.', + schema: { + mission, + layer: z.record(z.any()).describe('MMGIS layer entry (see dashboard_profile_schema layerExamples; vector layers can use url "geodatasets:")'), + position: z.number().optional().describe('Index to insert at (default: end)'), + }, + handler: ({ mission, layer, position }: any) => + wrap(async () => { + if (!layer || typeof layer.name !== 'string' || layer.name.trim() === '') { + return toErrorResult( + Object.assign(new Error('layer.name is required'), { + hint: 'Layer entries need a name.', + }) + ) + } + const entry = { uuid: randomUUID(), sublayers: [], visibility: true, ...layer } + const out = await editConfig(client, mission, (config) => { + config.layers = config.layers ?? [] + const at = position === undefined ? config.layers.length : Math.max(0, Math.min(position, config.layers.length)) + config.layers.splice(at, 0, entry) + return { info: { type: 'addLayer', layerName: entry.name } } + }) + return toToolResult({ ...out, layer: entry, refresh: LIVE_NOTE }) + }), + }, + { + name: 'layer_update', + description: 'Merge-patch a single layer (found by name or uuid). Applies live in open sessions.', + schema: { + mission, + layer: z.string().describe('Layer name or uuid'), + patch: z.record(z.any()).describe('Merge patch for the layer entry, e.g. {"visibility": false} or {"initialOpacity": 0.5}'), + }, + handler: ({ mission, layer, patch }: any) => + wrap(async () => { + let updatedName = '' + const out = await editConfig(client, mission, (config) => { + const idx = requireLayerIndex(config, layer) + config.layers[idx] = mergePatch(config.layers[idx], patch) + updatedName = config.layers[idx].name + return { info: { type: 'updateLayer', layerName: updatedName } } + }) + return toToolResult({ ...out, layer: updatedName, refresh: LIVE_NOTE }) + }), + }, + { + name: 'layer_remove', + description: 'Remove a layer (by name or uuid). Applies live in open sessions.', + schema: { mission, layer: z.string().describe('Layer name or uuid') }, + handler: ({ mission, layer }: any) => + wrap(async () => { + let removedName = '' + const out = await editConfig(client, mission, (config) => { + const idx = requireLayerIndex(config, layer) + removedName = config.layers[idx].name + config.layers.splice(idx, 1) + return { info: { type: 'removeLayer', layerName: removedName } } + }) + return toToolResult({ ...out, removed: removedName, refresh: LIVE_NOTE }) + }), + }, + { + name: 'tool_toggle', + description: "Turn a mission's tool on or off (e.g. Chart, Measure).", + schema: { mission, toolName: z.string(), on: z.boolean() }, + handler: ({ mission, toolName, on }: any) => + wrap(async () => { + const out = await editConfig(client, mission, (config) => { + const tool = (config.tools ?? []).find((t: any) => t.name === toolName) + if (!tool) { + throw Object.assign(new Error(`Unknown tool: ${toolName}`), { + hint: `Configured tools: ${(config.tools ?? []).map((t: any) => t.name).join(', ') || '(none)'}`, + }) + } + tool.on = on + }) + return toToolResult({ ...out, tool: toolName, on, refresh: RELOAD_NOTE }) + }), + }, + ] +} diff --git a/mcp/src/tools/result.ts b/mcp/src/tools/result.ts new file mode 100644 index 000000000..72ce3f2f3 --- /dev/null +++ b/mcp/src/tools/result.ts @@ -0,0 +1,35 @@ +import type { z } from 'zod' + +export interface ToolDef { + name: string + description: string + schema: z.ZodRawShape + handler: (args: any) => Promise<{ content: { type: 'text'; text: string }[]; isError?: boolean }> +} + +export function toToolResult(data: unknown) { + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] } +} + +export function toErrorResult(err: unknown) { + const e = err as { message?: string; hint?: string } + return { + isError: true, + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: e?.message || String(err), ...(e?.hint ? { hint: e.hint } : {}) }), + }, + ], + } +} + +export async function wrap( + fn: () => Promise<{ content: { type: 'text'; text: string }[]; isError?: boolean }> +) { + try { + return await fn() + } catch (err) { + return toErrorResult(err) + } +} diff --git a/mcp/src/tools/view.ts b/mcp/src/tools/view.ts new file mode 100644 index 000000000..0abc02b89 --- /dev/null +++ b/mcp/src/tools/view.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' +import type { BridgeClient } from '../bridge.js' +import { type ToolDef, toToolResult, toErrorResult } from './result.js' + +export function makeViewTools(bridge: BridgeClient): ToolDef[] { + const run = async (mission: string, command: string, args: object) => { + try { + return toToolResult({ result: await bridge.sendCommand(mission, command, args) }) + } catch (err) { + return toErrorResult(err) + } + } + const mission = z.string().describe('Exact mission name as returned by mission_list (case and spaces matter)') + return [ + { + name: 'view_fly_to', + description: "Fly a connected browser session's map to a lat/lon (and optional zoom).", + schema: { mission, lat: z.number(), lon: z.number(), zoom: z.number().optional() }, + handler: ({ mission, ...args }: any) => run(mission, 'fly_to', args), + }, + { + name: 'view_toggle_layer', + description: 'Toggle (or set) a layer\'s visibility in a connected browser session.', + schema: { + mission, + layer: z.string().describe('Layer name or uuid'), + on: z.boolean().optional().describe('Target state; omit to flip'), + }, + handler: ({ mission, ...args }: any) => run(mission, 'toggle_layer', args), + }, + { + name: 'view_open_tool', + description: 'Open a tool panel (e.g. Chart, Measure) in a connected browser session.', + schema: { mission, name: z.string().describe('Tool name') }, + handler: ({ mission, ...args }: any) => run(mission, 'open_tool', args), + }, + { + name: 'view_set_time', + description: 'Set the global time range in a connected browser session (time must be enabled).', + schema: { + mission, + startTime: z.string().describe('ISO datetime'), + endTime: z.string().describe('ISO datetime'), + currentTime: z.string().optional(), + }, + handler: ({ mission, ...args }: any) => run(mission, 'set_time', args), + }, + { + name: 'view_get_state', + description: 'Get the current view state (center, zoom, layers on, active tool, time) of a connected browser session.', + schema: { mission }, + handler: ({ mission }: any) => run(mission, 'get_view_state', {}), + }, + { + name: 'view_reload', + description: 'Reload an open browser session so non-layer config changes (basemap, page name, tools) take effect. A timeout after sending can mean the page reloaded before acking — treat that as success if view_get_state works afterwards.', + schema: { mission }, + handler: ({ mission }: any) => run(mission, 'reload', {}), + }, + ] +} diff --git a/mcp/tests/admin.spec.ts b/mcp/tests/admin.spec.ts new file mode 100644 index 000000000..d1c200f41 --- /dev/null +++ b/mcp/tests/admin.spec.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi } from 'vitest' +import { makeAdminTools } from '../src/tools/admin.js' +import { MMGISError } from '../src/mmgisClient.js' +import { parse } from './helpers.js' + +const fakeClient = { + listMissions: async () => ['Demo', 'Mars2020'], + getMission: async (m: string) => ({ mission: m, config: { msv: { mission: m } }, version: 2 }), +} as any + +describe('admin tools', () => { + const tools = Object.fromEntries(makeAdminTools(fakeClient).map((t) => [t.name, t])) + + it('exposes mission_list and mission_get', () => { + expect(Object.keys(tools).sort()).toEqual(['geodataset_delete', 'geodataset_ingest', 'geodataset_list', 'mission_clone', 'mission_delete', 'mission_get', 'mission_list', 'user_create', 'user_list', 'user_set_permission']) + }) + it('mission_list returns mission names', async () => { + expect(parse(await tools.mission_list.handler({}))).toEqual({ missions: ['Demo', 'Mars2020'] }) + }) + it('mission_get returns config and version', async () => { + const out = parse(await tools.mission_get.handler({ mission: 'Demo' })) + expect(out.version).toBe(2) + expect(out.config.msv.mission).toBe('Demo') + }) + it('errors become structured {error, hint} results with isError', async () => { + const failing = { listMissions: async () => { throw new MMGISError('boom', 'try this') } } as any + const t = Object.fromEntries(makeAdminTools(failing).map((x) => [x.name, x])) + const res = await t.mission_list.handler({}) + expect(res.isError).toBe(true) + expect(parse(res)).toEqual({ error: 'boom', hint: 'try this' }) + }) + + it('mission_clone calls the clone endpoint', async () => { + const client = { cloneMission: vi.fn(async () => ({ status: 'success', mission: 'B', version: 0 })) } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + const out = parse(await t.mission_clone.handler({ fromMission: 'A', toMission: 'B' })) + expect(out.mission).toBe('B') + expect(client.cloneMission).toHaveBeenCalledWith('A', 'B') + }) + + it('mission_delete requires confirm and previews first', async () => { + const client = { + listMissions: async () => ['A'], + destroyMission: vi.fn(async () => ({ message: 'Successfully Deleted Mission: A' })), + } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + const preview = parse(await t.mission_delete.handler({ mission: 'A' })) + expect(preview.needsConfirmation).toBe(true) + expect(preview.hint).toBe('After the user explicitly agrees, retry the SAME call with confirm: true.') + expect(client.destroyMission).not.toHaveBeenCalled() + const done = parse(await t.mission_delete.handler({ mission: 'A', confirm: true })) + expect(done.message).toContain('Deleted') + expect(client.destroyMission).toHaveBeenCalledWith('A') + }) + + it('mission_delete errors on an unknown mission without calling destroyMission (preview or confirm path)', async () => { + const client = { + listMissions: async () => ['A'], + destroyMission: vi.fn(async () => ({ message: 'Successfully Deleted Mission: B' })), + } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + const preview = await t.mission_delete.handler({ mission: 'B' }) + expect(preview.isError).toBe(true) + expect(parse(preview).error).toBe('Unknown mission: B') + expect(parse(preview).hint).toContain('A') + expect(client.destroyMission).not.toHaveBeenCalled() + + const confirmed = await t.mission_delete.handler({ mission: 'B', confirm: true }) + expect(confirmed.isError).toBe(true) + expect(client.destroyMission).not.toHaveBeenCalled() + }) + + it('geodataset_list returns entries', async () => { + const client = { geodatasetEntries: vi.fn(async () => [{ name: 'g1', num_features: 5 }]) } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + expect(parse(await t.geodataset_list.handler({})).geodatasets).toEqual([{ name: 'g1', num_features: 5 }]) + }) + + it('geodataset_ingest accepts inline FeatureCollections and rejects bad shapes', async () => { + const client = { + geodatasetRecreate: vi.fn(async () => ({ status: 'success' })), + geodatasetEntries: async () => [], + } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + const fc = { type: 'FeatureCollection', features: [{ type: 'Feature', geometry: null, properties: {} }] } + const out = parse(await t.geodataset_ingest.handler({ name: 'g1', geojson: fc })) + expect(out.name).toBe('g1') + expect(out.features).toBe(1) + expect(client.geodatasetRecreate).toHaveBeenCalledWith('g1', fc) + const bad = await t.geodataset_ingest.handler({ name: 'g1', geojson: { type: 'Point' } }) + expect(bad.isError).toBe(true) + }) + + it('geodataset_ingest fetches from a url with a size cap', async () => { + const fc = { type: 'FeatureCollection', features: [] } + const fetcher = vi.fn(async () => ({ + ok: true, headers: { get: () => null }, text: async () => JSON.stringify(fc), + })) as any + const client = { + geodatasetRecreate: vi.fn(async () => ({ status: 'success' })), + geodatasetEntries: async () => [], + } as any + const t = Object.fromEntries(makeAdminTools(client, fetcher).map((x) => [x.name, x])) + const out = parse(await t.geodataset_ingest.handler({ name: 'g2', url: 'https://x/y.geojson' })) + expect(out.features).toBe(0) + expect(fetcher).toHaveBeenCalledWith('https://x/y.geojson') + const big = vi.fn(async () => ({ ok: true, headers: { get: () => String(30 * 1024 * 1024) }, text: async () => '' })) as any + const t2 = Object.fromEntries(makeAdminTools(client, big).map((x) => [x.name, x])) + expect((await t2.geodataset_ingest.handler({ name: 'g3', url: 'https://x/big.geojson' })).isError).toBe(true) + }) + + it('geodataset_ingest requires confirm to replace an existing geodataset', async () => { + const client = { + geodatasetRecreate: vi.fn(async () => ({ status: 'success' })), + geodatasetEntries: async () => [{ name: 'g1', num_features: 5 }], + } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + const fc = { type: 'FeatureCollection', features: [] } + const preview = parse(await t.geodataset_ingest.handler({ name: 'g1', geojson: fc })) + expect(preview.needsConfirmation).toBe(true) + expect(preview.wouldReplace).toContain('g1') + expect(preview.wouldReplace).toContain('5 features') + expect(client.geodatasetRecreate).not.toHaveBeenCalled() + const out = parse(await t.geodataset_ingest.handler({ name: 'g1', geojson: fc, confirm: true })) + expect(out.name).toBe('g1') + expect(client.geodatasetRecreate).toHaveBeenCalledWith('g1', fc) + }) + + it('geodataset_delete requires confirm', async () => { + const client = { geodatasetRemove: vi.fn(async () => ({ message: "Successfully deleted geodataset 'g1'." })) } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + expect(parse(await t.geodataset_delete.handler({ name: 'g1' })).needsConfirmation).toBe(true) + expect(client.geodatasetRemove).not.toHaveBeenCalled() + parse(await t.geodataset_delete.handler({ name: 'g1', confirm: true })) + expect(client.geodatasetRemove).toHaveBeenCalledWith('g1') + }) + + it('user_list returns account entries without passwords', async () => { + const client = { accountEntries: vi.fn(async () => [{ id: 1, username: 'admin', permission: '111' }]) } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + expect(parse(await t.user_list.handler({})).users).toEqual([{ id: 1, username: 'admin', permission: '111' }]) + }) + + it('user_create requires confirm, calls signup, and never echoes the password', async () => { + const client = { userSignup: vi.fn(async () => ({ status: 'success', username: 'alice' })) } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + const preview = parse(await t.user_create.handler({ username: 'alice', password: 'Str0ng!Pass' })) + expect(preview.needsConfirmation).toBe(true) + expect(client.userSignup).not.toHaveBeenCalled() + const res = await t.user_create.handler({ username: 'alice', password: 'Str0ng!Pass', confirm: true }) + expect(res.content[0].text).not.toContain('Str0ng!Pass') + expect(parse(res).username).toBe('alice') + expect(client.userSignup).toHaveBeenCalledWith('alice', 'Str0ng!Pass') + }) + + it('user_set_permission resolves username to id and requires confirm', async () => { + const client = { + accountEntries: vi.fn(async () => [{ id: 1, username: 'admin', permission: '111' }, { id: 2, username: 'bob', permission: '001' }]), + accountUpdate: vi.fn(async () => ({ status: 'success' })), + } as any + const t = Object.fromEntries(makeAdminTools(client).map((x) => [x.name, x])) + const preview = parse(await t.user_set_permission.handler({ username: 'bob', permission: '110', missionsManaging: ['Demo'] })) + expect(preview.needsConfirmation).toBe(true) + await t.user_set_permission.handler({ username: 'bob', permission: '110', missionsManaging: ['Demo'], confirm: true }) + expect(client.accountUpdate).toHaveBeenCalledWith({ id: 2, permission: '110', missionsManaging: ['Demo'] }) + const unknown = await t.user_set_permission.handler({ username: 'nope', permission: '001', confirm: true }) + expect(unknown.isError).toBe(true) + }) +}) diff --git a/mcp/tests/bridge.spec.ts b/mcp/tests/bridge.spec.ts new file mode 100644 index 000000000..9ea0c0044 --- /dev/null +++ b/mcp/tests/bridge.spec.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { WebSocketServer, WebSocket } from 'ws' +import { BridgeClient } from '../src/bridge.js' +import { MMGISError } from '../src/mmgisClient.js' + +// Mimics MMGIS API/websocket.js: relay every frame to ALL clients (sender included) +function startRelay(): Promise<{ wss: WebSocketServer; url: string }> { + return new Promise((resolve) => { + const wss = new WebSocketServer({ port: 0 }, () => { + const { port } = wss.address() as { port: number } + resolve({ wss, url: `ws://127.0.0.1:${port}/` }) + }) + wss.on('connection', (ws) => { + ws.on('message', (m) => { + for (const c of wss.clients) if (c.readyState === WebSocket.OPEN) c.send(m.toString()) + }) + }) + }) +} + +// Fake AgentBridge browser session +function fakeBrowser(url: string, mission: string, respond: (agent: any) => any): WebSocket { + const ws = new WebSocket(url) + ws.on('message', (m) => { + const parsed = JSON.parse(m.toString()) + if (parsed.type !== 'agent-bridge' || parsed.agent?.kind !== 'command') return + if (parsed.body?.mission !== mission) return + ws.send( + JSON.stringify({ + type: 'agent-bridge', + body: { mission }, + info: { type: 'agentBridge' }, + agent: { kind: 'ack', id: parsed.agent.id, sessionId: 's1', ...respond(parsed.agent) }, + }) + ) + }) + return ws +} + +describe('BridgeClient', () => { + let wss: WebSocketServer, browser: WebSocket | null = null, bridge: BridgeClient + + afterEach(() => { + bridge?.close() + browser?.close() + wss?.close() + }) + + it('sends a command frame and resolves with the ack result', async () => { + const relay = await startRelay() + wss = relay.wss + browser = fakeBrowser(relay.url, 'Demo', (agent) => ({ ok: true, result: { echoed: agent.command } })) + await new Promise((r) => browser!.on('open', r)) + bridge = new BridgeClient(relay.url, 2000) + const result = await bridge.sendCommand('Demo', 'fly_to', { lat: 1, lon: 2 }) + expect(result).toEqual({ echoed: 'fly_to' }) + }) + + it('rejects with the browser-reported error on failed acks', async () => { + const relay = await startRelay() + wss = relay.wss + browser = fakeBrowser(relay.url, 'Demo', () => ({ ok: false, error: 'Unknown layer: X' })) + await new Promise((r) => browser!.on('open', r)) + bridge = new BridgeClient(relay.url, 2000) + await expect(bridge.sendCommand('Demo', 'toggle_layer', { layer: 'X' })).rejects.toThrow('Unknown layer: X') + }) + + it('times out with a helpful hint when no session responds', async () => { + const relay = await startRelay() + wss = relay.wss + bridge = new BridgeClient(relay.url, 300) + const err = await bridge.sendCommand('Demo', 'fly_to', {}).catch((e) => e) + expect(err.message).toMatch(/No browser session/) + expect(err.hint).toMatch(/AgentBridge/) + }) + + it('ignores acks for other missions (browser filters by mission)', async () => { + const relay = await startRelay() + wss = relay.wss + browser = fakeBrowser(relay.url, 'OtherMission', () => ({ ok: true, result: {} })) + await new Promise((r) => browser!.on('open', r)) + bridge = new BridgeClient(relay.url, 300) + await expect(bridge.sendCommand('Demo', 'fly_to', {})).rejects.toThrow(/No browser session/) + }) + + it('dedupes concurrent connects: two overlapping sendCommand calls share one bridge connection', async () => { + const relay = await startRelay() + wss = relay.wss + + // Attach the connection counter BEFORE anything connects, so both the + // fake browser's connection and the bridge client's connection(s) are + // counted. + let connectionCount = 0 + wss.on('connection', () => { + connectionCount++ + }) + + browser = fakeBrowser(relay.url, 'Demo', (agent) => ({ ok: true, result: { echoed: agent.command } })) + await new Promise((r) => browser!.on('open', r)) + expect(connectionCount).toBe(1) // sanity: only the fake browser so far + + bridge = new BridgeClient(relay.url, 2000) + // Fire both calls back-to-back without awaiting between them, both + // before the underlying websocket has finished connecting. + const p1 = bridge.sendCommand('Demo', 'fly_to', { lat: 1, lon: 2 }) + const p2 = bridge.sendCommand('Demo', 'toggle_layer', { layer: 'X' }) + + const [r1, r2] = await Promise.all([p1, p2]) + expect(r1).toEqual({ echoed: 'fly_to' }) + expect(r2).toEqual({ echoed: 'toggle_layer' }) + + // connectionCount now includes the fake browser's connection (1) plus + // however many connections the bridge client opened. Subtracting the + // browser's one isolates the bridge client's connection count, which + // must be exactly 1 even though two sendCommand calls overlapped. + expect(connectionCount - 1).toBe(1) + }) + + it('recovers from a post-open socket error instead of crashing, reconnecting on the next command', async () => { + const relay = await startRelay() + wss = relay.wss + let connectionCount = 0 + wss.on('connection', () => { + connectionCount++ + }) + browser = fakeBrowser(relay.url, 'Demo', (agent) => ({ ok: true, result: { echoed: agent.command } })) + await new Promise((r) => browser!.on('open', r)) + + bridge = new BridgeClient(relay.url, 2000) + await bridge.sendCommand('Demo', 'fly_to', { lat: 1, lon: 2 }) + expect(connectionCount).toBe(2) // browser's connection + the bridge's first connection + + // Simulate a post-open socket error (e.g. a proxy dropping the + // connection). Emitting 'error' directly on the live socket is the + // reliable way to trigger this deterministically, without racing real + // network teardown timing. If the client left this socket without a + // persistent 'error' listener, this emit would throw and crash the + // process (Node's EventEmitter behavior for unhandled 'error' events) + // — reaching the assertions below is itself part of the proof. + const liveWs = (bridge as any).ws + expect(liveWs).toBeTruthy() + liveWs.emit('error', new Error('simulated socket error')) + expect((bridge as any).ws).toBe(null) + + // The next command must open a brand-new connection rather than + // reusing (or hanging on) the broken one. + const result = await bridge.sendCommand('Demo', 'toggle_layer', { layer: 'X' }) + expect(result).toEqual({ echoed: 'toggle_layer' }) + expect(connectionCount).toBe(3) + }) + + it('rejects with an actionable hint when the relay is unreachable', async () => { + bridge = new BridgeClient('ws://127.0.0.1:1/', 300) + const err = await bridge.sendCommand('Demo', 'fly_to', {}).catch((e) => e) + expect(err).toBeInstanceOf(MMGISError) + expect(err.hint).toMatch(/ENABLE_MMGIS_WEBSOCKETS/) + }) +}) diff --git a/mcp/tests/catalog.spec.ts b/mcp/tests/catalog.spec.ts new file mode 100644 index 000000000..a7aa3121d --- /dev/null +++ b/mcp/tests/catalog.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { makeCatalogTools } from '../src/tools/catalog.js' +import { clearCollectionsCache } from '../src/stac.js' +import { parse } from './helpers.js' + +const cfg = { stacCatalogs: { test: 'https://stac.test' }, titilerUrl: 'https://titiler.xyz' } as any + +describe('catalog tools', () => { + const tools = Object.fromEntries(makeCatalogTools(cfg).map((t) => [t.name, t])) + it('exposes the three catalog tools', () => { + expect(Object.keys(tools).sort()).toEqual(['catalog_collections', 'catalog_item_to_layer', 'catalog_search']) + }) + it('rejects unknown catalog names with the configured list in the hint', async () => { + const res = await tools.catalog_search.handler({ catalog: 'nope' }) + expect(res.isError).toBe(true) + expect(parse(res).hint).toContain('test') + }) +}) + +describe('catalog_collections empty-match fallback', () => { + it('returns a sample of available collections when the keyword matches nothing', async () => { + clearCollectionsCache() + const fetcher = (async () => ({ + ok: true, + headers: { get: () => null }, + json: async () => ({ + collections: [ + { id: 'no2-monthly', title: 'NO2' }, + { id: 'lis-global-da-evap', title: 'Evapotranspiration' }, + ], + links: [], + }), + })) as any + const cfg2 = { stacCatalogs: { test: 'https://stac.test' }, titilerUrl: 'https://titiler.xyz' } as any + const t = Object.fromEntries(makeCatalogTools(cfg2, fetcher).map((x) => [x.name, x])) + const out = parse(await t.catalog_collections.handler({ catalog: 'test', keyword: 'air quality' })) + expect(out.collections).toEqual([]) + expect(out.hint).toMatch(/re-search/) + expect(out.availableCollections).toContain('no2-monthly') + expect(out.totalCollections).toBe(2) + }) +}) + +describe('tilerForItem', () => { + const cfg3 = { + stacCatalogs: { veda: 'https://openveda.cloud/api/stac', 'earth-search': 'https://earth-search.aws.element84.com/v1' }, + stacTilers: { veda: 'https://openveda.cloud/api/raster' }, + titilerUrl: 'https://titiler.xyz', + } as any + it('uses the catalog-dedicated tiler when the item origin matches', async () => { + const { tilerForItem } = await import('../src/tools/catalog.js') + expect(tilerForItem(cfg3, 'https://openveda.cloud/api/stac/collections/no2-monthly/items/x')).toEqual({ titilerUrl: 'https://openveda.cloud/api/raster', urlStyle: 'item-path' }) + }) + it('falls back to the generic tiler otherwise', async () => { + const { tilerForItem } = await import('../src/tools/catalog.js') + expect(tilerForItem(cfg3, 'https://earth-search.aws.element84.com/v1/collections/c/items/i')).toEqual({ titilerUrl: 'https://titiler.xyz', urlStyle: 'stac-url' }) + expect(tilerForItem(cfg3, null)).toEqual({ titilerUrl: 'https://titiler.xyz', urlStyle: 'stac-url' }) + expect(tilerForItem(cfg3, 'not a url')).toEqual({ titilerUrl: 'https://titiler.xyz', urlStyle: 'stac-url' }) + }) + it('prefers a direct catalogName -> tiler lookup over origin matching', async () => { + const { tilerForItem } = await import('../src/tools/catalog.js') + // Origin doesn't match any configured catalog, but the item carries + // catalogName 'veda' (stamped by searchStac) — direct lookup wins. + expect(tilerForItem(cfg3, 'https://unrelated.example.com/items/x', 'veda')).toEqual({ + titilerUrl: 'https://openveda.cloud/api/raster', + urlStyle: 'item-path', + }) + // Unknown catalogName falls back to origin matching / generic tiler. + expect(tilerForItem(cfg3, null, 'not-configured')).toEqual({ titilerUrl: 'https://titiler.xyz', urlStyle: 'stac-url' }) + }) +}) diff --git a/mcp/tests/config.spec.ts b/mcp/tests/config.spec.ts new file mode 100644 index 000000000..e5aefb737 --- /dev/null +++ b/mcp/tests/config.spec.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { loadConfig } from '../src/config.js' + +const base = { MMGIS_TOKEN: 'tok123' } + +describe('loadConfig', () => { + it('throws without MMGIS_TOKEN', () => { + expect(() => loadConfig({})).toThrow(/MMGIS_TOKEN/) + }) + it('defaults MMGIS_URL to localhost:8888 and strips trailing slashes', () => { + expect(loadConfig({ ...base }).mmgisUrl).toBe('http://localhost:8888') + expect(loadConfig({ ...base, MMGIS_URL: 'https://gis.example.com/' }).mmgisUrl).toBe('https://gis.example.com') + }) + it('derives wsUrl from mmgisUrl unless MMGIS_WS_URL is set', () => { + expect(loadConfig({ ...base }).wsUrl).toBe('ws://localhost:8888/') + expect(loadConfig({ ...base, MMGIS_URL: 'https://gis.example.com' }).wsUrl).toBe('wss://gis.example.com/') + expect(loadConfig({ ...base, MMGIS_WS_URL: 'ws://elsewhere:9000/' }).wsUrl).toBe('ws://elsewhere:9000/') + expect(loadConfig({ ...base, MMGIS_WS_URL: 'ws://elsewhere:9000' }).wsUrl).toBe('ws://elsewhere:9000/') + }) + it('parses STAC_CATALOGS JSON and falls back to defaults', () => { + expect(loadConfig({ ...base, STAC_CATALOGS: '{"mine":"https://stac.me"}' }).stacCatalogs).toEqual({ mine: 'https://stac.me' }) + expect(Object.keys(loadConfig({ ...base }).stacCatalogs)).toContain('veda') + }) + it('rejects STAC_CATALOGS that parses but is not a {name: url} object', () => { + expect(() => loadConfig({ ...base, STAC_CATALOGS: '[1,2]' })).toThrow(/STAC_CATALOGS must be/) + expect(() => loadConfig({ ...base, STAC_CATALOGS: '{"a": 5}' })).toThrow(/STAC_CATALOGS must be/) + }) + it('resolves repoRoot to the MMGIS checkout by default', () => { + expect(loadConfig({ ...base }).repoRoot.endsWith('MMGIS')).toBe(true) + }) +}) diff --git a/mcp/tests/configEdit.spec.ts b/mcp/tests/configEdit.spec.ts new file mode 100644 index 000000000..317b03920 --- /dev/null +++ b/mcp/tests/configEdit.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from 'vitest' +import { mergePatch, editConfig, findLayerIndex } from '../src/configEdit.js' + +describe('mergePatch (RFC 7386)', () => { + it.each([ + ['nested objects merge', { a: { b: 1, c: 2 } }, { a: { c: 3 } }, { a: { b: 1, c: 3 } }], + ['null deletes a key', { a: 1, b: 2 }, { b: null }, { a: 1 }], + ['arrays replace wholesale', { a: [1, 2] }, { a: [3] }, { a: [3] }], + ['scalars replace', { a: 1 }, { a: 'x' }, { a: 'x' }], + ['non-object patch replaces target', { a: 1 }, 'str', 'str'], + ['new nested keys are created', { a: {} }, { a: { b: { c: 1 } } }, { a: { b: { c: 1 } } }], + ['null inside new object is dropped', {}, { a: { b: null } }, { a: {} }], + ])('%s', (_name, target, patch, expected) => { + expect(mergePatch(target, patch)).toEqual(expected) + }) + it('does not mutate the target', () => { + const target = { a: { b: 1 } } + mergePatch(target, { a: { b: 2 } }) + expect(target.a.b).toBe(1) + }) +}) + +describe('editConfig', () => { + function fakeClient(config: any) { + return { + getMission: vi.fn(async () => ({ mission: 'M', config, version: 3 })), + upsertMission: vi.fn(async () => ({ mission: 'M', version: 4 })), + } as any + } + it('fetches, mutates a clone, and upserts with forceClientUpdate and default info', async () => { + const original = { look: { pagename: 'Old' }, layers: [] } + const client = fakeClient(original) + const out = await editConfig(client, 'M', (config) => { + config.look.pagename = 'New' + }) + expect(out.version).toBe(4) + expect(original.look.pagename).toBe('Old') + const [mission, sent, opts] = client.upsertMission.mock.calls[0] + expect(mission).toBe('M') + expect(sent.look.pagename).toBe('New') + expect(opts).toEqual({ forceClientUpdate: true, info: { type: 'upsert' } }) + }) + it('uses the info returned by the mutator', async () => { + const client = fakeClient({ layers: [] }) + await editConfig(client, 'M', (config) => { + config.layers.push({ name: 'L' }) + return { info: { type: 'addLayer', layerName: 'L' } } + }) + expect(client.upsertMission.mock.calls[0][2]).toEqual({ + forceClientUpdate: true, info: { type: 'addLayer', layerName: 'L' }, + }) + }) +}) + +describe('findLayerIndex', () => { + const config = { layers: [{ name: 'A', uuid: 'u1' }, { name: 'B', uuid: 'u2' }] } + it('finds by name and by uuid', () => { + expect(findLayerIndex(config, 'B')).toBe(1) + expect(findLayerIndex(config, 'u1')).toBe(0) + }) + it('returns -1 for unknown and missing layers array', () => { + expect(findLayerIndex(config, 'nope')).toBe(-1) + expect(findLayerIndex({}, 'A')).toBe(-1) + }) +}) diff --git a/mcp/tests/dashboard.spec.ts b/mcp/tests/dashboard.spec.ts new file mode 100644 index 000000000..7e0e5f5ff --- /dev/null +++ b/mcp/tests/dashboard.spec.ts @@ -0,0 +1,220 @@ +import { describe, it, expect } from 'vitest' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { makeDashboardTools } from '../src/tools/dashboard.js' +import { MMGISError } from '../src/mmgisClient.js' +import { parse } from './helpers.js' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') +const cfg = { + mmgisUrl: 'http://mm:8888', + mmgisToken: 't', + wsUrl: 'ws://mm:8888/', + repoRoot, + mapboxToken: 'pk.test', + stacCatalogs: {}, + titilerUrl: 'https://titiler.xyz', +} as any + +describe('dashboard tools', () => { + it('dashboard_profile_schema documents the DashboardSpec shape with layer examples', async () => { + const tools = Object.fromEntries(makeDashboardTools({} as any, cfg).map((t) => [t.name, t])) + const schema = parse(await tools.dashboard_profile_schema.handler({})) + expect(schema.spec.mission).toBeDefined() + expect(schema.layerExamples.tile.type).toBe('TileLayer') + expect(schema.layerExamples.geojson.type).toBe('GeoJsonLayer') + }) + + it('dashboard_generate builds, generates, injects AgentBridge, resolves tokens, and adds the mission', async () => { + const calls: any[] = [] + const client = { + addMission: async (mission: string, config: any) => { + calls.push({ mission, config }) + return { mission, version: 0 } + }, + } as any + const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) + const out = parse( + await tools.dashboard_generate.handler({ + mission: 'AQ Test', + view: { lat: 33.7, lon: -84.4, zoom: 9 }, + layers: [ + { + name: 'NO2', + type: 'TileLayer', + sourceType: 'url', + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + tileformat: 'wmts', + controlled: false, + initialOpacity: 1, + minZoom: 0, + maxNativeZoom: 18, + maxZoom: 22, + }, + ], + }) + ) + expect(out.mission).toBe('AQ Test') + expect(out.url).toBe('http://mm:8888/?mission=AQ%20Test') + const posted = calls[0].config + expect(posted.components).toEqual([{ name: 'AgentBridge', js: 'AgentBridge', on: true, variables: {} }]) + expect(JSON.stringify(posted)).not.toContain('{{MAPBOX_TOKEN}}') + expect(posted.msv.basemap.accessToken).toBe('pk.test') + }, 30000) + + it('falls back to upsert when the mission exists and updateExisting is set', async () => { + const client = { + addMission: async () => { + throw new MMGISError('Mission already exists.') + }, + upsertMission: async (mission: string) => ({ mission, version: 4 }), + } as any + const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) + const out = parse(await tools.dashboard_generate.handler({ mission: 'AQ Test', updateExisting: true })) + expect(out.version).toBe(4) + }, 30000) + + it('rejects an invalid mission name before doing any generation or client work', async () => { + const calls: any[] = [] + const client = { + addMission: async (mission: string, config: any) => { + calls.push({ mission, config }) + return { mission, version: 0 } + }, + } as any + const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) + // Hyphens are in configs.js's forbidden-character set (`add()`, ~line 265), + // so this mirrors what /api/configure/add would reject — but preflighted + // here instead of after an expensive generate. + const res = await tools.dashboard_generate.handler({ mission: 'air-quality-atlanta' }) + expect(res.isError).toBe(true) + const parsed = parse(res) + expect(parsed.error).toMatch(/air-quality-atlanta/) + expect(parsed.hint).toBeTruthy() + expect(calls).toHaveLength(0) + }) + + it('dashboard_profile_schema and the missionName schema describe the mission-name character rule', async () => { + const tools = Object.fromEntries(makeDashboardTools({} as any, cfg).map((t) => [t.name, t])) + const schema = parse(await tools.dashboard_profile_schema.handler({})) + expect(schema.spec.mission).toMatch(/must not contain/) + }) + + it('warns when MAPBOX_TOKEN is unset and the generated config needed it', async () => { + const calls: any[] = [] + const client = { + addMission: async (mission: string, config: any) => { + calls.push({ mission, config }) + return { mission, version: 0 } + }, + } as any + const noTokenCfg = { ...cfg, mapboxToken: '' } + const tools = Object.fromEntries(makeDashboardTools(client, noTokenCfg).map((t) => [t.name, t])) + const out = parse(await tools.dashboard_generate.handler({ mission: 'AQ Test' })) + expect(out.warnings).toEqual(['MAPBOX_TOKEN is not set — using the free MapLibre demo basemap instead']) + expect(calls[0].config.msv.basemap).toEqual({ + provider: 'maplibre', + style: 'https://demotiles.maplibre.org/style.json', + }) + }, 30000) + + it('does not warn about MAPBOX_TOKEN when it is set', async () => { + const client = { + addMission: async (mission: string) => ({ mission, version: 0 }), + } as any + const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) + const out = parse(await tools.dashboard_generate.handler({ mission: 'AQ Test' })) + expect(out.warnings).toBeUndefined() + }, 30000) + + it('reports exists-error with a hint when updateExisting is not set', async () => { + const client = { + addMission: async () => { + throw new MMGISError('Mission already exists.') + }, + } as any + const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) + const res = await tools.dashboard_generate.handler({ mission: 'AQ Test' }) + expect(res.isError).toBe(true) + expect(parse(res).hint).toMatch(/updateExisting/) + }, 30000) + + it('dashboard_generate includes the full config when returnConfig is true', async () => { + const client = { addMission: async (m: string) => ({ mission: m, version: 0 }) } as any + const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) + const out = parse(await tools.dashboard_generate.handler({ mission: 'RC Test', returnConfig: true })) + expect(out.config.msv.mission).toBe('RC Test') + const without = parse(await tools.dashboard_generate.handler({ mission: 'RC Test' })) + expect(without.config).toBeUndefined() + }, 30000) + + it('dashboard_create_from_config installs raw config with placeholder resolution and component injection', async () => { + const calls: any[] = [] + const client = { + addMission: async (mission: string, config: any) => { + calls.push({ mission, config }) + return { mission, version: 0 } + }, + } as any + const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) + const rawConfig = { + msv: { mission: 'From JSON', basemap: { accessToken: '{{MAPBOX_TOKEN}}' } }, + layers: [], + } + const out = parse( + await tools.dashboard_create_from_config.handler({ mission: 'From JSON', config: rawConfig }) + ) + expect(out.url).toBe('http://mm:8888/?mission=From%20JSON') + expect(calls[0].config.components).toEqual([ + { name: 'AgentBridge', js: 'AgentBridge', on: true, variables: {} }, + ]) + expect(calls[0].config.msv.basemap.accessToken).toBe('pk.test') + }) + + it('dashboard_create_from_config swaps to the MapLibre demo basemap when tokenless and mapbox-provider', async () => { + const calls: any[] = [] + const client = { + addMission: async (mission: string, config: any) => { + calls.push({ mission, config }) + return { mission, version: 0 } + }, + } as any + const noTokenCfg = { ...cfg, mapboxToken: '' } + const tools = Object.fromEntries(makeDashboardTools(client, noTokenCfg).map((t) => [t.name, t])) + const rawConfig = { + msv: { mission: 'From JSON', basemap: { provider: 'mapbox', accessToken: '{{MAPBOX_TOKEN}}' } }, + layers: [], + } + const out = parse( + await tools.dashboard_create_from_config.handler({ mission: 'From JSON', config: rawConfig }) + ) + expect(out.warnings).toEqual(['MAPBOX_TOKEN is not set — using the free MapLibre demo basemap instead']) + expect(calls[0].config.msv.basemap).toEqual({ + provider: 'maplibre', + style: 'https://demotiles.maplibre.org/style.json', + }) + }) + + it('dashboard_create_from_config keeps caller-provided components untouched', async () => { + const calls: any[] = [] + const client = { + addMission: async (mission: string, config: any) => { + calls.push({ mission, config }) + return { mission, version: 0 } + }, + } as any + const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) + await tools.dashboard_create_from_config.handler({ + mission: 'From JSON', + config: { components: [{ name: 'X', js: 'X', on: false, variables: {} }] }, + }) + expect(calls[0].config.components).toEqual([{ name: 'X', js: 'X', on: false, variables: {} }]) + }) + + it('dashboard_create_from_config rejects bad mission names before any client call', async () => { + const client = { addMission: async () => { throw new Error('should not be called') } } as any + const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) + const res = await tools.dashboard_create_from_config.handler({ mission: 'bad-name!', config: {} }) + expect(res.isError).toBe(true) + }) +}) diff --git a/mcp/tests/edit.spec.ts b/mcp/tests/edit.spec.ts new file mode 100644 index 000000000..713613254 --- /dev/null +++ b/mcp/tests/edit.spec.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from 'vitest' +import { makeEditTools } from '../src/tools/edit.js' +import { parse } from './helpers.js' + +function fakeClient(config: any) { + return { + getMission: vi.fn(async () => ({ mission: 'M', config, version: 1 })), + upsertMission: vi.fn(async (_m: string, cfg: any) => ({ mission: 'M', version: 2, _sent: cfg })), + } as any +} + +const baseConfig = () => ({ + look: { pagename: 'Old' }, + layers: [{ name: 'OSM', uuid: 'u1', visibility: true }], + tools: [{ name: 'LayerManager', on: true }, { name: 'Chart', on: false }], +}) + +describe('edit tools', () => { + const tools = (client: any) => Object.fromEntries(makeEditTools(client).map((t) => [t.name, t])) + + it('exposes exactly the five editing tools', () => { + expect(Object.keys(tools(fakeClient({}))).sort()).toEqual([ + 'layer_add', 'layer_remove', 'layer_update', 'mission_update_config', 'tool_toggle', + ]) + }) + + it('mission_update_config applies a merge patch and upserts with reload info', async () => { + const client = fakeClient(baseConfig()) + const out = parse(await tools(client).mission_update_config.handler({ + mission: 'M', patch: { look: { pagename: 'New' } }, + })) + expect(out.version).toBe(2) + expect(out.refresh).toMatch(/reload/i) + const sent = client.upsertMission.mock.calls[0][1] + expect(sent.look.pagename).toBe('New') + expect(sent.layers).toHaveLength(1) + expect(client.upsertMission.mock.calls[0][2]).toEqual({ forceClientUpdate: true, info: { type: 'upsert' } }) + }) + + it('layer_add appends (or inserts at position), mints uuid, and sends addLayer info', async () => { + const client = fakeClient(baseConfig()) + const out = parse(await tools(client).layer_add.handler({ + mission: 'M', layer: { name: 'NewLayer', type: 'vector', url: 'geodatasets:g1' }, position: 0, + })) + expect(out.layer.uuid).toMatch(/^[0-9a-f-]{36}$/) + const sent = client.upsertMission.mock.calls[0][1] + expect(sent.layers[0].name).toBe('NewLayer') + expect(client.upsertMission.mock.calls[0][2].info).toEqual({ type: 'addLayer', layerName: 'NewLayer' }) + }) + + it('layer_add errors early when layer.name is missing', async () => { + const client = fakeClient(baseConfig()) + const res = await tools(client).layer_add.handler({ mission: 'M', layer: { type: 'vector' } }) + expect(res.isError).toBe(true) + expect(parse(res).hint).toBe('Layer entries need a name.') + expect(client.upsertMission).not.toHaveBeenCalled() + }) + + it('layer_update merge-patches one layer found by name or uuid', async () => { + const client = fakeClient(baseConfig()) + await tools(client).layer_update.handler({ mission: 'M', layer: 'u1', patch: { visibility: false } }) + const sent = client.upsertMission.mock.calls[0][1] + expect(sent.layers[0]).toEqual({ name: 'OSM', uuid: 'u1', visibility: false }) + expect(client.upsertMission.mock.calls[0][2].info).toEqual({ type: 'updateLayer', layerName: 'OSM' }) + }) + + it('layer_remove deletes by name and sends removeLayer info', async () => { + const client = fakeClient(baseConfig()) + await tools(client).layer_remove.handler({ mission: 'M', layer: 'OSM' }) + const sent = client.upsertMission.mock.calls[0][1] + expect(sent.layers).toHaveLength(0) + expect(client.upsertMission.mock.calls[0][2].info).toEqual({ type: 'removeLayer', layerName: 'OSM' }) + }) + + it('unknown layers error with the available names and no upsert', async () => { + const client = fakeClient(baseConfig()) + const res = await tools(client).layer_update.handler({ mission: 'M', layer: 'Nope', patch: {} }) + expect(res.isError).toBe(true) + expect(parse(res).hint).toContain('OSM') + expect(client.upsertMission).not.toHaveBeenCalled() + }) + + it('tool_toggle flips the named tool and errors on unknown tools', async () => { + const client = fakeClient(baseConfig()) + await tools(client).tool_toggle.handler({ mission: 'M', toolName: 'Chart', on: true }) + const sent = client.upsertMission.mock.calls[0][1] + expect(sent.tools.find((t: any) => t.name === 'Chart').on).toBe(true) + const res = await tools(client).tool_toggle.handler({ mission: 'M', toolName: 'Nope', on: true }) + expect(res.isError).toBe(true) + }) +}) diff --git a/mcp/tests/generator.spec.ts b/mcp/tests/generator.spec.ts new file mode 100644 index 000000000..0f5dd0177 --- /dev/null +++ b/mcp/tests/generator.spec.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { buildProfile } from '../src/profileBuilder.js' +import { generateConfig, resolvePlaceholders, listAvailableTools } from '../src/generator.js' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') + +describe('generateConfig (integration with scripts/generate-mission-config.js)', () => { + it('generates a validated config from a built profile', async () => { + const profile = buildProfile( + { + missionName: 'MCP Test', + view: { lat: 33.75, lon: -84.39, zoom: 10 }, + layers: [ + { + name: 'Basemap Test', + type: 'TileLayer', + sourceType: 'url', + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + tileformat: 'wmts', + controlled: false, + initialOpacity: 1, + minZoom: 0, + maxNativeZoom: 18, + maxZoom: 22, + }, + ], + }, + repoRoot + ) + const config = await generateConfig(profile, repoRoot) + expect(config.msv.mission).toBe('MCP Test') + expect(config.tools.map((t: any) => t.name)).toContain('Title') + expect(config.layers[0].name).toBe('Basemap Test') + }, 30000) + + it('surfaces generator validation errors with a hint', async () => { + const profile = buildProfile({ missionName: 'Bad' }, repoRoot) + delete profile.scaffold.projection // break the template superset + const err = await generateConfig(profile, repoRoot).catch((e) => e) + expect(err.name).toBe('MMGISError') + expect(err.hint).toMatch(/profile/i) + }, 30000) +}) + +describe('resolvePlaceholders', () => { + it('replaces {{MAPBOX_TOKEN}} everywhere', () => { + const out = resolvePlaceholders({ a: { token: '{{MAPBOX_TOKEN}}' } }, 'pk.test') + expect(out.a.token).toBe('pk.test') + }) +}) + +describe('listAvailableTools', () => { + it('returns the generatable tool names', async () => { + const names = await listAvailableTools(repoRoot) + expect(names).toContain('Title') + expect(names).toContain('LayerManager') + }, 30000) +}) diff --git a/mcp/tests/helpers.ts b/mcp/tests/helpers.ts new file mode 100644 index 000000000..3b5483c0d --- /dev/null +++ b/mcp/tests/helpers.ts @@ -0,0 +1,9 @@ +import { vi } from 'vitest' + +export function fakeFetch(status: number, json: unknown) { + return vi.fn(async () => ({ ok: status < 400, status, json: async () => json })) as unknown as typeof fetch +} + +export function parse(res: { content: { text: string }[] }) { + return JSON.parse(res.content[0].text) +} diff --git a/mcp/tests/mmgisClient.spec.ts b/mcp/tests/mmgisClient.spec.ts new file mode 100644 index 000000000..8901a7622 --- /dev/null +++ b/mcp/tests/mmgisClient.spec.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi } from 'vitest' +import { MmgisClient, MMGISError } from '../src/mmgisClient.js' +import { fakeFetch } from './helpers.js' + +describe('MmgisClient', () => { + it('sends the Authorization header and returns mission names', async () => { + const f = fakeFetch(200, { status: 'success', missions: ['Demo'] }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + expect(await client.listMissions()).toEqual(['Demo']) + const [url, init] = (f as any).mock.calls[0] + expect(url).toBe('http://mm:8888/api/configure/missions') + expect(init.headers.Authorization).toBe('Bearer tok') + }) + it('getMission requests full config with encoded name', async () => { + const f = fakeFetch(200, { status: 'success', mission: 'A B', config: { msv: {} }, version: 3 }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + const out = await client.getMission('A B') + expect(out.version).toBe(3) + expect((f as any).mock.calls[0][0]).toBe('http://mm:8888/api/configure/get?mission=A%20B&full=true') + }) + it('addMission POSTs {mission, config, makedir}', async () => { + const f = fakeFetch(200, { status: 'success', mission: 'X', version: 0 }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + await client.addMission('X', { msv: {} }) + const [, init] = (f as any).mock.calls[0] + expect(init.method).toBe('POST') + expect(JSON.parse(init.body)).toEqual({ mission: 'X', config: { msv: {} }, makedir: true }) + }) + it('throws MMGISError with the server message on status:failure', async () => { + const f = fakeFetch(200, { status: 'failure', message: 'Mission already exists.' }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + await expect(client.addMission('X', {})).rejects.toThrow('Mission already exists.') + }) + it('throws MMGISError when the response body is not valid JSON (e.g. a proxy/login-page response)', async () => { + const f = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError('Unexpected token < in JSON at position 0') + }, + })) as unknown as typeof fetch + const client = new MmgisClient('http://mm:8888', 'tok', f) + const err = await client.listMissions().catch((e) => e) + expect(err).toBeInstanceOf(MMGISError) + expect(err.message).toMatch(/non-JSON response/) + expect(err.message).toMatch(/api\/configure\/missions/) + expect(err.hint).toMatch(/MMGIS_URL/) + }) + it('throws MMGISError with a hint on HTTP errors', async () => { + const f = fakeFetch(500, {}) + const client = new MmgisClient('http://mm:8888', 'tok', f) + const err = await client.listMissions().catch((e) => e) + expect(err).toBeInstanceOf(MMGISError) + expect(err.hint).toMatch(/MMGIS_URL/) + }) + it('upsertMission POSTs {mission, config} with no makedir and returns the parsed response', async () => { + const f = fakeFetch(200, { status: 'success', mission: 'X', version: 1 }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + const out = await client.upsertMission('X', { msv: {} }) + const [url, init] = (f as any).mock.calls[0] + expect(url).toBe('http://mm:8888/api/configure/upsert') + expect(init.method).toBe('POST') + expect(JSON.parse(init.body)).toEqual({ mission: 'X', config: { msv: {} } }) + expect(out).toEqual({ status: 'success', mission: 'X', version: 1 }) + }) + it('throws MMGISError with the base URL and an MMGIS_URL hint when the transport fails', async () => { + const f = vi.fn(async () => { + throw new Error('ECONNREFUSED') + }) as unknown as typeof fetch + const client = new MmgisClient('http://mm:8888', 'tok', f) + const err = await client.listMissions().catch((e) => e) + expect(err).toBeInstanceOf(MMGISError) + expect(err.message).toMatch('http://mm:8888') + expect(err.hint).toMatch(/MMGIS_URL/) + }) + it('upsertMission passes forceClientUpdate and info only when provided', async () => { + const f = fakeFetch(200, { status: 'success', mission: 'X', version: 2 }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + await client.upsertMission('X', { a: 1 }) + expect(JSON.parse((f as any).mock.calls[0][1].body)).toEqual({ mission: 'X', config: { a: 1 } }) + await client.upsertMission('X', { a: 1 }, { forceClientUpdate: true, info: { type: 'updateLayer', layerName: 'L' } }) + expect(JSON.parse((f as any).mock.calls[1][1].body)).toEqual({ + mission: 'X', config: { a: 1 }, forceClientUpdate: true, info: { type: 'updateLayer', layerName: 'L' }, + }) + }) + it('cloneMission and destroyMission hit the configure endpoints', async () => { + const f = fakeFetch(200, { status: 'success' }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + await client.cloneMission('A', 'B') + expect((f as any).mock.calls[0][0]).toBe('http://mm:8888/api/configure/clone') + expect(JSON.parse((f as any).mock.calls[0][1].body)).toEqual({ existingMission: 'A', cloneMission: 'B' }) + await client.destroyMission('A') + expect((f as any).mock.calls[1][0]).toBe('http://mm:8888/api/configure/destroy') + expect(JSON.parse((f as any).mock.calls[1][1].body)).toEqual({ mission: 'A' }) + }) + it('geodataset methods use the right verbs, paths, and raw bodies', async () => { + const f = fakeFetch(200, { status: 'success', body: { entries: [{ name: 'g1' }] } }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + expect(await client.geodatasetEntries()).toEqual([{ name: 'g1' }]) + expect((f as any).mock.calls[0][1].method).toBe('POST') + const fc = { type: 'FeatureCollection', features: [] } + await client.geodatasetRecreate('my set', fc) + expect((f as any).mock.calls[1][0]).toBe('http://mm:8888/api/geodatasets/recreate/my%20set') + expect(JSON.parse((f as any).mock.calls[1][1].body)).toEqual(fc) + await client.geodatasetRemove('my set') + expect((f as any).mock.calls[2][1].method).toBe('DELETE') + expect((f as any).mock.calls[2][0]).toBe('http://mm:8888/api/geodatasets/remove/my%20set') + }) + it('account and signup methods match the backend wire shapes', async () => { + const f = fakeFetch(200, { status: 'success', body: { entries: [{ id: 1, username: 'admin' }] } }) + const client = new MmgisClient('http://mm:8888', 'tok', f) + expect(await client.accountEntries()).toEqual([{ id: 1, username: 'admin' }]) + expect((f as any).mock.calls[0][1].method === undefined || (f as any).mock.calls[0][1].method === 'GET').toBe(true) + await client.accountUpdate({ id: 2, permission: '110', missionsManaging: ['Demo'] }) + expect(JSON.parse((f as any).mock.calls[1][1].body)).toEqual({ id: 2, permission: '110', missions_managing: ['Demo'] }) + await client.userSignup('alice', 'Str0ng!Pass') + expect((f as any).mock.calls[2][0]).toBe('http://mm:8888/api/users/signup') + expect(JSON.parse((f as any).mock.calls[2][1].body)).toEqual({ username: 'alice', password: 'Str0ng!Pass', skipLogin: true }) + }) +}) diff --git a/mcp/tests/profileBuilder.spec.ts b/mcp/tests/profileBuilder.spec.ts new file mode 100644 index 000000000..762d10a0a --- /dev/null +++ b/mcp/tests/profileBuilder.spec.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { buildProfile } from '../src/profileBuilder.js' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') + +describe('buildProfile', () => { + it('bases the profile on minimal.json with mission name applied', () => { + const p = buildProfile({ missionName: 'AQ Atlanta' }, repoRoot) + expect(p.scaffold.msv.mission).toBe('AQ Atlanta') + expect(p.scaffold.msv.missionFolderName).toBe('AQ Atlanta') + expect(p.tools).toContain('Title') + expect(p.tools).toContain('LayerManager') + expect(p.output).toBeUndefined() + }) + it('applies view as a string triple and pageName', () => { + const p = buildProfile( + { missionName: 'M', view: { lat: 33.75, lon: -84.39, zoom: 10 }, pageName: 'Air Quality' }, + repoRoot + ) + expect(p.scaffold.msv.view).toEqual(['33.75', '-84.39', '10']) + expect(p.scaffold.look.pagename).toBe('Air Quality') + }) + it('mints uuids for layers that lack one and fills envelope defaults', () => { + const p = buildProfile( + { missionName: 'M', layers: [{ name: 'NO2', type: 'TileLayer', url: 'https://t/{z}/{x}/{y}.png' }] }, + repoRoot + ) + const layer = p.scaffold.layers[0] + expect(layer.uuid).toMatch(/^[0-9a-f-]{36}$/) + expect(layer.sublayers).toEqual([]) + expect(layer.visibility).toBe(true) + expect(layer.name).toBe('NO2') + }) + it('merges extra tools and overrides without dropping the minimal set', () => { + const p = buildProfile( + { missionName: 'M', tools: ['Chart'], on: ['Chart'], overrides: { Chart: { variables: { a: 1 } } } }, + repoRoot + ) + expect(p.tools).toEqual(expect.arrayContaining(['Title', 'LayerManager', 'Chart'])) + expect(p.on).toContain('Chart') + expect(p.overrides.Chart.variables.a).toBe(1) + }) +}) diff --git a/mcp/tests/server.spec.ts b/mcp/tests/server.spec.ts new file mode 100644 index 000000000..3f528112e --- /dev/null +++ b/mcp/tests/server.spec.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { buildServer } from '../src/server.js' +import { makeAdminTools } from '../src/tools/admin.js' + +describe('buildServer', () => { + it('registers tools and answers listTools over MCP', async () => { + const server = buildServer({ tools: makeAdminTools({ listMissions: async () => [] } as any) }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + const client = new Client({ name: 'test', version: '0.0.0' }) + await client.connect(clientTransport) + const { tools } = await client.listTools() + expect(tools.map((t) => t.name)).toContain('mission_list') + }) +}) diff --git a/mcp/tests/stac.spec.ts b/mcp/tests/stac.spec.ts new file mode 100644 index 000000000..06c2f6588 --- /dev/null +++ b/mcp/tests/stac.spec.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { searchStac, searchCollections, stacItemToTileLayer, clearCollectionsCache } from '../src/stac.js' +import { MMGISError } from '../src/mmgisClient.js' +import { fakeFetch } from './helpers.js' + +const ITEM = { + id: 'i1', + collection: 'no2-monthly', + bbox: [-90, 30, -80, 40], + properties: { datetime: '2026-06-01T00:00:00Z' }, + links: [{ rel: 'self', href: 'https://stac.test/collections/no2-monthly/items/i1' }], + assets: { cog_default: { href: 'https://data.test/i1.tif', type: 'image/tiff', title: 'COG' } }, +} + +beforeEach(() => { + // Collections are cached per catalogUrl for 5 minutes; several tests here + // reuse 'https://stac.test' with different fetch mocks, so start fresh. + clearCollectionsCache() +}) + +describe('searchStac', () => { + it('POSTs to /search and summarizes items', async () => { + const f = fakeFetch(200, { features: [ITEM] }) + const items = await searchStac('https://stac.test', { bbox: [-90, 30, -80, 40], collections: ['no2-monthly'], limit: 5 }, f) + expect((f as any).mock.calls[0][0]).toBe('https://stac.test/search') + expect(JSON.parse((f as any).mock.calls[0][1].body)).toEqual({ + bbox: [-90, 30, -80, 40], + collections: ['no2-monthly'], + limit: 5, + }) + expect(items[0]).toEqual({ + id: 'i1', + collection: 'no2-monthly', + datetime: '2026-06-01T00:00:00Z', + bbox: [-90, 30, -80, 40], + selfHref: 'https://stac.test/collections/no2-monthly/items/i1', + assets: [{ key: 'cog_default', title: 'COG', type: 'image/tiff', href: 'https://data.test/i1.tif' }], + }) + }) + + it('rejects with degradation hint when STAC responds with non-OK status', async () => { + const f = vi.fn(async () => ({ ok: false, status: 503 })) as unknown as typeof fetch + try { + await searchStac('https://stac.test', {}, f) + expect.fail('Should have thrown MMGISError') + } catch (err) { + expect(err).toBeInstanceOf(MMGISError) + expect((err as MMGISError).hint).toMatch(/another configured catalog/) + } + }) +}) + +describe('searchCollections', () => { + it('filters collections by keyword across id/title/description', async () => { + const f = fakeFetch(200, { + collections: [ + { id: 'no2-monthly', title: 'NO2 Monthly', description: 'Nitrogen dioxide' }, + { id: 'dem', title: 'Elevation', description: 'Terrain' }, + ], + }) + const out = await searchCollections('https://stac.test', 'nitrogen', f) + expect(out.map((c) => c.id)).toEqual(['no2-monthly']) + }) + + it('follows rel=next links to collect paginated collections before filtering', async () => { + const page1Collections = Array.from({ length: 10 }, (_, i) => ({ + id: `other-${i}`, + title: `Other ${i}`, + description: 'Not it', + })) + const page1 = { + collections: page1Collections, + links: [{ rel: 'next', href: 'https://stac.test/collections?page=2' }], + } + const page2 = { + collections: [{ id: 'no2-monthly', title: 'NO2 Monthly', description: 'Nitrogen dioxide' }], + links: [], + } + const f = vi + .fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => page1 }) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => page2 }) as unknown as typeof fetch + const out = await searchCollections('https://stac.test', 'no2', f) + expect(out.map((c) => c.id)).toEqual(['no2-monthly']) + expect((f as any).mock.calls.length).toBe(2) + expect((f as any).mock.calls[1][0]).toBe('https://stac.test/collections?page=2') + }) +}) + +describe('stacItemToTileLayer', () => { + it('builds a TileLayer entry with a titiler stac tile URL', () => { + const item = { + id: 'i1', + collection: 'no2-monthly', + datetime: '2026-06-01T00:00:00Z', + bbox: [-90, 30, -80, 40], + selfHref: 'https://stac.test/collections/no2-monthly/items/i1', + assets: [{ key: 'cog_default', href: 'https://data.test/i1.tif' }], + } + const layer = stacItemToTileLayer(item as any, { name: 'NO2 June', titilerUrl: 'https://titiler.xyz', asset: 'cog_default' }) + expect(layer.type).toBe('TileLayer') + expect(layer.name).toBe('NO2 June') + expect(layer.boundingBox).toEqual([-90, 30, -80, 40]) + expect(layer.url).toBe( + 'https://titiler.xyz/stac/tiles/WebMercatorQuad/{z}/{x}/{y}.png?url=' + + encodeURIComponent('https://stac.test/collections/no2-monthly/items/i1') + + '&assets=cog_default' + ) + }) + + it('encodes rescale and colormap parameters correctly', () => { + const item = { + id: 'i1', + collection: 'no2-monthly', + datetime: '2026-06-01T00:00:00Z', + bbox: [-90, 30, -80, 40], + selfHref: 'https://stac.test/collections/no2-monthly/items/i1', + assets: [{ key: 'cog_default', href: 'https://data.test/i1.tif' }], + } + const layer = stacItemToTileLayer(item as any, { + name: 'NO2 June', + titilerUrl: 'https://titiler.xyz', + asset: 'cog_default', + rescale: '0,255', + colormap: 'viridis', + }) + expect(layer.url).toBe( + 'https://titiler.xyz/stac/tiles/WebMercatorQuad/{z}/{x}/{y}.png?url=' + + encodeURIComponent('https://stac.test/collections/no2-monthly/items/i1') + + '&assets=' + + encodeURIComponent('cog_default') + + '&rescale=' + + encodeURIComponent('0,255') + + '&colormap_name=' + + encodeURIComponent('viridis') + ) + }) +}) + +describe('stacItemToTileLayer item-path style', () => { + it('builds titiler-pgstac item-path URLs', () => { + const item = { + id: 'OMI_x.nc', + collection: 'no2-monthly', + datetime: null, + bbox: [-180, -90, 180, 90], + selfHref: 'https://openveda.cloud/api/stac/collections/no2-monthly/items/OMI_x.nc', + assets: [{ key: 'cog_default', href: 'https://x/y.tif' }], + } + const layer = stacItemToTileLayer(item as any, { + name: 'NO2', titilerUrl: 'https://openveda.cloud/api/raster', urlStyle: 'item-path', rescale: '0,3e15', colormap: 'reds', + }) + expect(layer.url).toBe( + 'https://openveda.cloud/api/raster/collections/no2-monthly/items/OMI_x.nc/tiles/WebMercatorQuad/{z}/{x}/{y}.png?assets=cog_default&rescale=0%2C3e15&colormap_name=reds' + ) + }) +}) + +describe('searchCollections coverage fields', () => { + it('includes temporal and spatial extents when the catalog provides them', async () => { + const f = (async () => ({ + ok: true, headers: { get: () => null }, + json: async () => ({ + collections: [{ + id: 'tx-flood-imerg', title: 'IMERG 2025 Texas Flood', + extent: { temporal: { interval: [['2025-07-07', '2025-07-07']] }, spatial: { bbox: [[-106.7, 25.8, -93.5, 36.6]] } }, + }], + links: [], + }), + })) as any + const out = await searchCollections('https://stac.test', undefined, f) + expect(out[0].temporalExtent).toEqual(['2025-07-07', '2025-07-07']) + expect(out[0].spatialExtent).toEqual([-106.7, 25.8, -93.5, 36.6]) + }) +}) diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json new file mode 100644 index 000000000..f5db0a5d8 --- /dev/null +++ b/mcp/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "declaration": false, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/mcp/vitest.config.ts b/mcp/vitest.config.ts new file mode 100644 index 000000000..d87fc4a69 --- /dev/null +++ b/mcp/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}) diff --git a/package.json b/package.json index ac1f85f6b..1e9305b0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mmgis", - "version": "4.2.18-20260717", + "version": "4.2.19-20260728", "description": "A web-based mapping and localization solution for science operation on planetary missions.", "homepage": "build", "repository": { diff --git a/scripts/server.js b/scripts/server.js index d1c0d40ac..baa8f1181 100644 --- a/scripts/server.js +++ b/scripts/server.js @@ -297,6 +297,23 @@ function ensureGroup(allowedGroups) { }; } +/** + * decorateReqFromTokenData - Stamps a validated long-term token's user data + * onto the request object. Shared by ensureAdmin, ensureUser, and + * annotateLongTermToken so the three auth middlewares agree on exactly what + * a long-term token grants. + * + * @param {object} req - Express request object (mutated in place). + * @param {object} tokenData - Row returned by validateLongTermToken's + * successCallback (has .permission, .missions_managing, .username). + */ +function decorateReqFromTokenData(req, tokenData) { + req.isLongTermToken = true; + req.tokenUserPermission = tokenData.permission; + req.tokenUserMissions = tokenData.missions_managing; + req.user = tokenData.username; +} + function ensureAdmin( toLoginPage, denyLongTermTokens, @@ -358,10 +375,7 @@ function ensureAdmin( validateLongTermToken( req.headers.authorization, (tokenData) => { - req.isLongTermToken = true; - req.tokenUserPermission = tokenData.permission; - req.tokenUserMissions = tokenData.missions_managing; - req.user = tokenData.username; + decorateReqFromTokenData(req, tokenData); next(); }, () => { @@ -405,6 +419,7 @@ function validateLongTermToken(token, successCallback, failureCallback) { result = result[0][0]; } catch (err) { failureCallback(); + return; } if ( @@ -419,6 +434,9 @@ function validateLongTermToken(token, successCallback, failureCallback) { } else { failureCallback(); } + }) + .catch((err) => { + failureCallback(); }); } @@ -450,10 +468,7 @@ function ensureUser() { validateLongTermToken( req.headers.authorization, (tokenData) => { - req.isLongTermToken = true; - req.tokenUserPermission = tokenData.permission; - req.tokenUserMissions = tokenData.missions_managing; - req.user = tokenData.username; + decorateReqFromTokenData(req, tokenData); next(); }, () => { @@ -479,6 +494,19 @@ function ensureUser() { }; } +function annotateLongTermToken(req, res, next) { + if (req.headers.authorization) { + validateLongTermToken( + req.headers.authorization, + (tokenData) => { + decorateReqFromTokenData(req, tokenData); + next(); + }, + () => next() + ); + } else next(); +} + var swaggerOptions = { customCssUrl: "/docs/swagger/swaggerCSS.css", customJs: "/docs/swagger/swaggerJS.js", @@ -503,6 +531,7 @@ let s = { ensureGroup, ensureAdmin, ensureUser, + annotateLongTermToken, swaggerUi, useSwaggerSchema, permissions, diff --git a/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js new file mode 100644 index 000000000..c5d427dcd --- /dev/null +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js @@ -0,0 +1,214 @@ +import Map_ from '../../Basics/Map_/Map_' +import L_ from '../../Basics/Layers_/Layers_' +import ToolController_ from '../../Basics/ToolController_/ToolController_' +import TimeControl from '../../Basics/TimeControl_/TimeControl' +import { isStaticBuild } from '../../../pre/capabilities' +import { executeCommand, resolveToolId, sameMission, shouldReloadForFrame } from './commands' + +// Envelope contract shared with mcp/src/bridge.ts — keep in sync. +const FRAME_TYPE = 'agent-bridge' +const RECONNECT_MS = 10000 +// Coalesce bursts of config-mutation broadcasts (e.g. several layer_add +// calls in a row) into a single reload instead of reloading per-frame. +const RELOAD_DEBOUNCE_MS = 800 + +// Shared modern-vs-classic mode check. Modern missions set +// L_.configData.msv.mode === 'modern'; everything else is classic. +// Kept as a single source of truth so the tool adapter and the +// auto-reload gate below never disagree about which mode is active. +function isModernMission() { + return !!(L_.configData && L_.configData.msv && L_.configData.msv.mode === 'modern') +} + +// Builds the tool-activation adapter `commands.js` uses for `open_tool` / +// `get_view_state`. Classic missions run the exclusive-panel ToolController_; +// modern missions (L_.configData.msv.mode === 'modern') never instantiate it +// — only ToolControllerModern_ runs, wired up behind window.mmgisAPI's +// show/hide/load-plugin API. Keeping the mode switch here (not in commands.js) +// lets commands.js stay a plain, dependency-injected, unit-testable module. +// Shared result shape for the modern openTool branches below. +function toOpenResult(ok, toolId, name) { + return ok + ? { ok: true, activeTool: toolId } + : { ok: false, error: `Unknown or unopenable tool: ${name}` } +} + +function buildToolAdapter() { + const isModern = isModernMission() + + if (isModern) { + return { + mode: 'modern', + // Modern layout has no single exclusive "active tool" — panels can + // show many tools at once — so there is nothing honest to report here. + activeToolName: function () { + return null + }, + openTool: function (name) { + const api = window.mmgisAPI + if (!api || typeof api.isPluginLoaded !== 'function') { + return { + ok: false, + error: 'open_tool is not supported in modern mode yet', + } + } + const toolId = resolveToolId(L_.configData && L_.configData.tools, name) + const loaded = api.isPluginLoaded(toolId) + const hidden = api.isPluginHidden(toolId) + + if (loaded && !hidden) { + // Already visible; treat as a success (idempotent open). + return toOpenResult(true, toolId, name) + } + if (loaded && hidden) { + // Loaded but hidden (hidePlugin / startHidden) — reveal it. + return toOpenResult(api.showPlugin(toolId), toolId, name) + } + if (!loaded && hidden) { + // Deferred (startUnloaded / previously unloadPlugin'd) — load it. + return toOpenResult(api.loadPlugin(toolId), toolId, name) + } + // Neither loaded nor deferred: this name was never assigned to a + // panel in this mission's config — there is no ground truth that + // says opening it did anything. + return toOpenResult(false, toolId, name) + }, + } + } + + return { + mode: 'classic', + activeToolName: function () { + return ToolController_.activeToolName + }, + openTool: function (name) { + const toolId = resolveToolId(L_.configData && L_.configData.tools, name) + const tool = ToolController_.toolModules && ToolController_.toolModules[toolId] + if ( + !tool || + typeof tool.make !== 'function' || + typeof tool.destroy !== 'function' + ) { + return { ok: false, error: `Unknown or unopenable tool: ${name}` } + } + ToolController_.makeTool(toolId) + return { ok: true, activeTool: ToolController_.activeToolName } + }, + } +} + +const AgentBridge = { + ws: null, + sessionId: null, + reloadTimer: null, + + init: function (vars) { + this.sessionId = + window.crypto && window.crypto.randomUUID + ? window.crypto.randomUUID() + : String(Math.random()).slice(2) + this.connect() + }, + + getWsPath: function () { + const g = window.mmgisglobal || {} + if (isStaticBuild()) return null + if (!g.PORT) return null + if (g.ENABLE_MMGIS_WEBSOCKETS !== 'true') return null + const protocol = + window.location.protocol.indexOf('https') !== -1 ? 'wss' : 'ws' + const rootPath = g.WEBSOCKET_ROOT_PATH || g.ROOT_PATH || '' + const host = + g.NODE_ENV === 'development' + ? `localhost:${parseInt(g.PORT || '8888', 10)}` + : window.location.host + return `${protocol}://${host}${rootPath}/` + }, + + connect: function () { + const path = this.getWsPath() + if (path == null) { + console.warn( + '[AgentBridge] Websockets disabled (static build, no PORT, or ENABLE_MMGIS_WEBSOCKETS != true); agent bridge inactive.' + ) + return + } + try { + this.ws = new WebSocket(path) + } catch (err) { + console.warn('[AgentBridge] Failed to open websocket:', err) + setTimeout(() => this.connect(), RECONNECT_MS) + return + } + this.ws.onopen = () => { + this.send({ kind: 'presence', sessionId: this.sessionId }) + } + this.ws.onmessage = (event) => this.onMessage(event) + this.ws.onclose = () => { + setTimeout(() => this.connect(), RECONNECT_MS) + } + }, + + send: function (agent) { + if (!this.ws || this.ws.readyState !== 1) return + this.ws.send( + JSON.stringify({ + type: FRAME_TYPE, + body: { mission: L_.mission }, + info: { type: 'agentBridge' }, + agent, + }) + ) + }, + + onMessage: async function (event) { + let parsed + try { + parsed = JSON.parse(event.data) + } catch (err) { + return + } + + // Config-mutation broadcast (not an agent-bridge frame): modern.js + // has no websocket client of its own, so this is the only place + // that can notice a saved config change and bring the session + // current. Debounced so a burst of edits reloads once, not per-frame. + // Modern-only: classic sessions already live-apply layer frames + // natively via essence.js, so reloading there would be redundant + // (and destructive to any unsaved local UI state). + if (shouldReloadForFrame(parsed, L_.mission) && isModernMission()) { + clearTimeout(this.reloadTimer) + this.reloadTimer = setTimeout(() => { + window.location.reload() + }, RELOAD_DEBOUNCE_MS) + } + + if (parsed == null || parsed.type !== FRAME_TYPE) return + if (parsed.agent == null || parsed.agent.kind !== 'command') return + if (parsed.body == null || !sameMission(parsed.body.mission, L_.mission)) return + + const { id, command, args } = parsed.agent + let outcome + try { + outcome = await executeCommand(command, args, { + Map_, + L_, + ToolAdapter: buildToolAdapter(), + TimeControl, + reload: () => window.location.reload(), + }) + } catch (err) { + outcome = { ok: false, error: `Command threw: ${err.message}` } + } + this.send({ + kind: 'ack', + id, + sessionId: this.sessionId, + ok: outcome.ok, + result: outcome.result, + error: outcome.error, + }) + }, +} + +export default AgentBridge diff --git a/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js b/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js new file mode 100644 index 000000000..a49c409ba --- /dev/null +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js @@ -0,0 +1,110 @@ +// Whitelisted, view-only commands the agent bridge can execute. +// All MMGIS internals arrive via `deps` so this module stays unit-testable. + +function isFiniteNumber(v) { + return typeof v === 'number' && isFinite(v) +} + +// Mission configs (and our dashboard_tool_options) expose a tool's display +// `name` (e.g. 'LayerManager'), but the id-keyed APIs that actually open a +// tool — window.mmgisAPI.showPlugin/loadPlugin/isPluginLoaded/isPluginHidden +// (modern) and ToolController_.toolModules/makeTool (classic) — are keyed by +// the config's `js` id (e.g. 'LayerManagerTool'). Accept either form so +// `open_tool` works whether the agent passes the display name or the js id. +export function resolveToolId(tools, name) { + const entry = (tools || []).find((t) => t.name === name || t.js === name) + return entry ? entry.js : name +} + +export function getViewState(deps) { + const { Map_, L_, ToolAdapter, TimeControl } = deps + return { + mission: L_.mission || null, + center: Map_.map && Map_.map.getCenter ? Map_.map.getCenter() : null, + zoom: Map_.map && Map_.map.getZoom ? Map_.map.getZoom() : null, + layersOn: L_.layers ? L_.layers.on : {}, + activeTool: + ToolAdapter && typeof ToolAdapter.activeToolName === 'function' + ? ToolAdapter.activeToolName() + : null, + currentTime: TimeControl && TimeControl.getTime ? TimeControl.getTime() : null, + } +} + +// Config-mutation broadcasts (POST /api/configure/upsert with +// forceClientUpdate: true) arrive over the same websocket as agent-bridge +// frames. The classic client (essence.js) auto-applies/shows RELOAD for +// these; modern.js has no websocket client at all, so AgentBridge (which +// connects in every mode) is the only thing that can notice and react. +// Kept dependency-free so it's trivially unit-testable. +// Mission names travel through an LLM, which routinely reformats them +// ("Air Quality Demo" for a mission stored as "Air_Quality_Demo"). Compare +// leniently so a cosmetic difference doesn't look like a dead session. +export function sameMission(a, b) { + const norm = (s) => + typeof s === 'string' ? s.toLowerCase().replace(/[\s_-]+/g, ' ').trim() : null + const na = norm(a) + return na != null && na === norm(b) +} + +export function shouldReloadForFrame(parsed, mission) { + if (parsed == null || parsed.type === 'agent-bridge') return false + if (parsed.forceClientUpdate !== true) return false + if (parsed.body == null || !sameMission(parsed.body.mission, mission)) return false + const type = parsed.info == null ? null : parsed.info.type + return ['upsert', 'addLayer', 'updateLayer', 'removeLayer'].includes(type) +} + +export async function executeCommand(command, args, deps) { + const { Map_, L_, ToolAdapter, TimeControl } = deps + const a = args || {} + switch (command) { + case 'fly_to': { + if (!isFiniteNumber(a.lat) || !isFiniteNumber(a.lon)) + return { ok: false, error: 'fly_to requires numeric lat and lon' } + Map_.resetView([a.lat, a.lon, isFiniteNumber(a.zoom) ? a.zoom : undefined]) + return { ok: true, result: getViewState(deps) } + } + case 'toggle_layer': { + if (typeof a.layer !== 'string') + return { ok: false, error: 'toggle_layer requires a layer name or uuid' } + const uuid = L_.asLayerUUID(a.layer) + if (uuid == null || L_.layers.data[uuid] == null) + return { ok: false, error: `Unknown layer: ${a.layer}` } + const current = L_.layers.on[uuid] + if (typeof a.on === 'boolean' && current === a.on) + return { ok: true, result: { layer: uuid, on: current } } + await L_.toggleLayer(L_.layers.data[uuid]) + return { ok: true, result: { layer: uuid, on: L_.layers.on[uuid] } } + } + case 'open_tool': { + if (typeof a.name !== 'string') + return { ok: false, error: 'open_tool requires a tool name' } + if (!ToolAdapter || typeof ToolAdapter.openTool !== 'function') + return { ok: false, error: 'open_tool is not supported in this session' } + const outcome = ToolAdapter.openTool(a.name) + if (!outcome || outcome.ok !== true) + return { + ok: false, + error: (outcome && outcome.error) || `Unknown or unopenable tool: ${a.name}`, + } + return { ok: true, result: { activeTool: outcome.activeTool ?? null } } + } + case 'set_time': { + if (!a.startTime || !a.endTime) + return { ok: false, error: 'set_time requires startTime and endTime (ISO strings)' } + const ok = TimeControl.setTime(a.startTime, a.endTime, false, '00:00:00', a.currentTime) + if (ok === false) + return { ok: false, error: 'Time is not enabled for this mission' } + return { ok: true, result: { currentTime: TimeControl.getTime() } } + } + case 'get_view_state': + return { ok: true, result: getViewState(deps) } + case 'reload': { + deps.reload() + return { ok: true, result: { reloading: true } } + } + default: + return { ok: false, error: `Unknown command: ${command}` } + } +} diff --git a/src/essence/MMGIS-Plugin-Components/AgentBridge/config.json b/src/essence/MMGIS-Plugin-Components/AgentBridge/config.json new file mode 100644 index 000000000..daffa4040 --- /dev/null +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/config.json @@ -0,0 +1,10 @@ +{ + "name": "AgentBridge", + "description": "Lets the MMGIS MCP server drive this browser session (fly, toggle layers, open tools, set time) over the MMGIS websocket.", + "defaultIcon": "robot", + "hasVars": false, + "config": { "rows": [] }, + "paths": { + "AgentBridge": "essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge" + } +} diff --git a/tests/unit/agentBridgeCommands.spec.js b/tests/unit/agentBridgeCommands.spec.js new file mode 100644 index 000000000..bf8ebecdb --- /dev/null +++ b/tests/unit/agentBridgeCommands.spec.js @@ -0,0 +1,217 @@ +import { describe, it, expect, vi } from 'vitest' +import { + executeCommand, + getViewState, + resolveToolId, + shouldReloadForFrame, +} from '../../src/essence/MMGIS-Plugin-Components/AgentBridge/commands' + +function makeDeps() { + return { + Map_: { + resetView: vi.fn(), + map: { getCenter: () => ({ lat: 1, lng: 2 }), getZoom: () => 5 }, + }, + L_: { + mission: 'Demo', + asLayerUUID: (v) => (v === 'NO2' || v === 'uuid-1' ? 'uuid-1' : null), + layers: { data: { 'uuid-1': { name: 'NO2' } }, on: { 'uuid-1': false } }, + toggleLayer: vi.fn(async function (l) { + this.layers.on['uuid-1'] = !this.layers.on['uuid-1'] + }), + }, + ToolAdapter: { + mode: 'classic', + activeToolName: vi.fn(() => 'LayerManager'), + openTool: vi.fn((name) => + name === 'Chart' + ? { ok: true, activeTool: 'Chart' } + : { ok: false, error: `Unknown or unopenable tool: ${name}` } + ), + }, + TimeControl: { + setTime: vi.fn(() => true), + getTime: () => '2026-06-01T00:00:00Z', + }, + reload: vi.fn(), + } +} + +describe('executeCommand', () => { + it('fly_to validates lat/lon and calls Map_.resetView', async () => { + const deps = makeDeps() + const res = await executeCommand('fly_to', { lat: 33.7, lon: -84.4, zoom: 9 }, deps) + expect(res.ok).toBe(true) + expect(deps.Map_.resetView).toHaveBeenCalledWith([33.7, -84.4, 9]) + }) + it('fly_to rejects non-numeric coordinates', async () => { + const res = await executeCommand('fly_to', { lat: 'x', lon: 0 }, makeDeps()) + expect(res.ok).toBe(false) + expect(res.error).toMatch(/lat/) + }) + it('toggle_layer resolves names to uuids and toggles', async () => { + const deps = makeDeps() + const res = await executeCommand('toggle_layer', { layer: 'NO2' }, deps) + expect(res.ok).toBe(true) + expect(res.result).toEqual({ layer: 'uuid-1', on: true }) + }) + it('toggle_layer is a no-op when already in the requested state', async () => { + const deps = makeDeps() + const res = await executeCommand('toggle_layer', { layer: 'NO2', on: false }, deps) + expect(res.ok).toBe(true) + expect(deps.L_.toggleLayer).not.toHaveBeenCalled() + }) + it('toggle_layer errors on unknown layers', async () => { + const res = await executeCommand('toggle_layer', { layer: 'Nope' }, makeDeps()) + expect(res.ok).toBe(false) + expect(res.error).toMatch(/Unknown layer/) + }) + it('open_tool delegates to ToolAdapter.openTool and reports the resulting active tool', async () => { + const deps = makeDeps() + const res = await executeCommand('open_tool', { name: 'Chart' }, deps) + expect(res.ok).toBe(true) + expect(deps.ToolAdapter.openTool).toHaveBeenCalledWith('Chart') + expect(res.result).toEqual({ activeTool: 'Chart' }) + }) + it('open_tool requires a tool name', async () => { + const res = await executeCommand('open_tool', {}, makeDeps()) + expect(res.ok).toBe(false) + expect(res.error).toMatch(/tool name/) + }) + it('open_tool returns ok:false for unknown/unopenable tools instead of faking success', async () => { + const deps = makeDeps() + const res = await executeCommand('open_tool', { name: 'NotARealTool' }, deps) + expect(res.ok).toBe(false) + expect(res.error).toMatch(/Unknown or unopenable tool: NotARealTool/) + }) + it('open_tool is honest when no ToolAdapter is available', async () => { + const deps = makeDeps() + deps.ToolAdapter = undefined + const res = await executeCommand('open_tool', { name: 'Chart' }, deps) + expect(res.ok).toBe(false) + expect(res.error).toMatch(/not supported/) + }) + it('set_time requires startTime and endTime', async () => { + const res = await executeCommand('set_time', { startTime: '2026-01-01T00:00:00Z' }, makeDeps()) + expect(res.ok).toBe(false) + }) + it('set_time sets the time range and returns the current time', async () => { + const deps = makeDeps() + const res = await executeCommand( + 'set_time', + { startTime: '2026-01-01T00:00:00Z', endTime: '2026-06-01T00:00:00Z' }, + deps + ) + expect(res.ok).toBe(true) + expect(res.result.currentTime).toBe('2026-06-01T00:00:00Z') + expect(deps.TimeControl.setTime).toHaveBeenCalledWith( + '2026-01-01T00:00:00Z', + '2026-06-01T00:00:00Z', + false, + '00:00:00', + undefined + ) + }) + it('get_view_state reports mission, center, zoom, layers, tool', async () => { + const deps = makeDeps() + const res = await executeCommand('get_view_state', {}, deps) + expect(res.ok).toBe(true) + expect(res.result.mission).toBe('Demo') + expect(res.result.center).toEqual({ lat: 1, lng: 2 }) + expect(res.result.zoom).toBe(5) + expect(res.result.activeTool).toBe('LayerManager') + expect(res.result.layersOn).toEqual(deps.L_.layers.on) + expect(res.result.currentTime).toBe('2026-06-01T00:00:00Z') + }) + it('reload calls the injected reload and reports', async () => { + const deps = makeDeps() + const res = await executeCommand('reload', {}, deps) + expect(res.ok).toBe(true) + expect(res.result).toEqual({ reloading: true }) + expect(deps.reload).toHaveBeenCalled() + }) + it('rejects unknown commands', async () => { + const res = await executeCommand('rm_rf', {}, makeDeps()) + expect(res.ok).toBe(false) + expect(res.error).toMatch(/Unknown command/) + }) +}) + +describe('getViewState', () => { + it('tolerates a missing map object', () => { + const deps = makeDeps() + deps.Map_.map = null + const state = getViewState(deps) + expect(state.center).toBe(null) + expect(state.zoom).toBe(null) + }) +}) + +describe('shouldReloadForFrame', () => { + const validFrame = { + type: undefined, + forceClientUpdate: true, + body: { mission: 'M' }, + info: { type: 'updateLayer' }, + } + + it('is true for a config-mutation broadcast matching the current mission', () => { + expect(shouldReloadForFrame(validFrame, 'M')).toBe(true) + }) + it('is false for agent-bridge frames', () => { + expect(shouldReloadForFrame({ ...validFrame, type: 'agent-bridge' }, 'M')).toBe(false) + }) + it('is false when forceClientUpdate is absent', () => { + const { forceClientUpdate, ...rest } = validFrame + expect(shouldReloadForFrame(rest, 'M')).toBe(false) + }) + it('is false when forceClientUpdate is false', () => { + expect(shouldReloadForFrame({ ...validFrame, forceClientUpdate: false }, 'M')).toBe(false) + }) + it('is false for a different mission', () => { + expect(shouldReloadForFrame(validFrame, 'Other')).toBe(false) + }) + it("is false when info.type isn't a reload-worthy mutation", () => { + expect( + shouldReloadForFrame({ ...validFrame, info: { type: 'somethingElse' } }, 'M') + ).toBe(false) + }) + it('is false for a null frame', () => { + expect(shouldReloadForFrame(null, 'M')).toBe(false) + }) +}) + +describe('resolveToolId', () => { + const tools = [ + { name: 'LayerManager', js: 'LayerManagerTool' }, + { name: 'Chart', js: 'ChartTool' }, + ] + + it('resolves a display name to its js id', () => { + expect(resolveToolId(tools, 'LayerManager')).toBe('LayerManagerTool') + }) + it('passes through a js id unchanged', () => { + expect(resolveToolId(tools, 'ChartTool')).toBe('ChartTool') + }) + it('falls back to the input when the tool is unknown', () => { + expect(resolveToolId(tools, 'NotARealTool')).toBe('NotARealTool') + }) + it('tolerates a missing tools list', () => { + expect(resolveToolId(undefined, 'LayerManager')).toBe('LayerManager') + }) +}) + +describe('sameMission', () => { + it('matches names that differ only by separators or case', async () => { + const { sameMission } = await import('../../src/essence/MMGIS-Plugin-Components/AgentBridge/commands') + expect(sameMission('Air Quality Demo', 'Air_Quality_Demo')).toBe(true) + expect(sameMission('air-quality-demo', 'Air Quality Demo')).toBe(true) + expect(sameMission('Agent Demo', 'Agent Demo')).toBe(true) + }) + it('rejects genuinely different or missing names', async () => { + const { sameMission } = await import('../../src/essence/MMGIS-Plugin-Components/AgentBridge/commands') + expect(sameMission('Air Quality Demo', 'Flood Demo')).toBe(false) + expect(sameMission(null, 'Agent Demo')).toBe(false) + expect(sameMission('Agent Demo', null)).toBe(false) + }) +})