From 994057f20d41f5050de6d1da82c1f8f0c3c7faf8 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Wed, 22 Jul 2026 21:04:07 -0500 Subject: [PATCH 01/71] Add agentic MMGIS umbrella design spec Design for MCP-driven MMGIS: agent control surface (REST + WebSocket agent bridge), NL-to-dashboard generation on top of the mission config generator, and phased NL plugin scaffolding. --- .../specs/2026-07-22-agentic-mmgis-design.md | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-22-agentic-mmgis-design.md 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. From e8db3c8821d1b7e9d728796d6c37634d737ce5ce Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Wed, 22 Jul 2026 21:28:36 -0500 Subject: [PATCH 02/71] Add Phase 1 implementation plan for agentic MMGIS --- .../plans/2026-07-22-agentic-mmgis-phase1.md | 2199 +++++++++++++++++ 1 file changed, 2199 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-agentic-mmgis-phase1.md 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). From ab237147aac94a5ecc69439936a0a4780d267b98 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 03:59:44 -0500 Subject: [PATCH 03/71] Scaffold MMGIS MCP server package with env config --- mcp/.gitignore | 2 + mcp/package-lock.json | 2812 ++++++++++++++++++++++++++++++++++++++ mcp/package.json | 25 + mcp/src/config.ts | 47 + mcp/tests/config.spec.ts | 26 + mcp/tsconfig.json | 13 + mcp/vitest.config.ts | 8 + 7 files changed, 2933 insertions(+) create mode 100644 mcp/.gitignore create mode 100644 mcp/package-lock.json create mode 100644 mcp/package.json create mode 100644 mcp/src/config.ts create mode 100644 mcp/tests/config.spec.ts create mode 100644 mcp/tsconfig.json create mode 100644 mcp/vitest.config.ts 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/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/config.ts b/mcp/src/config.ts new file mode 100644 index 000000000..088126edd --- /dev/null +++ b/mcp/src/config.ts @@ -0,0 +1,47 @@ +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(/\/+$/, ''), + } +} diff --git a/mcp/tests/config.spec.ts b/mcp/tests/config.spec.ts new file mode 100644 index 000000000..8ad5914bb --- /dev/null +++ b/mcp/tests/config.spec.ts @@ -0,0 +1,26 @@ +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) + }) +}) 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', + }, +}) From a8019227be236c157bacf51d151741f09ed633c7 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 04:03:50 -0500 Subject: [PATCH 04/71] Normalize wsUrl to always end with exactly one trailing slash --- mcp/src/config.ts | 3 ++- mcp/tests/config.spec.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/mcp/src/config.ts b/mcp/src/config.ts index 088126edd..155e638f1 100644 --- a/mcp/src/config.ts +++ b/mcp/src/config.ts @@ -24,7 +24,8 @@ export function loadConfig(env: Record = process.env } 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') + '/' + 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)), '..', '..') let stacCatalogs = DEFAULT_STAC_CATALOGS diff --git a/mcp/tests/config.spec.ts b/mcp/tests/config.spec.ts index 8ad5914bb..c8b3be0ec 100644 --- a/mcp/tests/config.spec.ts +++ b/mcp/tests/config.spec.ts @@ -15,6 +15,7 @@ describe('loadConfig', () => { 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' }) From f995d065299829c0ce1368d7dd055e9413e588f6 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 04:05:30 -0500 Subject: [PATCH 05/71] Add MMGIS REST client with long-term token auth --- mcp/src/mmgisClient.ts | 61 +++++++++++++++++++++++++++++++++++ mcp/tests/mmgisClient.spec.ts | 44 +++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 mcp/src/mmgisClient.ts create mode 100644 mcp/tests/mmgisClient.spec.ts diff --git a/mcp/src/mmgisClient.ts b/mcp/src/mmgisClient.ts new file mode 100644 index 000000000..622d74376 --- /dev/null +++ b/mcp/src/mmgisClient.ts @@ -0,0 +1,61 @@ +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 }) + } +} diff --git a/mcp/tests/mmgisClient.spec.ts b/mcp/tests/mmgisClient.spec.ts new file mode 100644 index 000000000..55c84b5e4 --- /dev/null +++ b/mcp/tests/mmgisClient.spec.ts @@ -0,0 +1,44 @@ +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/) + }) +}) From b5f1cf2ecc07390e8c7750e380087a7de78c8e60 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 04:12:13 -0500 Subject: [PATCH 06/71] Honor long-term tokens in /api/configure/add SuperAdmin check The /add route only checked req.session.permission, so long-term-token requests (used by the MCP server) were always rejected with 403 even when the token's creator was a SuperAdmin. Mirror the pattern already used by checkMissionPermission: allow when the session permission is 111 or when req.isLongTermToken is true and req.tokenUserPermission is 111. Also add mcp client tests for upsertMission and the transport-failure path (fetch throws), both of which were already implemented correctly in mmgisClient.ts. --- API/Backend/Config/routes/configs.js | 5 ++++- mcp/tests/mmgisClient.spec.ts | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/API/Backend/Config/routes/configs.js b/API/Backend/Config/routes/configs.js index ec7fbc36f..34d0df88f 100644 --- a/API/Backend/Config/routes/configs.js +++ b/API/Backend/Config/routes/configs.js @@ -381,7 +381,10 @@ function add(req, res, next, cb) { if (fullAccess) router.post("/add", function (req, res, next) { - if (req.session.permission !== "111") { + const isSuperAdmin = + req.session.permission === "111" || + (req.isLongTermToken === true && req.tokenUserPermission === "111"); + if (!isSuperAdmin) { res.send({ status: "failure", message: "Only SuperAdmins can add new missions.", diff --git a/mcp/tests/mmgisClient.spec.ts b/mcp/tests/mmgisClient.spec.ts index 55c84b5e4..80c44509f 100644 --- a/mcp/tests/mmgisClient.spec.ts +++ b/mcp/tests/mmgisClient.spec.ts @@ -41,4 +41,24 @@ describe('MmgisClient', () => { 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/) + }) }) From 00651e162175a4be16ef03b98859a2363a607cfc Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 04:15:28 -0500 Subject: [PATCH 07/71] Add MCP server skeleton with mission admin tools --- mcp/src/index.ts | 19 +++++++++++++++++++ mcp/src/server.ts | 12 ++++++++++++ mcp/src/tools/admin.ts | 33 +++++++++++++++++++++++++++++++++ mcp/src/tools/result.ts | 25 +++++++++++++++++++++++++ mcp/tests/admin.spec.ts | 35 +++++++++++++++++++++++++++++++++++ mcp/tests/server.spec.ts | 17 +++++++++++++++++ 6 files changed, 141 insertions(+) create mode 100644 mcp/src/index.ts create mode 100644 mcp/src/server.ts create mode 100644 mcp/src/tools/admin.ts create mode 100644 mcp/src/tools/result.ts create mode 100644 mcp/tests/admin.spec.ts create mode 100644 mcp/tests/server.spec.ts diff --git a/mcp/src/index.ts b/mcp/src/index.ts new file mode 100644 index 000000000..34ef5783d --- /dev/null +++ b/mcp/src/index.ts @@ -0,0 +1,19 @@ +#!/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) +}) 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/tools/admin.ts b/mcp/src/tools/admin.ts new file mode 100644 index 000000000..796d67eaf --- /dev/null +++ b/mcp/src/tools/admin.ts @@ -0,0 +1,33 @@ +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) + } + }, + }, + ] +} diff --git a/mcp/src/tools/result.ts b/mcp/src/tools/result.ts new file mode 100644 index 000000000..cfabec329 --- /dev/null +++ b/mcp/src/tools/result.ts @@ -0,0 +1,25 @@ +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 } : {}) }), + }, + ], + } +} diff --git a/mcp/tests/admin.spec.ts b/mcp/tests/admin.spec.ts new file mode 100644 index 000000000..0fc65d948 --- /dev/null +++ b/mcp/tests/admin.spec.ts @@ -0,0 +1,35 @@ +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' }) + }) +}) 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') + }) +}) From 9c4327d27a81c3577743cd85ae8f27f2125bd1e1 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 04:18:59 -0500 Subject: [PATCH 08/71] Add dashboard profile builder wrapping the mission config generator --- mcp/src/generator.ts | 47 +++++++++++++++++++++++++ mcp/src/profileBuilder.ts | 51 +++++++++++++++++++++++++++ mcp/tests/generator.spec.ts | 60 ++++++++++++++++++++++++++++++++ mcp/tests/profileBuilder.spec.ts | 45 ++++++++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 mcp/src/generator.ts create mode 100644 mcp/src/profileBuilder.ts create mode 100644 mcp/tests/generator.spec.ts create mode 100644 mcp/tests/profileBuilder.spec.ts diff --git a/mcp/src/generator.ts b/mcp/src/generator.ts new file mode 100644 index 000000000..b7e055710 --- /dev/null +++ b/mcp/src/generator.ts @@ -0,0 +1,47 @@ +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) +} diff --git a/mcp/src/profileBuilder.ts b/mcp/src/profileBuilder.ts new file mode 100644 index 000000000..1bbb7ca0a --- /dev/null +++ b/mcp/src/profileBuilder.ts @@ -0,0 +1,51 @@ +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 +} 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/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) + }) +}) From f205a517a1298d3edc34f94f6662c64a8db6aa3a Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 04:23:13 -0500 Subject: [PATCH 09/71] Add dashboard generation MCP tools --- mcp/src/index.ts | 5 +- mcp/src/tools/dashboard.ts | 128 ++++++++++++++++++++++++++++++++++++ mcp/tests/dashboard.spec.ts | 91 +++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 mcp/src/tools/dashboard.ts create mode 100644 mcp/tests/dashboard.spec.ts diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 34ef5783d..e69e0081c 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -3,12 +3,15 @@ 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 { buildServer } from './server.js' async function main() { const cfg = loadConfig() const client = new MmgisClient(cfg.mmgisUrl, cfg.mmgisToken) - const server = buildServer({ tools: [...makeAdminTools(client)] }) + const server = buildServer({ + tools: [...makeAdminTools(client), ...makeDashboardTools(client, cfg)], + }) await server.connect(new StdioServerTransport()) // stdio server runs until the client disconnects } diff --git a/mcp/src/tools/dashboard.ts b/mcp/src/tools/dashboard.ts new file mode 100644 index 000000000..e15ce2ff1 --- /dev/null +++ b/mcp/src/tools/dashboard.ts @@ -0,0 +1,128 @@ +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) + } + }, + }, + ] +} diff --git a/mcp/tests/dashboard.spec.ts b/mcp/tests/dashboard.spec.ts new file mode 100644 index 000000000..4e485813f --- /dev/null +++ b/mcp/tests/dashboard.spec.ts @@ -0,0 +1,91 @@ +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) +}) From 6f4238f7b47349db337754ff5cde4d134b78a689 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 04:27:34 -0500 Subject: [PATCH 10/71] Add STAC catalog search and layer conversion tools --- mcp/src/index.ts | 3 +- mcp/src/stac.ts | 110 ++++++++++++++++++++++++++++++++++++++ mcp/src/tools/catalog.ts | 76 ++++++++++++++++++++++++++ mcp/tests/catalog.spec.ts | 16 ++++++ mcp/tests/stac.spec.ts | 71 ++++++++++++++++++++++++ 5 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 mcp/src/stac.ts create mode 100644 mcp/src/tools/catalog.ts create mode 100644 mcp/tests/catalog.spec.ts create mode 100644 mcp/tests/stac.spec.ts diff --git a/mcp/src/index.ts b/mcp/src/index.ts index e69e0081c..290995480 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -4,13 +4,14 @@ 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 { buildServer } from './server.js' async function main() { const cfg = loadConfig() const client = new MmgisClient(cfg.mmgisUrl, cfg.mmgisToken) const server = buildServer({ - tools: [...makeAdminTools(client), ...makeDashboardTools(client, cfg)], + tools: [...makeAdminTools(client), ...makeDashboardTools(client, cfg), ...makeCatalogTools(cfg)], }) await server.connect(new StdioServerTransport()) // stdio server runs until the client disconnects diff --git a/mcp/src/stac.ts b/mcp/src/stac.ts new file mode 100644 index 000000000..c6404bbf4 --- /dev/null +++ b/mcp/src/stac.ts @@ -0,0 +1,110 @@ +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: {}, + } +} diff --git a/mcp/src/tools/catalog.ts b/mcp/src/tools/catalog.ts new file mode 100644 index 000000000..ecc9b7a6f --- /dev/null +++ b/mcp/src/tools/catalog.ts @@ -0,0 +1,76 @@ +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) + } + }, + }, + ] +} diff --git a/mcp/tests/catalog.spec.ts b/mcp/tests/catalog.spec.ts new file mode 100644 index 000000000..11deaa321 --- /dev/null +++ b/mcp/tests/catalog.spec.ts @@ -0,0 +1,16 @@ +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') + }) +}) diff --git a/mcp/tests/stac.spec.ts b/mcp/tests/stac.spec.ts new file mode 100644 index 000000000..48500a900 --- /dev/null +++ b/mcp/tests/stac.spec.ts @@ -0,0 +1,71 @@ +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' + ) + }) +}) From bef7af55fb2ec690d60ed8a964b99984c185e449 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 04:31:25 -0500 Subject: [PATCH 11/71] Fix STAC non-OK hint and query string encoding - Add degradation hint to non-OK STAC response in stacFetch - Encode asset, rescale, colormap in query string via encodeURIComponent - Add tests for non-OK status hint and parameter encoding --- mcp/src/stac.ts | 11 +++++++---- mcp/tests/stac.spec.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/mcp/src/stac.ts b/mcp/src/stac.ts index c6404bbf4..79f9a7fc3 100644 --- a/mcp/src/stac.ts +++ b/mcp/src/stac.ts @@ -19,7 +19,10 @@ async function stacFetch(url: string, init: RequestInit | undefined, fetchFn: ty '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}`) + 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() } @@ -87,9 +90,9 @@ export function stacItemToTileLayer( 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}` + `?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', diff --git a/mcp/tests/stac.spec.ts b/mcp/tests/stac.spec.ts index 48500a900..f985c44f4 100644 --- a/mcp/tests/stac.spec.ts +++ b/mcp/tests/stac.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest' import { searchStac, searchCollections, stacItemToTileLayer } from '../src/stac.js' +import { MMGISError } from '../src/mmgisClient.js' const ITEM = { id: 'i1', @@ -33,6 +34,17 @@ describe('searchStac', () => { 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', () => { @@ -68,4 +80,32 @@ describe('stacItemToTileLayer', () => { '&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}@1x.png?url=' + + encodeURIComponent('https://stac.test/collections/no2-monthly/items/i1') + + '&assets=' + + encodeURIComponent('cog_default') + + '&rescale=' + + encodeURIComponent('0,255') + + '&colormap_name=' + + encodeURIComponent('viridis') + ) + }) }) From 3770c6fb798c8963ac8d61e7551d744d3e88c77c Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 04:36:14 -0500 Subject: [PATCH 12/71] Add AgentBridge plugin component for browser-side agent control --- .gitignore | 1 + .../AgentBridge/AgentBridge.js | 106 ++++++++++++++++++ .../AgentBridge/commands.js | 61 ++++++++++ .../AgentBridge/config.json | 12 ++ tests/unit/agentBridgeCommands.spec.js | 91 +++++++++++++++ 5 files changed, 271 insertions(+) create mode 100644 src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js create mode 100644 src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js create mode 100644 src/essence/MMGIS-Plugin-Components/AgentBridge/config.json create mode 100644 tests/unit/agentBridgeCommands.spec.js diff --git a/.gitignore b/.gitignore index a66eec216..9a20c8f40 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 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..f1e021428 --- /dev/null +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js @@ -0,0 +1,106 @@ +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 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..be90e4f60 --- /dev/null +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js @@ -0,0 +1,61 @@ +// 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}` } + } +} 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..e774f4c87 --- /dev/null +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/config.json @@ -0,0 +1,12 @@ +{ + "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" + } + } +} diff --git a/tests/unit/agentBridgeCommands.spec.js b/tests/unit/agentBridgeCommands.spec.js new file mode 100644 index 000000000..5bf9b39ce --- /dev/null +++ b/tests/unit/agentBridgeCommands.spec.js @@ -0,0 +1,91 @@ +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) + }) +}) From 207d172773289cf52586ae31757452295a6983a4 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 04:41:43 -0500 Subject: [PATCH 13/71] [109] Guard AgentBridge websocket path against static builds and missing PORT getWsPath() only checked ENABLE_MMGIS_WEBSOCKETS, unlike essence.js's bootstrap which also gates on !isStaticBuild() and PORT being set. On a static export or with PORT unset, AgentBridge would otherwise retry a doomed connection every 10s forever. Also adds set_time success-path coverage and broadens the get_view_state test to cover layersOn/currentTime. --- .../AgentBridge/AgentBridge.js | 5 ++++- tests/unit/agentBridgeCommands.spec.js | 22 ++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js index f1e021428..e336e29cb 100644 --- a/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js @@ -2,6 +2,7 @@ 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 } from './commands' // Envelope contract shared with mcp/src/bridge.ts — keep in sync. @@ -22,6 +23,8 @@ const AgentBridge = { 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' @@ -37,7 +40,7 @@ const AgentBridge = { const path = this.getWsPath() if (path == null) { console.warn( - '[AgentBridge] Websockets disabled (ENABLE_MMGIS_WEBSOCKETS != true); agent bridge inactive.' + '[AgentBridge] Websockets disabled (static build, no PORT, or ENABLE_MMGIS_WEBSOCKETS != true); agent bridge inactive.' ) return } diff --git a/tests/unit/agentBridgeCommands.spec.js b/tests/unit/agentBridgeCommands.spec.js index 5bf9b39ce..df9cadea4 100644 --- a/tests/unit/agentBridgeCommands.spec.js +++ b/tests/unit/agentBridgeCommands.spec.js @@ -65,13 +65,33 @@ describe('executeCommand', () => { 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 res = await executeCommand('get_view_state', {}, makeDeps()) + 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('rejects unknown commands', async () => { const res = await executeCommand('rm_rf', {}, makeDeps()) From 2e3f523c812de72453ea7a3098775e9cd5dbcb41 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 04:44:36 -0500 Subject: [PATCH 14/71] Add websocket bridge client and browser view control tools --- mcp/src/bridge.ts | 75 +++++++++++++++++++++++++++++++++++ mcp/src/index.ts | 10 ++++- mcp/src/tools/view.ts | 55 ++++++++++++++++++++++++++ mcp/tests/bridge.spec.ts | 84 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 mcp/src/bridge.ts create mode 100644 mcp/src/tools/view.ts create mode 100644 mcp/tests/bridge.spec.ts diff --git a/mcp/src/bridge.ts b/mcp/src/bridge.ts new file mode 100644 index 000000000..9cbb15ccd --- /dev/null +++ b/mcp/src/bridge.ts @@ -0,0 +1,75 @@ +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 + } +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 290995480..ee80d2d23 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -5,13 +5,21 @@ 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 { 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)], + tools: [ + ...makeAdminTools(client), + ...makeDashboardTools(client, cfg), + ...makeCatalogTools(cfg), + ...makeViewTools(bridge), + ], }) await server.connect(new StdioServerTransport()) // stdio server runs until the client disconnects diff --git a/mcp/src/tools/view.ts b/mcp/src/tools/view.ts new file mode 100644 index 000000000..14ec4cf27 --- /dev/null +++ b/mcp/src/tools/view.ts @@ -0,0 +1,55 @@ +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', {}), + }, + ] +} diff --git a/mcp/tests/bridge.spec.ts b/mcp/tests/bridge.spec.ts new file mode 100644 index 000000000..c3b4247a5 --- /dev/null +++ b/mcp/tests/bridge.spec.ts @@ -0,0 +1,84 @@ +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/) + }) +}) From 7373d005215acd4b6f2a30a5055c2c3e7187015c Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 04:49:32 -0500 Subject: [PATCH 15/71] Fix BridgeClient.connect() to dedupe concurrent connection attempts Overlapping sendCommand calls issued before the websocket finished connecting each spawned their own WebSocket, orphaning all but the last handle. Memoize the in-flight connect() promise on a private connecting field so concurrent callers await the same socket. Also adds a connect-failure test asserting the ENABLE_MMGIS_WEBSOCKETS hint surfaces as an MMGISError. --- mcp/src/bridge.ts | 10 +++++++++- mcp/tests/bridge.spec.ts | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/mcp/src/bridge.ts b/mcp/src/bridge.ts index 9cbb15ccd..000944142 100644 --- a/mcp/src/bridge.ts +++ b/mcp/src/bridge.ts @@ -4,6 +4,7 @@ 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) {} @@ -11,13 +12,18 @@ export class BridgeClient { if (this.ws && this.ws.readyState === WebSocket.OPEN) { return Promise.resolve(this.ws) } - return new Promise((resolve, reject) => { + 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) }) ws.once('error', (err) => { + this.connecting = null reject( new MMGISError( `Could not connect to the MMGIS websocket at ${this.wsUrl}: ${err.message}`, @@ -26,6 +32,7 @@ export class BridgeClient { ) }) }) + return this.connecting } async sendCommand(mission: string, command: string, args: object): Promise { @@ -71,5 +78,6 @@ export class BridgeClient { close() { this.ws?.close() this.ws = null + this.connecting = null } } diff --git a/mcp/tests/bridge.spec.ts b/mcp/tests/bridge.spec.ts index c3b4247a5..13ffcc84f 100644 --- a/mcp/tests/bridge.spec.ts +++ b/mcp/tests/bridge.spec.ts @@ -1,6 +1,7 @@ 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 }> { @@ -81,4 +82,44 @@ describe('BridgeClient', () => { 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('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/) + }) }) From 44d5664d56c7438f2194571a3362bff6777c4d87 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 04:53:54 -0500 Subject: [PATCH 16/71] Register MMGIS MCP server and add setup/demo documentation --- .mcp.json | 12 +++++++++ AGENTS.md | 1 + mcp/README.md | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 .mcp.json create mode 100644 mcp/README.md 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..79ec692ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,7 @@ MMGIS/ │ └── Ancillary/ # UI components and helpers ├── configure/ # Admin configuration interface │ └── build/ # Configuration UI +├── mcp/ # MCP server: agents drive MMGIS + generate dashboards ├── docs/ # Documentation (Jekyll site) ├── public/ # Static assets ├── Missions/ # Mission data storage diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 000000000..4183b4e1b --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,74 @@ +# 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). From 0e56ec69659c59a85c1f4a4170c2efd64999a67e Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 04:56:16 -0500 Subject: [PATCH 17/71] Remove .mcp.json from .gitignore --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index 9a20c8f40..cf9cc6a9d 100644 --- a/.gitignore +++ b/.gitignore @@ -55,8 +55,6 @@ sessions .terraform/ .terraform.lock.hcl - -.mcp.json .serena .claude/* !.claude/skills/ From 400806ae4f2df65eb88807094b04fb0f66cc99f1 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 05:16:52 -0500 Subject: [PATCH 18/71] [final-review] Fix open_tool false success in modern-mode missions ToolController_ (classic) never runs when a mission's msv.mode is 'modern' - only ToolControllerModern_ does, wired behind window.mmgisAPI's show/hide/load-plugin API. commands.js was calling the classic controller unconditionally and reporting ok:true regardless of whether anything actually happened. Replace the ToolController_ dependency with a mode-aware ToolAdapter built in AgentBridge.js: classic missions still drive ToolController_.makeTool, modern missions drive window.mmgisAPI.showPlugin/loadPlugin based on ground-truth isPluginLoaded/isPluginHidden state. open_tool now returns ok:false with "Unknown or unopenable tool: " whenever activation can't be confirmed, in either mode. --- .../AgentBridge/AgentBridge.js | 74 ++++++++++++++++++- .../AgentBridge/commands.js | 20 +++-- tests/unit/agentBridgeCommands.spec.js | 33 ++++++++- 3 files changed, 118 insertions(+), 9 deletions(-) diff --git a/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js index e336e29cb..ca4a200d8 100644 --- a/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js @@ -9,6 +9,78 @@ import { executeCommand } from './commands' const FRAME_TYPE = 'agent-bridge' const RECONNECT_MS = 10000 +// 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. +function buildToolAdapter() { + const isModern = L_.configData && L_.configData.msv && L_.configData.msv.mode === 'modern' + + 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 loaded = api.isPluginLoaded(name) + const hidden = api.isPluginHidden(name) + + if (loaded && !hidden) { + // Already visible; treat as a success (idempotent open). + return { ok: true, activeTool: name } + } + if (loaded && hidden) { + // Loaded but hidden (hidePlugin / startHidden) — reveal it. + return api.showPlugin(name) + ? { ok: true, activeTool: name } + : { ok: false, error: `Unknown or unopenable tool: ${name}` } + } + if (!loaded && hidden) { + // Deferred (startUnloaded / previously unloadPlugin'd) — load it. + return api.loadPlugin(name) + ? { ok: true, activeTool: name } + : { ok: false, error: `Unknown or unopenable tool: ${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 { ok: false, error: `Unknown or unopenable tool: ${name}` } + }, + } + } + + return { + mode: 'classic', + activeToolName: function () { + return ToolController_.activeToolName + }, + openTool: function (name) { + const tool = ToolController_.toolModules && ToolController_.toolModules[name] + if ( + !tool || + typeof tool.make !== 'function' || + typeof tool.destroy !== 'function' + ) { + return { ok: false, error: `Unknown or unopenable tool: ${name}` } + } + ToolController_.makeTool(name) + return { ok: true, activeTool: ToolController_.activeToolName } + }, + } +} + const AgentBridge = { ws: null, sessionId: null, @@ -89,7 +161,7 @@ const AgentBridge = { outcome = await executeCommand(command, args, { Map_, L_, - ToolController_, + ToolAdapter: buildToolAdapter(), TimeControl, }) } catch (err) { diff --git a/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js b/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js index be90e4f60..3ff16862f 100644 --- a/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js @@ -6,19 +6,22 @@ function isFiniteNumber(v) { } export function getViewState(deps) { - const { Map_, L_, ToolController_, TimeControl } = 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: ToolController_ ? ToolController_.activeToolName : null, + activeTool: + ToolAdapter && typeof ToolAdapter.activeToolName === 'function' + ? ToolAdapter.activeToolName() + : null, currentTime: TimeControl && TimeControl.getTime ? TimeControl.getTime() : null, } } export async function executeCommand(command, args, deps) { - const { Map_, L_, ToolController_, TimeControl } = deps + const { Map_, L_, ToolAdapter, TimeControl } = deps const a = args || {} switch (command) { case 'fly_to': { @@ -42,8 +45,15 @@ export async function executeCommand(command, args, deps) { 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 } } + 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) diff --git a/tests/unit/agentBridgeCommands.spec.js b/tests/unit/agentBridgeCommands.spec.js index df9cadea4..6c54db6ae 100644 --- a/tests/unit/agentBridgeCommands.spec.js +++ b/tests/unit/agentBridgeCommands.spec.js @@ -18,7 +18,15 @@ function makeDeps() { this.layers.on['uuid-1'] = !this.layers.on['uuid-1'] }), }, - ToolController_: { makeTool: vi.fn(), activeToolName: 'LayerManager' }, + 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', @@ -55,11 +63,30 @@ describe('executeCommand', () => { expect(res.ok).toBe(false) expect(res.error).toMatch(/Unknown layer/) }) - it('open_tool calls ToolController_.makeTool', async () => { + 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.ToolController_.makeTool).toHaveBeenCalledWith('Chart') + 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()) From 7d50de025e1ffbeec5e6a060df171bb84545d405 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 05:17:05 -0500 Subject: [PATCH 19/71] [final-review] Preflight mission names and warn on missing MAPBOX_TOKEN dashboard_generate ran the full (expensive) profile-build and config generation before finding out /api/configure/add would reject the mission name. Mirror configs.js's add() character/prefix rule (mcp/src/tools/dashboard.ts) and validate missionName first, returning {error, hint} without touching the client. The rule is now also documented in dashboard_profile_schema and the missionName zod .describe(). Also: dashboard_generate silently produced a config with an unrendered basemap when MAPBOX_TOKEN was unset. Detect whether the generated config needed {{MAPBOX_TOKEN}} before resolving placeholders, and surface a warnings: [...] entry in the success payload when the token is missing. --- mcp/src/tools/dashboard.ts | 35 ++++++++++++++++++++++++-- mcp/tests/dashboard.spec.ts | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/mcp/src/tools/dashboard.ts b/mcp/src/tools/dashboard.ts index e15ce2ff1..5b1275ff3 100644 --- a/mcp/src/tools/dashboard.ts +++ b/mcp/src/tools/dashboard.ts @@ -35,8 +35,31 @@ const LAYER_EXAMPLES = { }, } +// 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, + } +} + const dashboardGenerateSchema = { - missionName: z.string().describe('Name for the new mission/dashboard'), + missionName: z.string().describe(`Name for the new mission/dashboard. ${MISSION_NAME_RULE}`), layers: z .array(z.record(z.any())) .optional() @@ -63,7 +86,7 @@ export function makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef handler: async () => toToolResult({ spec: { - missionName: 'string (required)', + missionName: `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)', @@ -95,8 +118,11 @@ export function makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef schema: dashboardGenerateSchema, handler: async (args: DashboardSpec & { updateExisting?: boolean }) => { try { + const nameError = validateMissionName(args.missionName) + if (nameError) return toErrorResult(nameError) const profile = buildProfile(args, cfg.repoRoot) let config = await generateConfig(profile, cfg.repoRoot) + const neededMapboxToken = JSON.stringify(config).includes('{{MAPBOX_TOKEN}}') config = resolvePlaceholders(config, cfg.mapboxToken) // Injected after generation: `components` is not a template key, // and /api/configure/add does not run backend validation. @@ -114,10 +140,15 @@ export function makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef throw err } } + 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) diff --git a/mcp/tests/dashboard.spec.ts b/mcp/tests/dashboard.spec.ts index 4e485813f..d001abaeb 100644 --- a/mcp/tests/dashboard.spec.ts +++ b/mcp/tests/dashboard.spec.ts @@ -77,6 +77,55 @@ describe('dashboard tools', () => { 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({ missionName: '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.missionName).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({ missionName: 'AQ Test' })) + expect(out.warnings).toEqual(['MAPBOX_TOKEN is not set — the basemap will not render']) + }, 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({ missionName: 'AQ Test' })) + expect(out.warnings).toBeUndefined() + }, 30000) + it('reports exists-error with a hint when updateExisting is not set', async () => { const client = { addMission: async () => { From d3349ecc201bc0e0621e27e44085f217873c0146 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 05:17:10 -0500 Subject: [PATCH 20/71] [final-review] Wrap res.json() with a diagnosable MMGISError A non-JSON response body (e.g. hitting a proxy or login page instead of the MMGIS API) made res.json() throw a raw SyntaxError with no actionable context. Wrap it and raise MMGISError('MMGIS returned a non-JSON response for ', ...) with a hint pointing at MMGIS_URL. --- mcp/src/mmgisClient.ts | 10 +++++++++- mcp/tests/mmgisClient.spec.ts | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/mcp/src/mmgisClient.ts b/mcp/src/mmgisClient.ts index 622d74376..ac50f0967 100644 --- a/mcp/src/mmgisClient.ts +++ b/mcp/src/mmgisClient.ts @@ -35,7 +35,15 @@ export class MmgisClient { 'Check MMGIS_URL and that MMGIS_TOKEN is a valid, unexpired long-term token.' ) } - const json = await res.json() + 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}`) } diff --git a/mcp/tests/mmgisClient.spec.ts b/mcp/tests/mmgisClient.spec.ts index 80c44509f..b07b4a0c3 100644 --- a/mcp/tests/mmgisClient.spec.ts +++ b/mcp/tests/mmgisClient.spec.ts @@ -34,6 +34,21 @@ describe('MmgisClient', () => { 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) From fbc61bec986ee98e2f165de66c5ae52e91e757d2 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 05:17:15 -0500 Subject: [PATCH 21/71] [final-review] Validate STAC_CATALOGS shape after parsing JSON.parse(STAC_CATALOGS) alone doesn't guarantee the {name: url} string map shape stac.ts assumes - an array or an object with non-string values parsed fine but broke confusingly downstream. Add a shape assertion and throw an existing-style Error naming the expected shape when it doesn't hold. --- mcp/src/config.ts | 21 +++++++++++++++++++-- mcp/tests/config.spec.ts | 4 ++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/mcp/src/config.ts b/mcp/src/config.ts index 155e638f1..0547331eb 100644 --- a/mcp/src/config.ts +++ b/mcp/src/config.ts @@ -16,6 +16,20 @@ const DEFAULT_STAC_CATALOGS: Record = { 'earth-search': 'https://earth-search.aws.element84.com/v1', } +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) + } +} + export function loadConfig(env: Record = process.env): McpConfig { if (!env.MMGIS_TOKEN) { throw new Error( @@ -30,11 +44,14 @@ export function loadConfig(env: Record = process.env const defaultRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') let stacCatalogs = DEFAULT_STAC_CATALOGS if (env.STAC_CATALOGS) { + let parsed: unknown try { - stacCatalogs = JSON.parse(env.STAC_CATALOGS) + parsed = JSON.parse(env.STAC_CATALOGS) } catch { - throw new Error('STAC_CATALOGS must be a JSON object of {name: url}') + throw new Error(STAC_CATALOGS_SHAPE_ERROR) } + assertStacCatalogsShape(parsed) + stacCatalogs = parsed } return { mmgisUrl, diff --git a/mcp/tests/config.spec.ts b/mcp/tests/config.spec.ts index c8b3be0ec..e5aefb737 100644 --- a/mcp/tests/config.spec.ts +++ b/mcp/tests/config.spec.ts @@ -21,6 +21,10 @@ describe('loadConfig', () => { 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) }) From 557555598c62e8b2822bcadd6f06272049c9f5c1 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 05:17:19 -0500 Subject: [PATCH 22/71] [final-review] Make BridgeClient survive post-open socket errors The connect-phase once('error', ...) listener stays attached (it never fired) for the lifetime of the socket, but nothing cleared this.ws after a post-open error, so a later sendCommand could keep reusing a dead socket. Attach a persistent on('error', ...) once the socket opens that nulls this.ws (and this.connecting), so a later error can't leave the client stuck and the next command transparently reconnects. --- mcp/src/bridge.ts | 11 +++++++++++ mcp/tests/bridge.spec.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/mcp/src/bridge.ts b/mcp/src/bridge.ts index 000944142..fa074619e 100644 --- a/mcp/src/bridge.ts +++ b/mcp/src/bridge.ts @@ -21,6 +21,17 @@ export class BridgeClient { 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', () => { + if (this.ws === ws) this.ws = null + if (this.connecting) this.connecting = null + }) }) ws.once('error', (err) => { this.connecting = null diff --git a/mcp/tests/bridge.spec.ts b/mcp/tests/bridge.spec.ts index 13ffcc84f..9ea0c0044 100644 --- a/mcp/tests/bridge.spec.ts +++ b/mcp/tests/bridge.spec.ts @@ -116,6 +116,39 @@ describe('BridgeClient', () => { 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) From 81fce7caef684cac539213efc78b67d40df44a00 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 05:17:24 -0500 Subject: [PATCH 23/71] [final-review] Correct README security notes and E2E checklist The relay note undersold the exposure: API/websocket.js broadcasts every frame to every connected client with no per-mission routing, so any peer can issue commands for and read view-state acks from any mission - not just "unauthenticated upstream". Spell that out, plus the multi-session semantics (all sessions on a mission execute each command; first ack wins) and that presence frames are broadcast but unconsumed (reserved for Phase 2). Also update the view_open_tool checklist line, which hedged on ToolControllerModern_ wiring that has since landed. --- mcp/README.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 4183b4e1b..97ff2b55c 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -63,12 +63,28 @@ browser → `view_fly_to`. - [ ] 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_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 is unauthenticated upstream; do not expose it +- 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). From f9b755822801631eff1bce3c7ed8347d13e4c9d1 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 05:24:41 -0500 Subject: [PATCH 24/71] Fix AgentBridge open_tool id resolution and guard bridge reconnect state Resolve tool names to their config js id before hitting the id-keyed mmgisAPI show/load/isPluginLoaded/isPluginHidden calls and the classic ToolController_.toolModules/makeTool lookup, since mission configs expose the display name while those APIs are keyed by js. Add a dependency-free resolveToolId() in commands.js so the resolution logic is unit-testable, and use it from both the modern and classic branches of buildToolAdapter(). Also guard the persistent post-open websocket error handler in mcp/src/bridge.ts so it only clears this.connecting when the erroring socket was still the current one, preventing it from nulling out a newer in-flight reconnect's promise. --- mcp/src/bridge.ts | 9 ++++++-- .../AgentBridge/AgentBridge.js | 22 ++++++++++--------- .../AgentBridge/commands.js | 11 ++++++++++ tests/unit/agentBridgeCommands.spec.js | 21 ++++++++++++++++++ 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/mcp/src/bridge.ts b/mcp/src/bridge.ts index fa074619e..35f51efed 100644 --- a/mcp/src/bridge.ts +++ b/mcp/src/bridge.ts @@ -29,8 +29,13 @@ export class BridgeClient { // 'error' with zero listeners) and so the next sendCommand() // reconnects instead of reusing the broken socket. ws.on('error', () => { - if (this.ws === ws) this.ws = null - if (this.connecting) this.connecting = null + // 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) => { diff --git a/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js index ca4a200d8..abd2c4eff 100644 --- a/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js @@ -3,7 +3,7 @@ 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 } from './commands' +import { executeCommand, resolveToolId } from './commands' // Envelope contract shared with mcp/src/bridge.ts — keep in sync. const FRAME_TYPE = 'agent-bridge' @@ -34,23 +34,24 @@ function buildToolAdapter() { error: 'open_tool is not supported in modern mode yet', } } - const loaded = api.isPluginLoaded(name) - const hidden = api.isPluginHidden(name) + 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 { ok: true, activeTool: name } + return { ok: true, activeTool: toolId } } if (loaded && hidden) { // Loaded but hidden (hidePlugin / startHidden) — reveal it. - return api.showPlugin(name) - ? { ok: true, activeTool: name } + return api.showPlugin(toolId) + ? { ok: true, activeTool: toolId } : { ok: false, error: `Unknown or unopenable tool: ${name}` } } if (!loaded && hidden) { // Deferred (startUnloaded / previously unloadPlugin'd) — load it. - return api.loadPlugin(name) - ? { ok: true, activeTool: name } + return api.loadPlugin(toolId) + ? { ok: true, activeTool: toolId } : { ok: false, error: `Unknown or unopenable tool: ${name}` } } // Neither loaded nor deferred: this name was never assigned to a @@ -67,7 +68,8 @@ function buildToolAdapter() { return ToolController_.activeToolName }, openTool: function (name) { - const tool = ToolController_.toolModules && ToolController_.toolModules[name] + const toolId = resolveToolId(L_.configData && L_.configData.tools, name) + const tool = ToolController_.toolModules && ToolController_.toolModules[toolId] if ( !tool || typeof tool.make !== 'function' || @@ -75,7 +77,7 @@ function buildToolAdapter() { ) { return { ok: false, error: `Unknown or unopenable tool: ${name}` } } - ToolController_.makeTool(name) + ToolController_.makeTool(toolId) return { ok: true, activeTool: ToolController_.activeToolName } }, } diff --git a/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js b/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js index 3ff16862f..d45780df5 100644 --- a/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js @@ -5,6 +5,17 @@ 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 { diff --git a/tests/unit/agentBridgeCommands.spec.js b/tests/unit/agentBridgeCommands.spec.js index 6c54db6ae..c700e0077 100644 --- a/tests/unit/agentBridgeCommands.spec.js +++ b/tests/unit/agentBridgeCommands.spec.js @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest' import { executeCommand, getViewState, + resolveToolId, } from '../../src/essence/MMGIS-Plugin-Components/AgentBridge/commands' function makeDeps() { @@ -136,3 +137,23 @@ describe('getViewState', () => { expect(state.zoom).toBe(null) }) }) + +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') + }) +}) From 42dfc0ed7804c32e9800bb05642924ef7e4dcc5a Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Thu, 23 Jul 2026 11:58:11 -0500 Subject: [PATCH 25/71] Flatten AgentBridge config.json to the shape updateComponents expects --- .../AgentBridge/config.json | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/essence/MMGIS-Plugin-Components/AgentBridge/config.json b/src/essence/MMGIS-Plugin-Components/AgentBridge/config.json index e774f4c87..daffa4040 100644 --- a/src/essence/MMGIS-Plugin-Components/AgentBridge/config.json +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/config.json @@ -1,12 +1,10 @@ { - "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" - } + "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" } } From cbe1cf0cd0bfa88016bc8e2b200dc19a397a17f3 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Fri, 24 Jul 2026 19:12:48 -0500 Subject: [PATCH 26/71] Add chat UI design spec --- .../specs/2026-07-24-chat-ui-design.md | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-24-chat-ui-design.md 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..b8aaee1d1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-chat-ui-design.md @@ -0,0 +1,126 @@ +# 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. + +## 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. From 715ffacfca2b21b84a84cb2853c80c88206d456e Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Fri, 24 Jul 2026 19:16:58 -0500 Subject: [PATCH 27/71] Add JSON config round-trip to chat UI spec --- .../specs/2026-07-24-chat-ui-design.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/superpowers/specs/2026-07-24-chat-ui-design.md b/docs/superpowers/specs/2026-07-24-chat-ui-design.md index b8aaee1d1..aed4a6928 100644 --- a/docs/superpowers/specs/2026-07-24-chat-ui-design.md +++ b/docs/superpowers/specs/2026-07-24-chat-ui-design.md @@ -117,6 +117,26 @@ every var. Root `.gitignore` needs no change (chat/.env handled locally). 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). From 89de14efa7a822a7a61c1da38628ce72c80d6f78 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Fri, 24 Jul 2026 19:21:46 -0500 Subject: [PATCH 28/71] Add chat UI implementation plan --- docs/superpowers/plans/2026-07-24-chat-ui.md | 1414 ++++++++++++++++++ 1 file changed, 1414 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-24-chat-ui.md 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. From 4d2e04770aa1f94dda0ee78110fc9d49662f5d1b Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Fri, 24 Jul 2026 19:35:37 -0500 Subject: [PATCH 29/71] Add dashboard JSON config round-trip to MCP server --- mcp/README.md | 2 +- mcp/src/tools/dashboard.ts | 70 ++++++++++++++++++++++++++++++------- mcp/tests/dashboard.spec.ts | 55 +++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 14 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 97ff2b55c..7bcf6c3a5 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -41,7 +41,7 @@ catalogs for data layers, and control a live browser session. ## Tools - `mission_list`, `mission_get` — admin plane -- `dashboard_profile_schema`, `dashboard_tool_options`, `dashboard_generate` — NL → dashboard +- `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) diff --git a/mcp/src/tools/dashboard.ts b/mcp/src/tools/dashboard.ts index 5b1275ff3..4025e68c5 100644 --- a/mcp/src/tools/dashboard.ts +++ b/mcp/src/tools/dashboard.ts @@ -58,6 +58,23 @@ function validateMissionName(missionName: string): { message: string; hint: stri } } +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 = { missionName: z.string().describe(`Name for the new mission/dashboard. ${MISSION_NAME_RULE}`), layers: z @@ -74,6 +91,10 @@ const dashboardGenerateSchema = { 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[] { @@ -116,7 +137,7 @@ export function makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef 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 }) => { + handler: async (args: DashboardSpec & { updateExisting?: boolean; returnConfig?: boolean }) => { try { const nameError = validateMissionName(args.missionName) if (nameError) return toErrorResult(nameError) @@ -127,19 +148,42 @@ export function makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef // 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 - } + 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 } : {}), + ...(args.returnConfig ? { config } : {}), + }) + } catch (err) { + return toErrorResult(err) + } + }, + }, + { + 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') diff --git a/mcp/tests/dashboard.spec.ts b/mcp/tests/dashboard.spec.ts index d001abaeb..95dcc6c0e 100644 --- a/mcp/tests/dashboard.spec.ts +++ b/mcp/tests/dashboard.spec.ts @@ -137,4 +137,59 @@ describe('dashboard tools', () => { 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({ 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) + }) }) From a8c40d565d3cb50a7b9a6caa437c54aebc4f3aa3 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Fri, 24 Jul 2026 19:39:30 -0500 Subject: [PATCH 30/71] Scaffold standalone chat app with env config --- chat/.env.example | 13 + chat/.gitignore | 2 + chat/lib/config.js | 18 + chat/package-lock.json | 3218 +++++++++++++++++++++++++++++++++++++ chat/package.json | 19 + chat/tests/config.spec.js | 28 + chat/vitest.config.js | 7 + 7 files changed, 3305 insertions(+) create mode 100644 chat/.env.example create mode 100644 chat/.gitignore create mode 100644 chat/lib/config.js create mode 100644 chat/package-lock.json create mode 100644 chat/package.json create mode 100644 chat/tests/config.spec.js create mode 100644 chat/vitest.config.js diff --git a/chat/.env.example b/chat/.env.example new file mode 100644 index 000000000..0a2494d11 --- /dev/null +++ b/chat/.env.example @@ -0,0 +1,13 @@ +# 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= 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/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/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/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/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'] + } +}) From cc53828abdc4de75b3d3a07d61b633b0ac35c5ee Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Fri, 24 Jul 2026 19:42:15 -0500 Subject: [PATCH 31/71] Add MCP bridge exposing tools as OpenAI function schemas --- chat/lib/mcpBridge.js | 64 ++++++++++++++++++++++++++++++++++++ chat/tests/mcpBridge.spec.js | 52 +++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 chat/lib/mcpBridge.js create mode 100644 chat/tests/mcpBridge.spec.js diff --git a/chat/lib/mcpBridge.js b/chat/lib/mcpBridge.js new file mode 100644 index 000000000..a177a0a04 --- /dev/null +++ b/chat/lib/mcpBridge.js @@ -0,0 +1,64 @@ +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 + } +} diff --git a/chat/tests/mcpBridge.spec.js b/chat/tests/mcpBridge.spec.js new file mode 100644 index 000000000..0dc73bd56 --- /dev/null +++ b/chat/tests/mcpBridge.spec.js @@ -0,0 +1,52 @@ +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) + }) +}) From a09532554e256c493b9e67b963c130fd6e9c0dd9 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Fri, 24 Jul 2026 20:00:36 -0500 Subject: [PATCH 32/71] Add OpenAI function-calling agent loop --- chat/lib/agentLoop.js | 80 ++++++++++++++++++++++++ chat/tests/agentLoop.spec.js | 116 +++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 chat/lib/agentLoop.js create mode 100644 chat/tests/agentLoop.spec.js diff --git a/chat/lib/agentLoop.js b/chat/lib/agentLoop.js new file mode 100644 index 000000000..147eaffb9 --- /dev/null +++ b/chat/lib/agentLoop.js @@ -0,0 +1,80 @@ +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) } +} 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') + }) +}) From abff147efcd1987257c75ec746b7eb6706a19083 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Fri, 24 Jul 2026 20:04:08 -0500 Subject: [PATCH 33/71] Add chat server with SSE streaming endpoints --- chat/lib/app.js | 53 ++++++++++++++++++++++++++ chat/server.js | 14 +++++++ chat/tests/app.spec.js | 84 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 chat/lib/app.js create mode 100644 chat/server.js create mode 100644 chat/tests/app.spec.js diff --git a/chat/lib/app.js b/chat/lib/app.js new file mode 100644 index 000000000..13cd6374b --- /dev/null +++ b/chat/lib/app.js @@ -0,0 +1,53 @@ +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 +} diff --git a/chat/server.js b/chat/server.js new file mode 100644 index 000000000..167659dc6 --- /dev/null +++ b/chat/server.js @@ -0,0 +1,14 @@ +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})`) +}) diff --git a/chat/tests/app.spec.js b/chat/tests/app.spec.js new file mode 100644 index 000000000..6ecaab989 --- /dev/null +++ b/chat/tests/app.spec.js @@ -0,0 +1,84 @@ +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' }) + }) +}) From 7904710d675b738a6b915f7ab5dc72ef1dcd3465 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Fri, 24 Jul 2026 20:06:37 -0500 Subject: [PATCH 34/71] Add test for GET /api/health graceful degradation when MCP bridge is down --- chat/tests/app.spec.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/chat/tests/app.spec.js b/chat/tests/app.spec.js index 6ecaab989..4ab04a9f4 100644 --- a/chat/tests/app.spec.js +++ b/chat/tests/app.spec.js @@ -81,4 +81,16 @@ describe('chat app', () => { 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 }) + }) }) From 77fb4b50c164ea4f576800a570c4f5dc10e9d607 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Fri, 24 Jul 2026 20:08:26 -0500 Subject: [PATCH 35/71] Add chat frontend with tool cards and JSON config drawer --- chat/public/app.js | 206 ++++++++++++++++++++++++++++++++++++ chat/public/index.html | 36 +++++++ chat/public/style.css | 50 +++++++++ chat/tests/frontend.spec.js | 30 ++++++ 4 files changed, 322 insertions(+) create mode 100644 chat/public/app.js create mode 100644 chat/public/index.html create mode 100644 chat/public/style.css create mode 100644 chat/tests/frontend.spec.js diff --git a/chat/public/app.js b/chat/public/app.js new file mode 100644 index 000000000..8300b336b --- /dev/null +++ b/chat/public/app.js @@ -0,0 +1,206 @@ +// --- 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) +} diff --git a/chat/public/index.html b/chat/public/index.html new file mode 100644 index 000000000..6acc929ab --- /dev/null +++ b/chat/public/index.html @@ -0,0 +1,36 @@ + + + + + + MMGIS Chat + + + +
+

MMGIS Chat

+
connecting…
+
+ + +
+
+ + + +
+ +
+ + +
+ + + + diff --git a/chat/public/style.css b/chat/public/style.css new file mode 100644 index 000000000..195106707 --- /dev/null +++ b/chat/public/style.css @@ -0,0 +1,50 @@ +* { 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; } diff --git a/chat/tests/frontend.spec.js b/chat/tests/frontend.spec.js new file mode 100644 index 000000000..e8aa5399f --- /dev/null +++ b/chat/tests/frontend.spec.js @@ -0,0 +1,30 @@ +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']) + }) +}) From 305ac14b441a24e4c6cd1410e5be9f7b1a936b04 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Fri, 24 Jul 2026 20:14:38 -0500 Subject: [PATCH 36/71] Fix chat frontend review findings: bubble dup, races, XSS, robustness - Split assistant streaming text into per-bubble bubbleText and whole-turn fullText to stop duplicate bubble content after tool calls; fullText is what gets saved to the conversation history. - Guard newChat and composer submit against an in-flight request so a still-streaming response can't land in a freshly reset chat and typed input isn't silently dropped while busy. - Build the tool-call summary via textContent instead of innerHTML to avoid XSS from a model-controlled tool name. - Wrap the initial localStorage JSON.parse in try/catch. - Disable send/newChat while busy with a matching CSS rule. --- chat/public/app.js | 36 ++++++++++++++++++++++++++++-------- chat/public/style.css | 1 + 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/chat/public/app.js b/chat/public/app.js index 8300b336b..d5293fbca 100644 --- a/chat/public/app.js +++ b/chat/public/app.js @@ -42,12 +42,18 @@ if (typeof document !== 'undefined') { 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') - let messages = JSON.parse(localStorage.getItem('mmgisChat') || '[]') + let messages = [] + try { + messages = JSON.parse(localStorage.getItem('mmgisChat') || '[]') + } catch { + messages = [] + } let busy = false const save = () => localStorage.setItem('mmgisChat', JSON.stringify(messages)) @@ -64,7 +70,9 @@ if (typeof document !== 'undefined') { function addToolCard(name, args) { const details = document.createElement('details') details.className = 'tool' - details.innerHTML = `🔧 ${name}` + 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) @@ -110,9 +118,12 @@ if (typeof document !== 'undefined') { async function sendConversation() { busy = true + send.disabled = true + newChat.disabled = true const toolCards = new Map() let assistantDiv = null - let assistantText = '' + let bubbleText = '' + let fullText = '' try { const res = await fetch('/api/chat', { method: 'POST', @@ -131,9 +142,14 @@ if (typeof document !== 'undefined') { buffer = rest for (const ev of events) { if (ev.type === 'text') { - if (!assistantDiv) assistantDiv = addBubble('assistant', '') - assistantText += ev.delta - assistantDiv.textContent = assistantText + if (!assistantDiv) { + assistantDiv = addBubble('assistant', '') + bubbleText = '' + if (fullText) fullText += '\n' + } + bubbleText += ev.delta + fullText += ev.delta + assistantDiv.textContent = bubbleText transcript.scrollTop = transcript.scrollHeight } else if (ev.type === 'tool_call') { // a new assistant bubble will follow the tool round @@ -147,14 +163,16 @@ if (typeof document !== 'undefined') { } } } - if (assistantText) { - messages.push({ role: 'assistant', content: assistantText }) + if (fullText) { + messages.push({ role: 'assistant', content: fullText }) save() } } catch (err) { addBubble('error', `Error: ${err.message}`) } finally { busy = false + send.disabled = false + newChat.disabled = false } } @@ -168,6 +186,7 @@ if (typeof document !== 'undefined') { composer.addEventListener('submit', (e) => { e.preventDefault() + if (busy) return const content = input.value input.value = '' submitUserMessage(content) @@ -179,6 +198,7 @@ if (typeof document !== 'undefined') { } }) newChat.addEventListener('click', () => { + if (busy) return messages = [] save() render() diff --git a/chat/public/style.css b/chat/public/style.css index 195106707..721d45744 100644 --- a/chat/public/style.css +++ b/chat/public/style.css @@ -18,6 +18,7 @@ button { border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer; } button:hover { background: #34426a; } +button:disabled { opacity: .5; cursor: default; } .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; } From 39a29e153a745b4341a0748b352ef9dc26d70c1a Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Fri, 24 Jul 2026 20:17:15 -0500 Subject: [PATCH 37/71] Add chat app documentation and register it in the project structure --- AGENTS.md | 1 + chat/README.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 chat/README.md diff --git a/AGENTS.md b/AGENTS.md index 79ec692ac..b637082d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,6 +133,7 @@ MMGIS/ ├── 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/chat/README.md b/chat/README.md new file mode 100644 index 000000000..80c76b31b --- /dev/null +++ b/chat/README.md @@ -0,0 +1,51 @@ +# 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 From 1b94c05cd9ec099871304dff3daa4770cbc12946 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Fri, 24 Jul 2026 20:25:08 -0500 Subject: [PATCH 38/71] [chat] Harden MCP bridge lifecycle and error serialization for merge Guard McpBridge.connect() against concurrent callers with an in-flight promise, close the dead client before dropping a broken connection, and serialize non-Error throws safely in both the bridge and the chat API's error events. Add a graceful shutdown hook to server.js and document known limitations of cross-turn memory and large-config round-tripping in the README. --- chat/README.md | 9 +++++++++ chat/lib/app.js | 2 +- chat/lib/mcpBridge.js | 19 +++++++++++++++---- chat/server.js | 7 +++++++ chat/tests/app.spec.js | 12 ++++++++++++ chat/tests/mcpBridge.spec.js | 18 ++++++++++++++++++ 6 files changed, 62 insertions(+), 5 deletions(-) diff --git a/chat/README.md b/chat/README.md index 80c76b31b..e9d3bcca4 100644 --- a/chat/README.md +++ b/chat/README.md @@ -40,6 +40,15 @@ MMGIS REST + websocket. Conversation state lives in your browser | `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 | +## 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 14 tools diff --git a/chat/lib/app.js b/chat/lib/app.js index 13cd6374b..f199d4a77 100644 --- a/chat/lib/app.js +++ b/chat/lib/app.js @@ -44,7 +44,7 @@ export function createApp({ cfg, openai, bridge }) { try { await runAgentLoop({ messages, openai, bridge, model: cfg.model, onEvent: send }) } catch (err) { - send({ type: 'error', message: err.message }) + send({ type: 'error', message: String(err?.message ?? err) }) } res.end() }) diff --git a/chat/lib/mcpBridge.js b/chat/lib/mcpBridge.js index a177a0a04..d338f66ec 100644 --- a/chat/lib/mcpBridge.js +++ b/chat/lib/mcpBridge.js @@ -19,12 +19,21 @@ export class McpBridge { this.clientFactory = clientFactory this.client = null this.tools = null + this.connecting = null } async connect() { if (this.client) return - const { client } = await this.clientFactory(this.cfg) - this.client = client + 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() { @@ -49,10 +58,12 @@ export class McpBridge { 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) + // 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: err.message }), isError: true } + return { text: JSON.stringify({ error: String(err?.message ?? err) }), isError: true } } } diff --git a/chat/server.js b/chat/server.js index 167659dc6..20e379c43 100644 --- a/chat/server.js +++ b/chat/server.js @@ -12,3 +12,10 @@ 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/app.spec.js b/chat/tests/app.spec.js index 4ab04a9f4..1868c3a97 100644 --- a/chat/tests/app.spec.js +++ b/chat/tests/app.spec.js @@ -82,6 +82,18 @@ describe('chat app', () => { 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, diff --git a/chat/tests/mcpBridge.spec.js b/chat/tests/mcpBridge.spec.js index 0dc73bd56..b7b90be9e 100644 --- a/chat/tests/mcpBridge.spec.js +++ b/chat/tests/mcpBridge.spec.js @@ -46,7 +46,25 @@ describe('McpBridge', () => { 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') + }) }) From 61aeb91d8e72e56f390a3c1ac3b7967698d1de16 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 13:17:09 -0500 Subject: [PATCH 39/71] Add chat-driven full configuration design spec --- .../2026-07-25-chat-full-config-design.md | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-25-chat-full-config-design.md 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). From 1a2ca8cb56482d651c0bc27c73d4c143bba59e1e Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 14:03:17 -0500 Subject: [PATCH 40/71] Add chat-driven full configuration implementation plan --- .../plans/2026-07-25-chat-full-config.md | 1106 +++++++++++++++++ 1 file changed, 1106 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-25-chat-full-config.md 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). From a2803b5fc31c5d5282ad589ad09fda053d527e1a Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 15:11:51 -0500 Subject: [PATCH 41/71] Add REST client methods for config editing and admin operations --- mcp/src/mmgisClient.ts | 53 +++++++++++++++++++++++++++++++++-- mcp/tests/mmgisClient.spec.ts | 44 +++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/mcp/src/mmgisClient.ts b/mcp/src/mmgisClient.ts index ac50f0967..79be7ac65 100644 --- a/mcp/src/mmgisClient.ts +++ b/mcp/src/mmgisClient.ts @@ -12,7 +12,7 @@ export class MmgisClient { private fetchFn: typeof fetch = fetch ) {} - private async request(method: 'GET' | 'POST', apiPath: string, body?: unknown): Promise { + private async request(method: 'GET' | 'POST' | 'DELETE', apiPath: string, body?: unknown): Promise { let res try { res = await this.fetchFn(`${this.baseUrl}${apiPath}`, { @@ -63,7 +63,54 @@ export class MmgisClient { 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 }) + 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/tests/mmgisClient.spec.ts b/mcp/tests/mmgisClient.spec.ts index b07b4a0c3..30b0fe841 100644 --- a/mcp/tests/mmgisClient.spec.ts +++ b/mcp/tests/mmgisClient.spec.ts @@ -76,4 +76,48 @@ describe('MmgisClient', () => { 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 }) + }) }) From 352469ad438ba62fb50bcf5ed90b2658519ed2c2 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 15:14:21 -0500 Subject: [PATCH 42/71] Add RFC 7386 merge patch and config edit helper --- mcp/src/configEdit.ts | 41 +++++++++++++++++++++++ mcp/tests/configEdit.spec.ts | 65 ++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 mcp/src/configEdit.ts create mode 100644 mcp/tests/configEdit.spec.ts diff --git a/mcp/src/configEdit.ts b/mcp/src/configEdit.ts new file mode 100644 index 000000000..30d26b204 --- /dev/null +++ b/mcp/src/configEdit.ts @@ -0,0 +1,41 @@ +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) +} 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) + }) +}) From f32f3dbf81797cbd5ab815432812aec055644aed Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 15:18:12 -0500 Subject: [PATCH 43/71] Add live config editing tools --- mcp/src/index.ts | 2 + mcp/src/tools/edit.ts | 136 +++++++++++++++++++++++++++++++++++++++++ mcp/tests/edit.spec.ts | 86 ++++++++++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 mcp/src/tools/edit.ts create mode 100644 mcp/tests/edit.spec.ts diff --git a/mcp/src/index.ts b/mcp/src/index.ts index ee80d2d23..a3b745dc1 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -7,6 +7,7 @@ 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() { @@ -19,6 +20,7 @@ async function main() { ...makeDashboardTools(client, cfg), ...makeCatalogTools(cfg), ...makeViewTools(bridge), + ...makeEditTools(client), ], }) await server.connect(new StdioServerTransport()) diff --git a/mcp/src/tools/edit.ts b/mcp/src/tools/edit.ts new file mode 100644 index 000000000..a2d5b7d8e --- /dev/null +++ b/mcp/src/tools/edit.ts @@ -0,0 +1,136 @@ +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) + } + }, + }, + ] +} diff --git a/mcp/tests/edit.spec.ts b/mcp/tests/edit.spec.ts new file mode 100644 index 000000000..4ca744186 --- /dev/null +++ b/mcp/tests/edit.spec.ts @@ -0,0 +1,86 @@ +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) + }) +}) From af3d10cd8a8f2b1106f106a5971b57b0edfe3569 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 15:22:48 -0500 Subject: [PATCH 44/71] Add mission and geodataset admin tools with confirmation gating --- mcp/src/tools/admin.ts | 110 +++++++++++++++++++++++++++++++++++++++- mcp/tests/admin.spec.ts | 65 +++++++++++++++++++++++- 2 files changed, 172 insertions(+), 3 deletions(-) diff --git a/mcp/src/tools/admin.ts b/mcp/src/tools/admin.ts index 796d67eaf..3c5ad2ddf 100644 --- a/mcp/src/tools/admin.ts +++ b/mcp/src/tools/admin.ts @@ -1,8 +1,15 @@ import { z } from 'zod' import type { MmgisClient } from '../mmgisClient.js' +import { MMGISError } from '../mmgisClient.js' import { type ToolDef, toToolResult, toErrorResult } from './result.js' -export function makeAdminTools(client: MmgisClient): ToolDef[] { +const MAX_GEOJSON_BYTES = 20 * 1024 * 1024 + +function isFeatureCollection(v: any): boolean { + return v != null && v.type === 'FeatureCollection' && Array.isArray(v.features) +} + +export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetch): ToolDef[] { return [ { name: 'mission_list', @@ -29,5 +36,106 @@ export function makeAdminTools(client: MmgisClient): ToolDef[] { } }, }, + { + 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) + } + }, + }, ] } diff --git a/mcp/tests/admin.spec.ts b/mcp/tests/admin.spec.ts index 0fc65d948..b225797d9 100644 --- a/mcp/tests/admin.spec.ts +++ b/mcp/tests/admin.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { makeAdminTools } from '../src/tools/admin.js' import { MMGISError } from '../src/mmgisClient.js' @@ -15,7 +15,7 @@ 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']) + expect(Object.keys(tools).sort()).toEqual(['geodataset_delete', 'geodataset_ingest', 'geodataset_list', 'mission_clone', 'mission_delete', 'mission_get', 'mission_list']) }) it('mission_list returns mission names', async () => { expect(parse(await tools.mission_list.handler({}))).toEqual({ missions: ['Demo', 'Mars2020'] }) @@ -32,4 +32,65 @@ describe('admin tools', () => { 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 = { 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') + }) }) From 4fcb89771101d0881f24818c8c90f17baa3e2580 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 15:27:34 -0500 Subject: [PATCH 45/71] Add user admin tools and honor SuperAdmin tokens in signup --- API/Backend/Users/routes/users.js | 5 ++- API/Backend/Users/setup.js | 7 +++- mcp/src/tools/admin.ts | 58 +++++++++++++++++++++++++++++++ mcp/tests/admin.spec.ts | 34 +++++++++++++++++- scripts/server.js | 17 +++++++++ 5 files changed, 118 insertions(+), 3 deletions(-) diff --git a/API/Backend/Users/routes/users.js b/API/Backend/Users/routes/users.js index 60be2a0a4..c0baca61c 100644 --- a/API/Backend/Users/routes/users.js +++ b/API/Backend/Users/routes/users.js @@ -82,11 +82,14 @@ router.post("/signup", function (req, res, next) { if ( (process.env.AUTH === "local" && req.session.permission !== "111" && + !(req.isLongTermToken === true && req.tokenUserPermission === "111") && !( 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" && + req.session.permission !== "111" && + !(req.isLongTermToken === true && req.tokenUserPermission === "111")) ) { 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/mcp/src/tools/admin.ts b/mcp/src/tools/admin.ts index 3c5ad2ddf..122284cd2 100644 --- a/mcp/src/tools/admin.ts +++ b/mcp/src/tools/admin.ts @@ -137,5 +137,63 @@ export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetc } }, }, + { + 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) + } + }, + }, ] } diff --git a/mcp/tests/admin.spec.ts b/mcp/tests/admin.spec.ts index b225797d9..1960ae910 100644 --- a/mcp/tests/admin.spec.ts +++ b/mcp/tests/admin.spec.ts @@ -15,7 +15,7 @@ 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']) + 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'] }) @@ -93,4 +93,36 @@ describe('admin tools', () => { 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/scripts/server.js b/scripts/server.js index d1c0d40ac..d599db521 100644 --- a/scripts/server.js +++ b/scripts/server.js @@ -479,6 +479,22 @@ function ensureUser() { }; } +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(); +} + var swaggerOptions = { customCssUrl: "/docs/swagger/swaggerCSS.css", customJs: "/docs/swagger/swaggerJS.js", @@ -503,6 +519,7 @@ let s = { ensureGroup, ensureAdmin, ensureUser, + annotateLongTermToken, swaggerUi, useSwaggerSchema, permissions, From 3a4db10f198ada2696b9a950366ba7e5a77e074b Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 15:31:39 -0500 Subject: [PATCH 46/71] Add catch handler to validateLongTermToken promise chain to prevent hanging requests on DB query errors --- scripts/server.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/server.js b/scripts/server.js index d599db521..a2619ddbb 100644 --- a/scripts/server.js +++ b/scripts/server.js @@ -419,6 +419,9 @@ function validateLongTermToken(token, successCallback, failureCallback) { } else { failureCallback(); } + }) + .catch((err) => { + failureCallback(); }); } From 73eac408670b28c851093f2e0df1fcd0090414b4 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 15:33:17 -0500 Subject: [PATCH 47/71] Add view_reload bridge command for applying non-layer config changes --- mcp/src/tools/view.ts | 6 ++++++ .../MMGIS-Plugin-Components/AgentBridge/AgentBridge.js | 1 + .../MMGIS-Plugin-Components/AgentBridge/commands.js | 4 ++++ tests/unit/agentBridgeCommands.spec.js | 8 ++++++++ 4 files changed, 19 insertions(+) diff --git a/mcp/src/tools/view.ts b/mcp/src/tools/view.ts index 14ec4cf27..f76f3c841 100644 --- a/mcp/src/tools/view.ts +++ b/mcp/src/tools/view.ts @@ -51,5 +51,11 @@ export function makeViewTools(bridge: BridgeClient): ToolDef[] { 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/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js index abd2c4eff..a4b800705 100644 --- a/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js @@ -165,6 +165,7 @@ const AgentBridge = { L_, ToolAdapter: buildToolAdapter(), TimeControl, + reload: () => window.location.reload(), }) } catch (err) { outcome = { ok: false, error: `Command threw: ${err.message}` } diff --git a/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js b/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js index d45780df5..a1b446531 100644 --- a/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js @@ -76,6 +76,10 @@ export async function executeCommand(command, args, deps) { } 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/tests/unit/agentBridgeCommands.spec.js b/tests/unit/agentBridgeCommands.spec.js index c700e0077..d2e0d9042 100644 --- a/tests/unit/agentBridgeCommands.spec.js +++ b/tests/unit/agentBridgeCommands.spec.js @@ -32,6 +32,7 @@ function makeDeps() { setTime: vi.fn(() => true), getTime: () => '2026-06-01T00:00:00Z', }, + reload: vi.fn(), } } @@ -121,6 +122,13 @@ describe('executeCommand', () => { 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) From c30c476cefea08fad34f5edc8eae9095241565b5 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 15:36:09 -0500 Subject: [PATCH 48/71] Teach the chat agent config editing and admin workflows --- chat/README.md | 7 +++++++ chat/lib/agentLoop.js | 4 ++++ mcp/README.md | 4 ++++ 3 files changed, 15 insertions(+) diff --git a/chat/README.md b/chat/README.md index e9d3bcca4..0954b7d58 100644 --- a/chat/README.md +++ b/chat/README.md @@ -22,6 +22,7 @@ all from a browser chat. 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 @@ -58,3 +59,9 @@ MMGIS REST + websocket. Conversation state lives in your browser - [ ] 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 index 147eaffb9..38834f95e 100644 --- a/chat/lib/agentLoop.js +++ b/chat/lib/agentLoop.js @@ -9,6 +9,10 @@ Workflow guidance: - 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. +- 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. - Be concise. Never invent tool results.` export async function runAgentLoop({ messages, openai, bridge, model, onEvent, maxIterations = 15 }) { diff --git a/mcp/README.md b/mcp/README.md index 7bcf6c3a5..9ed95b19b 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -44,6 +44,9 @@ catalogs for data layers, and control a live browser session. - `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 (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 ## Demo (end-to-end) @@ -88,3 +91,4 @@ browser → `view_fly_to`. - 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). +- `/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. From 8d2fc54134045b289649d66c41f85f0a468a8dd2 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 15:42:38 -0500 Subject: [PATCH 49/71] Auto-reload modern-mode sessions via AgentBridge on config mutations Modern dashboards have no websocket client of their own (only classic essence.js auto-applies/RELOADs on config broadcasts), so config edits made through the MCP tools (mission_update_config, layer_add/update/ remove, tool_toggle) silently required a manual page reload to show up. AgentBridge already holds a websocket connection in every mode, so give it a pure, unit-tested predicate (shouldReloadForFrame) to recognize forceClientUpdate broadcasts for the current mission and debounce a window.location.reload() so bursts of edits coalesce into one reload. Also updates the MCP edit-tool refresh note, README, and chat system prompt wording to reflect that config edits now apply live instead of requiring a manual RELOAD/view_reload call. --- chat/lib/agentLoop.js | 2 +- mcp/README.md | 2 +- mcp/src/tools/edit.ts | 2 +- mcp/tests/edit.spec.ts | 2 +- .../AgentBridge/AgentBridge.js | 18 +++++++++- .../AgentBridge/commands.js | 14 ++++++++ tests/unit/agentBridgeCommands.spec.js | 35 +++++++++++++++++++ 7 files changed, 70 insertions(+), 5 deletions(-) diff --git a/chat/lib/agentLoop.js b/chat/lib/agentLoop.js index 38834f95e..d21ee387e 100644 --- a/chat/lib/agentLoop.js +++ b/chat/lib/agentLoop.js @@ -9,7 +9,7 @@ Workflow guidance: - 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. -- 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. +- 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. diff --git a/mcp/README.md b/mcp/README.md index 9ed95b19b..a33c5de13 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -44,7 +44,7 @@ catalogs for data layers, and control a live browser session. - `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 (layer changes auto-apply in open sessions; others need one RELOAD click or `view_reload`) +- `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 diff --git a/mcp/src/tools/edit.ts b/mcp/src/tools/edit.ts index a2d5b7d8e..ceec7df1e 100644 --- a/mcp/src/tools/edit.ts +++ b/mcp/src/tools/edit.ts @@ -5,7 +5,7 @@ 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.' + 'Change saved. Open sessions auto-reload within about a second (AgentBridge); call view_reload as a manual fallback if that lag matters.' const LIVE_NOTE = 'Change saved and pushed live to open sessions.' function layerNames(config: any): string { diff --git a/mcp/tests/edit.spec.ts b/mcp/tests/edit.spec.ts index 4ca744186..77240bbda 100644 --- a/mcp/tests/edit.spec.ts +++ b/mcp/tests/edit.spec.ts @@ -33,7 +33,7 @@ describe('edit tools', () => { missionName: 'M', patch: { look: { pagename: 'New' } }, })) expect(out.version).toBe(2) - expect(out.refresh).toMatch(/RELOAD|view_reload/) + 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) diff --git a/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js index a4b800705..c6c22e6c2 100644 --- a/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js @@ -3,11 +3,14 @@ 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 } from './commands' +import { executeCommand, resolveToolId, 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 // Builds the tool-activation adapter `commands.js` uses for `open_tool` / // `get_view_state`. Classic missions run the exclusive-panel ToolController_; @@ -86,6 +89,7 @@ function buildToolAdapter() { const AgentBridge = { ws: null, sessionId: null, + reloadTimer: null, init: function (vars) { this.sessionId = @@ -153,6 +157,18 @@ const AgentBridge = { } 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. + if (shouldReloadForFrame(parsed, L_.mission)) { + 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 || parsed.body.mission !== L_.mission) return diff --git a/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js b/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js index a1b446531..0235e6d5a 100644 --- a/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js @@ -31,6 +31,20 @@ export function getViewState(deps) { } } +// 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. +export function shouldReloadForFrame(parsed, mission) { + if (parsed == null || parsed.type === 'agent-bridge') return false + if (parsed.forceClientUpdate !== true) return false + if (parsed.body == null || 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 || {} diff --git a/tests/unit/agentBridgeCommands.spec.js b/tests/unit/agentBridgeCommands.spec.js index d2e0d9042..4ca4aa5a8 100644 --- a/tests/unit/agentBridgeCommands.spec.js +++ b/tests/unit/agentBridgeCommands.spec.js @@ -3,6 +3,7 @@ import { executeCommand, getViewState, resolveToolId, + shouldReloadForFrame, } from '../../src/essence/MMGIS-Plugin-Components/AgentBridge/commands' function makeDeps() { @@ -146,6 +147,40 @@ describe('getViewState', () => { }) }) +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' }, From 094955246220b4b7ad61cd1a12a4c29c8f701813 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 15:55:01 -0500 Subject: [PATCH 50/71] Gate AgentBridge config-mutation auto-reload to modern-mode sessions Classic missions already live-apply layer frames natively via essence.js, so reloading on a config-mutation broadcast there is redundant and destructive to unsaved local UI state. Extract the mode check already used by buildToolAdapter into isModernMission() and require it before scheduling the debounced reload. --- .../AgentBridge/AgentBridge.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js index c6c22e6c2..4bfc50d7c 100644 --- a/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js @@ -12,6 +12,14 @@ const RECONNECT_MS = 10000 // 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 @@ -19,7 +27,7 @@ const RELOAD_DEBOUNCE_MS = 800 // 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. function buildToolAdapter() { - const isModern = L_.configData && L_.configData.msv && L_.configData.msv.mode === 'modern' + const isModern = isModernMission() if (isModern) { return { @@ -162,7 +170,10 @@ const AgentBridge = { // 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. - if (shouldReloadForFrame(parsed, L_.mission)) { + // 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() From f310ed9251e2f180a11e1ce7fb2b75e0f1c16de2 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 15:55:04 -0500 Subject: [PATCH 51/71] Require confirm before geodataset_ingest overwrites an existing dataset Previously ingesting under a name that already existed silently replaced all its features. Check geodatasetEntries() first and return a needsConfirmation preview (with the feature count that would be lost) unless confirm: true was passed; brand-new names still ingest in one call. --- mcp/src/tools/admin.ts | 11 ++++++++++- mcp/tests/admin.spec.ts | 27 +++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/mcp/src/tools/admin.ts b/mcp/src/tools/admin.ts index 122284cd2..44f33f26d 100644 --- a/mcp/src/tools/admin.ts +++ b/mcp/src/tools/admin.ts @@ -92,12 +92,21 @@ export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetc 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: async ({ name, geojson, url }: any) => { + handler: async ({ name, geojson, url, confirm }: any) => { try { if (!geojson === !url) { return toErrorResult(new MMGISError('Provide exactly one of geojson or url')) } + const existing = await client.geodatasetEntries() + const match = (existing as any[]).find((e: any) => e.name === name) + if (match && confirm !== true) { + return toToolResult({ + needsConfirmation: true, + 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) diff --git a/mcp/tests/admin.spec.ts b/mcp/tests/admin.spec.ts index 1960ae910..2816cce69 100644 --- a/mcp/tests/admin.spec.ts +++ b/mcp/tests/admin.spec.ts @@ -59,7 +59,10 @@ describe('admin tools', () => { }) it('geodataset_ingest accepts inline FeatureCollections and rejects bad shapes', async () => { - const client = { geodatasetRecreate: vi.fn(async () => ({ status: 'success' })) } as any + 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 })) @@ -75,7 +78,10 @@ describe('admin tools', () => { 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 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) @@ -85,6 +91,23 @@ describe('admin tools', () => { 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])) From d779c9f15bbae2dd3e14169dec1a2e00abd9812a Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 15:55:08 -0500 Subject: [PATCH 52/71] Fix tool-count and security-note gaps in READMEs chat/README.md's E2E checklist said 14 tools; the MCP server actually exposes 28 (admin 10, edit 5, view 6, catalog 3, dashboard 4). Document two more pre-existing security caveats in mcp/README.md: any relay peer can forge a config-mutation frame to force-reload modern-mode sessions of any mission (same unauthenticated-relay family as the existing view-command caveat), and /api/accounts/entries and /api/accounts/update are reachable by any valid long-term token, not just admin ones. --- chat/README.md | 2 +- mcp/README.md | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/chat/README.md b/chat/README.md index 0954b7d58..0b581f9a7 100644 --- a/chat/README.md +++ b/chat/README.md @@ -52,7 +52,7 @@ MMGIS REST + websocket. Conversation state lives in your browser ## Manual E2E checklist -- [ ] `/api/health` shows the model and `MCP connected` with 14 tools +- [ ] `/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 diff --git a/mcp/README.md b/mcp/README.md index a33c5de13..49a242db2 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -91,4 +91,17 @@ browser → `view_fly_to`. - 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. From 9f15729f6ca158e7cd371dbe1de3fd61c48e0de7 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 15:55:13 -0500 Subject: [PATCH 53/71] Fold in remaining final-review fixes - edit.ts: make LIVE_NOTE/RELOAD_NOTE mode-honest (classic applies live vs. shows RELOAD; modern auto-reloads via AgentBridge; view_reload is a manual fallback either way), matching the AgentBridge modern-only gate. - edit.ts layer_add: error early with a clear hint when layer.name is missing instead of silently inserting an unnamed layer entry; add a test. - server.js: return after failureCallback() in validateLongTermToken's inner catch so a malformed query result can't also fall through and double-invoke a callback. - agentLoop.js SYSTEM_PROMPT: clarify that provided config JSON for an EXISTING mission should go through mission_update_config as a merge patch, reserving dashboard_create_from_config for new missions. --- chat/lib/agentLoop.js | 2 +- mcp/src/tools/edit.ts | 12 ++++++++++-- mcp/tests/edit.spec.ts | 8 ++++++++ scripts/server.js | 1 + 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/chat/lib/agentLoop.js b/chat/lib/agentLoop.js index d21ee387e..f2daf7ad1 100644 --- a/chat/lib/agentLoop.js +++ b/chat/lib/agentLoop.js @@ -6,7 +6,7 @@ Workflow guidance: - 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. +- 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. diff --git a/mcp/src/tools/edit.ts b/mcp/src/tools/edit.ts index ceec7df1e..13c0e9ad8 100644 --- a/mcp/src/tools/edit.ts +++ b/mcp/src/tools/edit.ts @@ -4,9 +4,10 @@ import type { MmgisClient } from '../mmgisClient.js' import { mergePatch, editConfig, findLayerIndex } from '../configEdit.js' import { type ToolDef, toToolResult, toErrorResult } 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. Open sessions auto-reload within about a second (AgentBridge); call view_reload as a manual fallback if that lag matters.' -const LIVE_NOTE = 'Change saved and pushed live to open sessions.' + 'Change saved. Modern sessions auto-reload within about a second (AgentBridge); classic sessions show a RELOAD button. view_reload is a manual fallback.' function layerNames(config: any): string { return (config?.layers ?? []).map((l: any) => l.name).join(', ') || '(none)' @@ -46,6 +47,13 @@ export function makeEditTools(client: MmgisClient): ToolDef[] { }, handler: async ({ missionName, layer, position }: any) => { try { + 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, missionName, (config) => { config.layers = config.layers ?? [] diff --git a/mcp/tests/edit.spec.ts b/mcp/tests/edit.spec.ts index 77240bbda..b6416101b 100644 --- a/mcp/tests/edit.spec.ts +++ b/mcp/tests/edit.spec.ts @@ -51,6 +51,14 @@ describe('edit tools', () => { 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({ missionName: '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({ missionName: 'M', layer: 'u1', patch: { visibility: false } }) diff --git a/scripts/server.js b/scripts/server.js index a2619ddbb..2266ad5a2 100644 --- a/scripts/server.js +++ b/scripts/server.js @@ -405,6 +405,7 @@ function validateLongTermToken(token, successCallback, failureCallback) { result = result[0][0]; } catch (err) { failureCallback(); + return; } if ( From 33ef68a4e9c3c97510893b85a411830e4be6641c Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 21:18:44 -0500 Subject: [PATCH 54/71] [MCP] Fix five live-LLM-test findings in the MCP server - stac.ts: follow rel=next links in searchCollections (bounded at 10 pages) so paginated STAC catalogs like VEDA don't silently drop collections before keyword filtering. - Standardize the mission-name tool arg to `mission` across edit.ts, admin.ts (mission_delete), and dashboard.ts (schema arg only; maps to DashboardSpec.missionName internally). - Add a `hint` telling the model to retry with confirm: true on every needsConfirmation result (mission_delete, geodataset_delete, geodataset_ingest overwrite, user_create, user_set_permission). - mission_delete now pre-checks the mission exists via listMissions before preview or destroy, since the backend returns success for a nonexistent mission. - view.ts: clarify the mission arg must be the exact mission_list name (case and spaces matter). --- mcp/src/stac.ts | 14 ++++++++++++-- mcp/src/tools/admin.ts | 38 ++++++++++++++++++++++++++++++------- mcp/src/tools/dashboard.ts | 24 +++++++++++------------ mcp/src/tools/edit.ts | 32 +++++++++++++++---------------- mcp/src/tools/view.ts | 2 +- mcp/tests/admin.spec.ts | 27 +++++++++++++++++++++++--- mcp/tests/dashboard.spec.ts | 26 ++++++++++++------------- mcp/tests/edit.spec.ts | 16 ++++++++-------- mcp/tests/stac.spec.ts | 24 +++++++++++++++++++++++ 9 files changed, 141 insertions(+), 62 deletions(-) diff --git a/mcp/src/stac.ts b/mcp/src/stac.ts index 79f9a7fc3..2a108f441 100644 --- a/mcp/src/stac.ts +++ b/mcp/src/stac.ts @@ -59,13 +59,23 @@ export async function searchStac( return (json.features || []).map(summarizeItem) } +const MAX_COLLECTION_PAGES = 10 + 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) => ({ + 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() + } + let collections = rawCollections.map((c: any) => ({ id: c.id, ...(c.title ? { title: c.title } : {}), ...(c.description ? { description: c.description } : {}), diff --git a/mcp/src/tools/admin.ts b/mcp/src/tools/admin.ts index 44f33f26d..7567eedd1 100644 --- a/mcp/src/tools/admin.ts +++ b/mcp/src/tools/admin.ts @@ -5,6 +5,8 @@ import { type ToolDef, toToolResult, toErrorResult } 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) } @@ -56,18 +58,27 @@ export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetc 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(), + mission: z.string(), confirm: z.boolean().optional().describe('Must be true to actually delete'), }, - handler: async ({ missionName, confirm }: any) => { + handler: async ({ mission, confirm }: any) => { try { + 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 toToolResult({ needsConfirmation: true, - wouldDelete: `Mission "${missionName}" and every config version of it (the Missions/ folder is renamed, not erased).`, + wouldDelete: `Mission "${mission}" and every config version of it (the Missions/ folder is renamed, not erased).`, + hint: CONFIRM_RETRY_HINT, }) } - return toToolResult(await client.destroyMission(missionName)) + return toToolResult(await client.destroyMission(mission)) } catch (err) { return toErrorResult(err) } @@ -105,6 +116,7 @@ export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetc return toToolResult({ needsConfirmation: true, wouldReplace: `Geodataset "${name}" already exists with ${match.num_features} features — ingesting replaces ALL of them.`, + hint: CONFIRM_RETRY_HINT, }) } let data = geojson @@ -138,7 +150,11 @@ export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetc 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({ + needsConfirmation: true, + wouldDelete: `Geodataset "${name}" and its feature table. Layers referencing geodatasets:${name} will break.`, + hint: CONFIRM_RETRY_HINT, + }) } return toToolResult(await client.geodatasetRemove(name)) } catch (err) { @@ -169,7 +185,11 @@ export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetc handler: async ({ username, password, confirm }: any) => { try { if (confirm !== true) { - return toToolResult({ needsConfirmation: true, wouldCreate: `User "${username}" with Viewer (001) permission.` }) + return toToolResult({ + needsConfirmation: true, + wouldCreate: `User "${username}" with Viewer (001) permission.`, + hint: CONFIRM_RETRY_HINT, + }) } const out = await client.userSignup(username, password) return toToolResult({ username: out.username ?? username, created: true }) @@ -190,7 +210,11 @@ export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetc 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(', ')}]` : ''}.` }) + return toToolResult({ + needsConfirmation: true, + wouldChange: `Set "${username}" permission to ${permission}${missionsManaging ? ` managing [${missionsManaging.join(', ')}]` : ''}.`, + hint: CONFIRM_RETRY_HINT, + }) } const users = await client.accountEntries() const user = users.find((u: any) => u.username === username) diff --git a/mcp/src/tools/dashboard.ts b/mcp/src/tools/dashboard.ts index 4025e68c5..14f051581 100644 --- a/mcp/src/tools/dashboard.ts +++ b/mcp/src/tools/dashboard.ts @@ -76,7 +76,7 @@ async function installMission( } const dashboardGenerateSchema = { - missionName: z.string().describe(`Name for the new mission/dashboard. ${MISSION_NAME_RULE}`), + mission: z.string().describe(`Name for the new mission/dashboard. ${MISSION_NAME_RULE}`), layers: z .array(z.record(z.any())) .optional() @@ -107,7 +107,7 @@ export function makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef handler: async () => toToolResult({ spec: { - missionName: `string (required). ${MISSION_NAME_RULE}`, + 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)', @@ -137,18 +137,18 @@ export function makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef 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; returnConfig?: boolean }) => { + handler: async (args: Omit & { mission: string; updateExisting?: boolean; returnConfig?: boolean }) => { try { - const nameError = validateMissionName(args.missionName) + const nameError = validateMissionName(args.mission) if (nameError) return toErrorResult(nameError) - const profile = buildProfile(args, cfg.repoRoot) + const profile = buildProfile({ ...args, missionName: args.mission }, cfg.repoRoot) let config = await generateConfig(profile, cfg.repoRoot) const neededMapboxToken = JSON.stringify(config).includes('{{MAPBOX_TOKEN}}') 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] - const out = await installMission(client, args.missionName, config, args.updateExisting) + const out = await installMission(client, args.mission, config, args.updateExisting) const warnings: string[] = [] if (neededMapboxToken && cfg.mapboxToken === '') { warnings.push('MAPBOX_TOKEN is not set — the basemap will not render') @@ -156,7 +156,7 @@ export function makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef return toToolResult({ mission: out.mission, version: out.version, - url: `${cfg.mmgisUrl}/?mission=${encodeURIComponent(args.missionName)}`, + url: `${cfg.mmgisUrl}/?mission=${encodeURIComponent(args.mission)}`, ...(warnings.length > 0 ? { warnings } : {}), ...(args.returnConfig ? { config } : {}), }) @@ -170,20 +170,20 @@ export function makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef 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}`), + 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: async (args: { missionName: string; config: any; updateExisting?: boolean }) => { + handler: async (args: { mission: string; config: any; updateExisting?: boolean }) => { try { - const nameError = validateMissionName(args.missionName) + const nameError = validateMissionName(args.mission) 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 out = await installMission(client, args.mission, config, args.updateExisting) const warnings: string[] = [] if (neededMapboxToken && cfg.mapboxToken === '') { warnings.push('MAPBOX_TOKEN is not set — the basemap will not render') @@ -191,7 +191,7 @@ export function makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef return toToolResult({ mission: out.mission, version: out.version, - url: `${cfg.mmgisUrl}/?mission=${encodeURIComponent(args.missionName)}`, + url: `${cfg.mmgisUrl}/?mission=${encodeURIComponent(args.mission)}`, ...(warnings.length > 0 ? { warnings } : {}), }) } catch (err) { diff --git a/mcp/src/tools/edit.ts b/mcp/src/tools/edit.ts index 13c0e9ad8..40e1880d5 100644 --- a/mcp/src/tools/edit.ts +++ b/mcp/src/tools/edit.ts @@ -14,19 +14,19 @@ function layerNames(config: any): string { } export function makeEditTools(client: MmgisClient): ToolDef[] { - const missionName = z.string().describe('Mission to edit (see mission_list)') + 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: { - missionName, + mission, patch: z.record(z.any()).describe('Merge patch, e.g. {"look": {"pagename": "New Name"}} or {"msv": {"basemap": {...}}}'), }, - handler: async ({ missionName, patch }: any) => { + handler: async ({ mission, patch }: any) => { try { - const out = await editConfig(client, missionName, (config) => { + 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) @@ -41,11 +41,11 @@ export function makeEditTools(client: MmgisClient): ToolDef[] { name: 'layer_add', description: 'Add a layer entry to a mission. Applies live in open sessions.', schema: { - missionName, + 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: async ({ missionName, layer, position }: any) => { + handler: async ({ mission, layer, position }: any) => { try { if (!layer || typeof layer.name !== 'string' || layer.name.trim() === '') { return toErrorResult( @@ -55,7 +55,7 @@ export function makeEditTools(client: MmgisClient): ToolDef[] { ) } const entry = { uuid: randomUUID(), sublayers: [], visibility: true, ...layer } - const out = await editConfig(client, missionName, (config) => { + 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) @@ -71,14 +71,14 @@ export function makeEditTools(client: MmgisClient): ToolDef[] { name: 'layer_update', description: 'Merge-patch a single layer (found by name or uuid). Applies live in open sessions.', schema: { - missionName, + 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: async ({ missionName, layer, patch }: any) => { + handler: async ({ mission, layer, patch }: any) => { try { let updatedName = '' - const out = await editConfig(client, missionName, (config) => { + const out = await editConfig(client, mission, (config) => { const idx = findLayerIndex(config, layer) if (idx === -1) { throw Object.assign(new Error(`Unknown layer: ${layer}`), { @@ -98,11 +98,11 @@ export function makeEditTools(client: MmgisClient): ToolDef[] { { 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) => { + schema: { mission, layer: z.string().describe('Layer name or uuid') }, + handler: async ({ mission, layer }: any) => { try { let removedName = '' - const out = await editConfig(client, missionName, (config) => { + const out = await editConfig(client, mission, (config) => { const idx = findLayerIndex(config, layer) if (idx === -1) { throw Object.assign(new Error(`Unknown layer: ${layer}`), { @@ -122,10 +122,10 @@ export function makeEditTools(client: MmgisClient): ToolDef[] { { 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) => { + schema: { mission, toolName: z.string(), on: z.boolean() }, + handler: async ({ mission, toolName, on }: any) => { try { - const out = await editConfig(client, missionName, (config) => { + 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}`), { diff --git a/mcp/src/tools/view.ts b/mcp/src/tools/view.ts index f76f3c841..0abc02b89 100644 --- a/mcp/src/tools/view.ts +++ b/mcp/src/tools/view.ts @@ -10,7 +10,7 @@ export function makeViewTools(bridge: BridgeClient): ToolDef[] { return toErrorResult(err) } } - const mission = z.string().describe('Mission name of the browser session to drive') + const mission = z.string().describe('Exact mission name as returned by mission_list (case and spaces matter)') return [ { name: 'view_fly_to', diff --git a/mcp/tests/admin.spec.ts b/mcp/tests/admin.spec.ts index 2816cce69..d6f1396b1 100644 --- a/mcp/tests/admin.spec.ts +++ b/mcp/tests/admin.spec.ts @@ -42,16 +42,37 @@ describe('admin tools', () => { }) it('mission_delete requires confirm and previews first', async () => { - const client = { destroyMission: vi.fn(async () => ({ message: 'Successfully Deleted Mission: A' })) } as any + 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({ missionName: 'A' })) + 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({ missionName: 'A', confirm: true })) + 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])) diff --git a/mcp/tests/dashboard.spec.ts b/mcp/tests/dashboard.spec.ts index 95dcc6c0e..e598dfdce 100644 --- a/mcp/tests/dashboard.spec.ts +++ b/mcp/tests/dashboard.spec.ts @@ -23,7 +23,7 @@ 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.spec.mission).toBeDefined() expect(schema.layerExamples.tile.type).toBe('TileLayer') expect(schema.layerExamples.geojson.type).toBe('GeoJsonLayer') }) @@ -39,7 +39,7 @@ describe('dashboard tools', () => { const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) const out = parse( await tools.dashboard_generate.handler({ - missionName: 'AQ Test', + mission: 'AQ Test', view: { lat: 33.7, lon: -84.4, zoom: 9 }, layers: [ { @@ -73,7 +73,7 @@ describe('dashboard tools', () => { 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 })) + const out = parse(await tools.dashboard_generate.handler({ mission: 'AQ Test', updateExisting: true })) expect(out.version).toBe(4) }, 30000) @@ -89,7 +89,7 @@ describe('dashboard tools', () => { // 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({ missionName: 'air-quality-atlanta' }) + 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/) @@ -100,7 +100,7 @@ describe('dashboard tools', () => { 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.missionName).toMatch(/must not contain/) + expect(schema.spec.mission).toMatch(/must not contain/) }) it('warns when MAPBOX_TOKEN is unset and the generated config needed it', async () => { @@ -113,7 +113,7 @@ describe('dashboard tools', () => { } 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({ missionName: 'AQ Test' })) + const out = parse(await tools.dashboard_generate.handler({ mission: 'AQ Test' })) expect(out.warnings).toEqual(['MAPBOX_TOKEN is not set — the basemap will not render']) }, 30000) @@ -122,7 +122,7 @@ describe('dashboard tools', () => { 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({ missionName: 'AQ Test' })) + const out = parse(await tools.dashboard_generate.handler({ mission: 'AQ Test' })) expect(out.warnings).toBeUndefined() }, 30000) @@ -133,7 +133,7 @@ describe('dashboard tools', () => { }, } as any const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) - const res = await tools.dashboard_generate.handler({ missionName: 'AQ Test' }) + const res = await tools.dashboard_generate.handler({ mission: 'AQ Test' }) expect(res.isError).toBe(true) expect(parse(res).hint).toMatch(/updateExisting/) }, 30000) @@ -141,9 +141,9 @@ describe('dashboard tools', () => { 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 })) + 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({ missionName: 'RC Test' })) + const without = parse(await tools.dashboard_generate.handler({ mission: 'RC Test' })) expect(without.config).toBeUndefined() }, 30000) @@ -161,7 +161,7 @@ describe('dashboard tools', () => { layers: [], } const out = parse( - await tools.dashboard_create_from_config.handler({ missionName: 'From JSON', config: rawConfig }) + 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([ @@ -180,7 +180,7 @@ describe('dashboard tools', () => { } as any const tools = Object.fromEntries(makeDashboardTools(client, cfg).map((t) => [t.name, t])) await tools.dashboard_create_from_config.handler({ - missionName: 'From JSON', + 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: {} }]) @@ -189,7 +189,7 @@ describe('dashboard tools', () => { 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: {} }) + 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 index b6416101b..e0b591670 100644 --- a/mcp/tests/edit.spec.ts +++ b/mcp/tests/edit.spec.ts @@ -30,7 +30,7 @@ describe('edit tools', () => { 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' } }, + mission: 'M', patch: { look: { pagename: 'New' } }, })) expect(out.version).toBe(2) expect(out.refresh).toMatch(/reload/i) @@ -43,7 +43,7 @@ describe('edit tools', () => { 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, + 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] @@ -53,7 +53,7 @@ describe('edit tools', () => { it('layer_add errors early when layer.name is missing', async () => { const client = fakeClient(baseConfig()) - const res = await tools(client).layer_add.handler({ missionName: 'M', layer: { type: 'vector' } }) + 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() @@ -61,7 +61,7 @@ describe('edit tools', () => { 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 } }) + 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' }) @@ -69,7 +69,7 @@ describe('edit tools', () => { 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' }) + 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' }) @@ -77,7 +77,7 @@ describe('edit tools', () => { 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: {} }) + 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() @@ -85,10 +85,10 @@ describe('edit tools', () => { 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 }) + 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({ missionName: 'M', toolName: 'Nope', on: 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/stac.spec.ts b/mcp/tests/stac.spec.ts index f985c44f4..2bba362b6 100644 --- a/mcp/tests/stac.spec.ts +++ b/mcp/tests/stac.spec.ts @@ -58,6 +58,30 @@ describe('searchCollections', () => { 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', () => { From eb1a5f19710a0d70853598ddc9be5be50c797bc2 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 21:21:42 -0500 Subject: [PATCH 55/71] Raise STAC collection pagination cap to cover large catalogs --- mcp/src/stac.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mcp/src/stac.ts b/mcp/src/stac.ts index 2a108f441..2f15ceab9 100644 --- a/mcp/src/stac.ts +++ b/mcp/src/stac.ts @@ -59,7 +59,8 @@ export async function searchStac( return (json.features || []).map(summarizeItem) } -const MAX_COLLECTION_PAGES = 10 +// VEDA alone spans 25 pages at 10 collections/page (verified live 2026-07-25) +const MAX_COLLECTION_PAGES = 50 export async function searchCollections( catalogUrl: string, From 3507e133e218f3d9cbd1994c3c7fa8575e544694 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 21:58:26 -0500 Subject: [PATCH 56/71] Show thinking indicator and add request timeout in chat UI --- chat/public/app.js | 19 ++++++++++++++++++- chat/public/style.css | 9 +++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/chat/public/app.js b/chat/public/app.js index d5293fbca..77bdad20a 100644 --- a/chat/public/app.js +++ b/chat/public/app.js @@ -124,11 +124,21 @@ if (typeof document !== 'undefined') { 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() @@ -141,6 +151,7 @@ if (typeof document !== 'undefined') { const { events, rest } = parseSseChunks(buffer) buffer = rest for (const ev of events) { + clearThinking() if (ev.type === 'text') { if (!assistantDiv) { assistantDiv = addBubble('assistant', '') @@ -168,8 +179,14 @@ if (typeof document !== 'undefined') { save() } } catch (err) { - addBubble('error', `Error: ${err.message}`) + 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 diff --git a/chat/public/style.css b/chat/public/style.css index 721d45744..5711c784a 100644 --- a/chat/public/style.css +++ b/chat/public/style.css @@ -49,3 +49,12 @@ details.tool pre { } #composer { display: flex; gap: 8px; padding: 12px 16px; background: #1a1f27; border-top: 1px solid #2a3040; } #input { flex: 1; resize: none; } +.msg.thinking { + color: #8b94a3; + font-style: italic; + animation: pulse 1.2s ease-in-out infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 0.45; } + 50% { opacity: 1; } +} From 2e21c023f2d5f2d8228aebb93bb93be4748612b7 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 22:05:56 -0500 Subject: [PATCH 57/71] Guide catalog searches past thematic keywords with synonyms and samples --- chat/lib/agentLoop.js | 1 + mcp/src/tools/catalog.ts | 18 +++++++++++++++--- mcp/tests/catalog.spec.ts | 23 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/chat/lib/agentLoop.js b/chat/lib/agentLoop.js index f2daf7ad1..6512eb491 100644 --- a/chat/lib/agentLoop.js +++ b/chat/lib/agentLoop.js @@ -3,6 +3,7 @@ export const SYSTEM_PROMPT = `You are an assistant that builds and drives MMGIS 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 check the availableSample list before concluding no data exists. - 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. diff --git a/mcp/src/tools/catalog.ts b/mcp/src/tools/catalog.ts index ecc9b7a6f..655581025 100644 --- a/mcp/src/tools/catalog.ts +++ b/mcp/src/tools/catalog.ts @@ -16,18 +16,30 @@ function resolveCatalog(cfg: McpConfig, catalog: string): string { return url } -export function makeCatalogTools(cfg: McpConfig): ToolDef[] { +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.', + 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 use the returned availableSample to pick 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: async ({ catalog, keyword }: { catalog: string; keyword?: string }) => { try { - return toToolResult({ collections: await searchCollections(resolveCatalog(cfg, catalog), keyword) }) + const url = resolveCatalog(cfg, catalog) + const collections = await searchCollections(url, keyword, fetchFn) + if (keyword && collections.length === 0) { + const all = await searchCollections(url, undefined, fetchFn) + return toToolResult({ + collections: [], + hint: `No collections matched "${keyword}". Dataset names are technical — try synonyms (e.g. air quality: no2, so2, pm, aerosol; fire: fire, burn, thermal; flood: flood, water, inundation) or pick from availableSample.`, + availableSample: all.slice(0, 40).map((c) => c.id), + totalCollections: all.length, + }) + } + return toToolResult({ collections }) } catch (err) { return toErrorResult(err) } diff --git a/mcp/tests/catalog.spec.ts b/mcp/tests/catalog.spec.ts index 11deaa321..307a62d55 100644 --- a/mcp/tests/catalog.spec.ts +++ b/mcp/tests/catalog.spec.ts @@ -14,3 +14,26 @@ describe('catalog tools', () => { expect(JSON.parse(res.content[0].text).hint).toContain('test') }) }) + +describe('catalog_collections empty-match fallback', () => { + it('returns a sample of available collections when the keyword matches nothing', async () => { + 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 = JSON.parse((await t.catalog_collections.handler({ catalog: 'test', keyword: 'air quality' })).content[0].text) + expect(out.collections).toEqual([]) + expect(out.hint).toMatch(/synonyms/) + expect(out.availableSample).toContain('no2-monthly') + expect(out.totalCollections).toBe(2) + }) +}) From dc976da4ca45f20c7cc9007f530322d9cbfa1b9e Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 22:08:20 -0500 Subject: [PATCH 58/71] Fall back to MapLibre basemap when MAPBOX_TOKEN is unset --- mcp/src/tools/dashboard.ts | 14 ++++++++++---- mcp/tests/dashboard.spec.ts | 6 +++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/mcp/src/tools/dashboard.ts b/mcp/src/tools/dashboard.ts index 14f051581..81b0170cc 100644 --- a/mcp/src/tools/dashboard.ts +++ b/mcp/src/tools/dashboard.ts @@ -145,14 +145,20 @@ export function makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef let config = await generateConfig(profile, cfg.repoRoot) const neededMapboxToken = JSON.stringify(config).includes('{{MAPBOX_TOKEN}}') config = resolvePlaceholders(config, cfg.mapboxToken) + const warnings: string[] = [] + // Without a Mapbox token the default basemap renders black; fall + // back to the token-free MapLibre demo style so maps always show. + if (neededMapboxToken && cfg.mapboxToken === '' && config?.msv?.basemap?.provider === 'mapbox') { + config.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') + } // Injected after generation: `components` is not a template key, // and /api/configure/add does not run backend validation. config.components = [AGENT_BRIDGE_COMPONENT] const out = await installMission(client, args.mission, 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, diff --git a/mcp/tests/dashboard.spec.ts b/mcp/tests/dashboard.spec.ts index e598dfdce..31c5b8d46 100644 --- a/mcp/tests/dashboard.spec.ts +++ b/mcp/tests/dashboard.spec.ts @@ -114,7 +114,11 @@ describe('dashboard tools', () => { 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 — the basemap will not render']) + 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 () => { From 1621678c1057d955a41b51d541863f8541fc3497 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 22:11:42 -0500 Subject: [PATCH 59/71] Route catalog items to their own tile servers and drop the scale suffix --- mcp/src/config.ts | 20 ++++++++++++++++++++ mcp/src/stac.ts | 2 +- mcp/src/tools/catalog.ts | 21 ++++++++++++++++++++- mcp/tests/catalog.spec.ts | 18 ++++++++++++++++++ mcp/tests/stac.spec.ts | 4 ++-- 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/mcp/src/config.ts b/mcp/src/config.ts index 0547331eb..70bfe1497 100644 --- a/mcp/src/config.ts +++ b/mcp/src/config.ts @@ -9,6 +9,7 @@ export interface McpConfig { mapboxToken: string stacCatalogs: Record titilerUrl: string + stacTilers: Record } const DEFAULT_STAC_CATALOGS: Record = { @@ -16,6 +17,13 @@ const DEFAULT_STAC_CATALOGS: Record = { '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"}' @@ -53,6 +61,17 @@ export function loadConfig(env: Record = process.env assertStacCatalogsShape(parsed) stacCatalogs = parsed } + let stacTilers = DEFAULT_STAC_TILERS + if (env.STAC_TILERS) { + let parsedTilers: unknown + try { + parsedTilers = JSON.parse(env.STAC_TILERS) + } catch { + throw new Error('STAC_TILERS must be a JSON object mapping catalog name to tiler URL') + } + assertStacCatalogsShape(parsedTilers) + stacTilers = parsedTilers + } return { mmgisUrl, mmgisToken: env.MMGIS_TOKEN, @@ -61,5 +80,6 @@ export function loadConfig(env: Record = process.env mapboxToken: env.MAPBOX_TOKEN || '', stacCatalogs, titilerUrl: (env.TITILER_URL || 'https://titiler.xyz').replace(/\/+$/, ''), + stacTilers, } } diff --git a/mcp/src/stac.ts b/mcp/src/stac.ts index 2f15ceab9..4ed552d59 100644 --- a/mcp/src/stac.ts +++ b/mcp/src/stac.ts @@ -100,7 +100,7 @@ export function stacItemToTileLayer( 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` + + `${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)}` diff --git a/mcp/src/tools/catalog.ts b/mcp/src/tools/catalog.ts index 655581025..7e4045360 100644 --- a/mcp/src/tools/catalog.ts +++ b/mcp/src/tools/catalog.ts @@ -4,6 +4,24 @@ import { searchStac, searchCollections, stacItemToTileLayer } from '../stac.js' import { MMGISError } from '../mmgisClient.js' import { type ToolDef, toToolResult, toErrorResult } from './result.js' +// Some catalogs (e.g. VEDA) keep imagery in protected buckets only their own +// tile server can read — match the item's self link to a catalog origin and +// use that catalog's dedicated tiler when one is configured. +export function tilerForItem(cfg: McpConfig, selfHref: string | null): string { + 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 cfg.stacTilers[name].replace(/\/+$/, '') + } + } catch { + // unparseable URL — fall through to the generic tiler + } + } + } + return cfg.titilerUrl +} + function resolveCatalog(cfg: McpConfig, catalog: string): string { if (/^https?:\/\//.test(catalog)) return catalog const url = cfg.stacCatalogs[catalog] @@ -76,8 +94,9 @@ export function makeCatalogTools(cfg: McpConfig, fetchFn: typeof fetch = fetch): }, handler: async ({ item, name, asset, rescale, colormap }: any) => { try { + const titilerUrl = tilerForItem(cfg, item?.selfHref ?? null) return toToolResult({ - layer: stacItemToTileLayer(item, { name, titilerUrl: cfg.titilerUrl, asset, rescale, colormap }), + layer: stacItemToTileLayer(item, { name, titilerUrl, asset, rescale, colormap }), }) } catch (err) { return toErrorResult(err) diff --git a/mcp/tests/catalog.spec.ts b/mcp/tests/catalog.spec.ts index 307a62d55..ce951cc40 100644 --- a/mcp/tests/catalog.spec.ts +++ b/mcp/tests/catalog.spec.ts @@ -37,3 +37,21 @@ describe('catalog_collections empty-match fallback', () => { 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')).toBe('https://openveda.cloud/api/raster') + }) + 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')).toBe('https://titiler.xyz') + expect(tilerForItem(cfg3, null)).toBe('https://titiler.xyz') + expect(tilerForItem(cfg3, 'not a url')).toBe('https://titiler.xyz') + }) +}) diff --git a/mcp/tests/stac.spec.ts b/mcp/tests/stac.spec.ts index 2bba362b6..9b0ea3705 100644 --- a/mcp/tests/stac.spec.ts +++ b/mcp/tests/stac.spec.ts @@ -99,7 +99,7 @@ describe('stacItemToTileLayer', () => { 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=' + + 'https://titiler.xyz/stac/tiles/WebMercatorQuad/{z}/{x}/{y}.png?url=' + encodeURIComponent('https://stac.test/collections/no2-monthly/items/i1') + '&assets=cog_default' ) @@ -122,7 +122,7 @@ describe('stacItemToTileLayer', () => { colormap: 'viridis', }) expect(layer.url).toBe( - 'https://titiler.xyz/stac/tiles/WebMercatorQuad/{z}/{x}/{y}@1x.png?url=' + + 'https://titiler.xyz/stac/tiles/WebMercatorQuad/{z}/{x}/{y}.png?url=' + encodeURIComponent('https://stac.test/collections/no2-monthly/items/i1') + '&assets=' + encodeURIComponent('cog_default') + From 5ffae4b6f63548428d841e9ba55726e0c6dbc841 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sat, 25 Jul 2026 22:15:21 -0500 Subject: [PATCH 60/71] Build item-path tile URLs for titiler-pgstac catalogs like VEDA --- mcp/src/stac.ts | 29 +++++++++++++++++++++++++---- mcp/src/tools/catalog.ts | 22 +++++++++++++++++----- mcp/tests/catalog.spec.ts | 8 ++++---- mcp/tests/stac.spec.ts | 19 +++++++++++++++++++ 4 files changed, 65 insertions(+), 13 deletions(-) diff --git a/mcp/src/stac.ts b/mcp/src/stac.ts index 4ed552d59..f9de6f5a2 100644 --- a/mcp/src/stac.ts +++ b/mcp/src/stac.ts @@ -92,16 +92,37 @@ export async function searchCollections( export function stacItemToTileLayer( item: StacItemSummary, - opts: { name: string; titilerUrl: string; asset?: string; rescale?: string; colormap?: string } + 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 = - `${opts.titilerUrl}/stac/tiles/WebMercatorQuad/{z}/{x}/{y}.png` + - `?url=${encodeURIComponent(item.selfHref)}&assets=${encodeURIComponent(asset)}` + 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 { diff --git a/mcp/src/tools/catalog.ts b/mcp/src/tools/catalog.ts index 7e4045360..8bba03c34 100644 --- a/mcp/src/tools/catalog.ts +++ b/mcp/src/tools/catalog.ts @@ -7,19 +7,24 @@ import { type ToolDef, toToolResult, toErrorResult } from './result.js' // Some catalogs (e.g. VEDA) keep imagery in protected buckets only their own // tile server can read — match the item's self link to a catalog origin and // use that catalog's dedicated tiler when one is configured. -export function tilerForItem(cfg: McpConfig, selfHref: string | null): string { +export function tilerForItem( + cfg: McpConfig, + selfHref: string | null +): { titilerUrl: string; urlStyle: 'stac-url' | '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 cfg.stacTilers[name].replace(/\/+$/, '') + // Dedicated tilers are titiler-pgstac (eoAPI) deployments — + // they serve items by path, not by a ?url= parameter. + return { titilerUrl: cfg.stacTilers[name].replace(/\/+$/, ''), urlStyle: 'item-path' } } } catch { // unparseable URL — fall through to the generic tiler } } } - return cfg.titilerUrl + return { titilerUrl: cfg.titilerUrl, urlStyle: 'stac-url' } } function resolveCatalog(cfg: McpConfig, catalog: string): string { @@ -94,9 +99,16 @@ export function makeCatalogTools(cfg: McpConfig, fetchFn: typeof fetch = fetch): }, handler: async ({ item, name, asset, rescale, colormap }: any) => { try { - const titilerUrl = tilerForItem(cfg, item?.selfHref ?? null) + const tiler = tilerForItem(cfg, item?.selfHref ?? null) return toToolResult({ - layer: stacItemToTileLayer(item, { name, titilerUrl, asset, rescale, colormap }), + layer: stacItemToTileLayer(item, { + name, + titilerUrl: tiler.titilerUrl, + urlStyle: tiler.urlStyle, + asset, + rescale, + colormap, + }), }) } catch (err) { return toErrorResult(err) diff --git a/mcp/tests/catalog.spec.ts b/mcp/tests/catalog.spec.ts index ce951cc40..530f93bf6 100644 --- a/mcp/tests/catalog.spec.ts +++ b/mcp/tests/catalog.spec.ts @@ -46,12 +46,12 @@ describe('tilerForItem', () => { } 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')).toBe('https://openveda.cloud/api/raster') + 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')).toBe('https://titiler.xyz') - expect(tilerForItem(cfg3, null)).toBe('https://titiler.xyz') - expect(tilerForItem(cfg3, 'not a url')).toBe('https://titiler.xyz') + 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' }) }) }) diff --git a/mcp/tests/stac.spec.ts b/mcp/tests/stac.spec.ts index 9b0ea3705..65e3d2af6 100644 --- a/mcp/tests/stac.spec.ts +++ b/mcp/tests/stac.spec.ts @@ -133,3 +133,22 @@ describe('stacItemToTileLayer', () => { ) }) }) + +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' + ) + }) +}) From 3b8228de196e3e1c74fd182bedc7e403ffa5e950 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sun, 26 Jul 2026 01:30:08 -0500 Subject: [PATCH 61/71] Add split-panel layout with embedded live dashboard --- chat/lib/app.js | 18 ++++++++- chat/public/app.js | 83 ++++++++++++++++++++++++++++++++++++++++++ chat/public/index.html | 25 ++++++++++--- chat/public/style.css | 28 ++++++++++++++ chat/tests/app.spec.js | 36 +++++++++++++++++- 5 files changed, 181 insertions(+), 9 deletions(-) diff --git a/chat/lib/app.js b/chat/lib/app.js index f199d4a77..ae84e543a 100644 --- a/chat/lib/app.js +++ b/chat/lib/app.js @@ -17,7 +17,23 @@ export function createApp({ cfg, openai, bridge }) { } catch { // leave toolCount 0; mcpConnected reflects reality below } - res.json({ ok: true, model: cfg.model, mcpConnected: bridge.isConnected(), toolCount }) + res.json({ + ok: true, + model: cfg.model, + mcpConnected: bridge.isConnected(), + toolCount, + mmgisUrl: 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) => { diff --git a/chat/public/app.js b/chat/public/app.js index 77bdad20a..7754e5955 100644 --- a/chat/public/app.js +++ b/chat/public/app.js @@ -47,6 +47,13 @@ if (typeof document !== 'undefined') { 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 { @@ -97,9 +104,75 @@ if (typeof document !== 'undefined') { a.target = '_blank' a.textContent = 'Open dashboard →' card.appendChild(a) + if (url.includes('?mission=')) showDashboard(url) } } + // --- Dashboard panel --- + + let mmgisUrl = null + + function missionFromUrl(url) { + try { + return new URL(url).searchParams.get('mission') + } catch { + return null + } + } + + function showDashboard(url) { + 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 current = missionPicker.value + missionPicker.length = 1 + for (const m of out.missions || []) missionPicker.appendChild(new Option(m, m)) + if (current) missionPicker.value = current + } 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) @@ -110,6 +183,13 @@ if (typeof document !== 'undefined') { 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(/\/+$/, '') + 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' @@ -190,6 +270,7 @@ if (typeof document !== 'undefined') { busy = false send.disabled = false newChat.disabled = false + refreshMissions() } } @@ -238,6 +319,8 @@ if (typeof document !== 'undefined') { }) 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 index 6acc929ab..05848eb5d 100644 --- a/chat/public/index.html +++ b/chat/public/index.html @@ -11,6 +11,7 @@

MMGIS Chat

connecting…
+
@@ -24,12 +25,24 @@

MMGIS Chat

-
- -
- - -
+
+
+
+
+ + +
+
+
+
+
+ + +
+ +
Create or pick a mission and it will appear here.
+
+
diff --git a/chat/public/style.css b/chat/public/style.css index 5711c784a..7ea9bd640 100644 --- a/chat/public/style.css +++ b/chat/public/style.css @@ -58,3 +58,31 @@ details.tool pre { 0%, 100% { opacity: 0.45; } 50% { opacity: 1; } } + +/* --- Split layout: chat left, dashboard panel right --- */ +#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: 6px; cursor: col-resize; background: #1a1f27; + border-left: 1px solid #2a3040; border-right: 1px solid #2a3040; flex: none; +} +#divider:hover { background: #2a3550; } +#dashCol { flex: 1; display: flex; flex-direction: column; min-width: 0; position: relative; background: #0d1117; } +#dashBar { + display: flex; align-items: center; gap: 8px; padding: 6px 10px; + background: #1a1f27; border-bottom: 1px solid #2a3040; +} +#missionPicker { + flex: 1; background: #0d1117; color: #e6e8eb; border: 1px solid #2a3040; + border-radius: 6px; padding: 5px 8px; font-size: 13px; +} +#dashPop { color: #9fb4d8; text-decoration: none; font-size: 16px; padding: 0 6px; } +#dashFrame { flex: 1; border: 0; width: 100%; background: #fff; display: none; } +#dashEmpty { + position: absolute; inset: 40px 0 0 0; display: flex; align-items: center; justify-content: center; + color: #8b94a3; 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; } diff --git a/chat/tests/app.spec.js b/chat/tests/app.spec.js index 1868c3a97..51c1278da 100644 --- a/chat/tests/app.spec.js +++ b/chat/tests/app.spec.js @@ -40,7 +40,7 @@ describe('chat app', () => { 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 }) + expect(out).toEqual({ ok: true, model: 'test-model', mcpConnected: true, toolCount: 1, mmgisUrl: null }) }) it('GET /api/tools lists tool names and descriptions', async () => { @@ -103,6 +103,38 @@ describe('chat app', () => { 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 }) + expect(await res.json()).toEqual({ ok: true, model: 'test-model', mcpConnected: false, toolCount: 0, mmgisUrl: 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') }) }) From 822f0164fcf17b408f864148528a0b9e14b8ee28 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sun, 26 Jul 2026 01:32:25 -0500 Subject: [PATCH 62/71] Route the dashboard panel through the UI origin to keep mission queries --- chat/.env.example | 4 ++++ chat/lib/app.js | 2 ++ chat/public/app.js | 21 ++++++++++++++++++++- chat/tests/app.spec.js | 4 ++-- chat/tests/frontend.spec.js | 14 ++++++++++++++ 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/chat/.env.example b/chat/.env.example index 0a2494d11..beb899c40 100644 --- a/chat/.env.example +++ b/chat/.env.example @@ -11,3 +11,7 @@ MCP_ARGS=../mcp/dist/index.js MMGIS_URL=http://localhost:8891 MMGIS_TOKEN= MAPBOX_TOKEN= + +# Where the dashboard UI is served, if different from MMGIS_URL +# (dev: webpack serves the UI on PORT+1 and its redirect drops query params) +MMGIS_DASHBOARD_URL=http://localhost:8892 diff --git a/chat/lib/app.js b/chat/lib/app.js index ae84e543a..b60b12bfe 100644 --- a/chat/lib/app.js +++ b/chat/lib/app.js @@ -23,6 +23,8 @@ export function createApp({ cfg, openai, bridge }) { 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, }) }) diff --git a/chat/public/app.js b/chat/public/app.js index 7754e5955..c22958cba 100644 --- a/chat/public/app.js +++ b/chat/public/app.js @@ -35,6 +35,22 @@ export function extractUrls(resultText) { 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') { @@ -111,6 +127,7 @@ if (typeof document !== 'undefined') { // --- Dashboard panel --- let mmgisUrl = null + let dashboardUrl = null function missionFromUrl(url) { try { @@ -120,7 +137,8 @@ if (typeof document !== 'undefined') { } } - function showDashboard(url) { + function showDashboard(rawUrl) { + const url = rewriteDashboardUrl(rawUrl, mmgisUrl, dashboardUrl) if (dashFrame.src !== url) dashFrame.src = url dashPop.href = url split.classList.add('has-dash') @@ -185,6 +203,7 @@ if (typeof document !== 'undefined') { 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)}`) diff --git a/chat/tests/app.spec.js b/chat/tests/app.spec.js index 51c1278da..2cb03812f 100644 --- a/chat/tests/app.spec.js +++ b/chat/tests/app.spec.js @@ -40,7 +40,7 @@ describe('chat app', () => { 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 }) + 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 () => { @@ -103,7 +103,7 @@ describe('chat app', () => { 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 }) + expect(await res.json()).toEqual({ ok: true, model: 'test-model', mcpConnected: false, toolCount: 0, mmgisUrl: null, dashboardUrl: null }) }) }) diff --git a/chat/tests/frontend.spec.js b/chat/tests/frontend.spec.js index e8aa5399f..36bda4811 100644 --- a/chat/tests/frontend.spec.js +++ b/chat/tests/frontend.spec.js @@ -28,3 +28,17 @@ describe('extractUrls', () => { 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') + }) +}) From 3b95f7a2c25c86d743bfabb4ba9e558320eb8d06 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sun, 26 Jul 2026 10:57:22 -0500 Subject: [PATCH 63/71] List every collection id on empty catalog matches and distrust stale no-data claims --- chat/lib/agentLoop.js | 3 ++- mcp/src/tools/catalog.ts | 6 +++--- mcp/tests/catalog.spec.ts | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/chat/lib/agentLoop.js b/chat/lib/agentLoop.js index 6512eb491..229e17bf5 100644 --- a/chat/lib/agentLoop.js +++ b/chat/lib/agentLoop.js @@ -3,7 +3,7 @@ export const SYSTEM_PROMPT = `You are an assistant that builds and drives MMGIS 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 check the availableSample list before concluding no data exists. +- 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. - 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. @@ -14,6 +14,7 @@ Workflow guidance: - 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 }) { diff --git a/mcp/src/tools/catalog.ts b/mcp/src/tools/catalog.ts index 8bba03c34..b43a72834 100644 --- a/mcp/src/tools/catalog.ts +++ b/mcp/src/tools/catalog.ts @@ -44,7 +44,7 @@ export function makeCatalogTools(cfg: McpConfig, fetchFn: typeof fetch = fetch): { 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 use the returned availableSample to pick real names.', + '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"'), @@ -57,8 +57,8 @@ export function makeCatalogTools(cfg: McpConfig, fetchFn: typeof fetch = fetch): const all = await searchCollections(url, undefined, fetchFn) return toToolResult({ collections: [], - hint: `No collections matched "${keyword}". Dataset names are technical — try synonyms (e.g. air quality: no2, so2, pm, aerosol; fire: fire, burn, thermal; flood: flood, water, inundation) or pick from availableSample.`, - availableSample: all.slice(0, 40).map((c) => c.id), + 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, }) } diff --git a/mcp/tests/catalog.spec.ts b/mcp/tests/catalog.spec.ts index 530f93bf6..c6e9de4d3 100644 --- a/mcp/tests/catalog.spec.ts +++ b/mcp/tests/catalog.spec.ts @@ -32,8 +32,8 @@ describe('catalog_collections empty-match fallback', () => { const t = Object.fromEntries(makeCatalogTools(cfg2, fetcher).map((x) => [x.name, x])) const out = JSON.parse((await t.catalog_collections.handler({ catalog: 'test', keyword: 'air quality' })).content[0].text) expect(out.collections).toEqual([]) - expect(out.hint).toMatch(/synonyms/) - expect(out.availableSample).toContain('no2-monthly') + expect(out.hint).toMatch(/re-search/) + expect(out.availableCollections).toContain('no2-monthly') expect(out.totalCollections).toBe(2) }) }) From 15c42733a33eaa41bb0a78684a174a8254e814ab Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sun, 26 Jul 2026 11:02:12 -0500 Subject: [PATCH 64/71] Restyle chat UI with a light professional theme and reset stale panel missions --- chat/public/app.js | 13 ++- chat/public/style.css | 205 +++++++++++++++++++++++++++++------------- 2 files changed, 156 insertions(+), 62 deletions(-) diff --git a/chat/public/app.js b/chat/public/app.js index c22958cba..a6a97134f 100644 --- a/chat/public/app.js +++ b/chat/public/app.js @@ -155,10 +155,21 @@ if (typeof document !== 'undefined') { 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 out.missions || []) missionPicker.appendChild(new Option(m, m)) + 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 } diff --git a/chat/public/style.css b/chat/public/style.css index 7ea9bd640..2092d2c0e 100644 --- a/chat/public/style.css +++ b/chat/public/style.css @@ -1,88 +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: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - background: #12151a; color: #e6e8eb; + font-family: var(--sans); + background: var(--bg); color: var(--ink); } + +/* --- Header --- */ header { - display: flex; align-items: center; gap: 12px; padding: 10px 16px; - background: #1a1f27; border-bottom: 1px solid #2a3040; + 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; } -.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; +header h1 { + font-size: 15px; margin: 0; font-weight: 600; letter-spacing: 0.02em; } -button:hover { background: #34426a; } -button:disabled { opacity: .5; cursor: default; } -.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; +header h1::before { + content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 50%; + background: var(--accent); margin-right: 8px; vertical-align: baseline; } -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; +.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; } -.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; +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; } -#composer { display: flex; gap: 8px; padding: 12px 16px; background: #1a1f27; border-top: 1px solid #2a3040; } -#input { flex: 1; resize: none; } -.msg.thinking { - color: #8b94a3; - font-style: italic; - animation: pulse 1.2s ease-in-out infinite; + +/* --- JSON drawer --- */ +.drawer { + padding: 14px 18px; background: var(--surface-2); border-bottom: 1px solid var(--line); + display: flex; flex-direction: column; gap: 10px; } -@keyframes pulse { - 0%, 100% { opacity: 0.45; } - 50% { opacity: 1; } +.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: chat left, dashboard panel right --- */ +/* --- 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: 6px; cursor: col-resize; background: #1a1f27; - border-left: 1px solid #2a3040; border-right: 1px solid #2a3040; flex: none; + 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: #2a3550; } -#dashCol { flex: 1; display: flex; flex-direction: column; min-width: 0; position: relative; background: #0d1117; } +#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: 8px; padding: 6px 10px; - background: #1a1f27; border-bottom: 1px solid #2a3040; + display: flex; align-items: center; gap: 10px; padding: 8px 12px; + background: var(--surface); border-bottom: 1px solid var(--line); } #missionPicker { - flex: 1; background: #0d1117; color: #e6e8eb; border: 1px solid #2a3040; - border-radius: 6px; padding: 5px 8px; font-size: 13px; + 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: #9fb4d8; text-decoration: none; font-size: 16px; padding: 0 6px; } -#dashFrame { flex: 1; border: 0; width: 100%; background: #fff; display: none; } +#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: 40px 0 0 0; display: flex; align-items: center; justify-content: center; - color: #8b94a3; font-size: 14px; pointer-events: none; + 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; } From edafd5fb6abd72652685542b8f276b8a3ee669db Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sun, 26 Jul 2026 12:55:18 -0500 Subject: [PATCH 65/71] Present dataset choices with coverage before building thematic dashboards --- chat/lib/agentLoop.js | 1 + mcp/src/stac.ts | 18 +++++++++++++----- mcp/tests/stac.spec.ts | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/chat/lib/agentLoop.js b/chat/lib/agentLoop.js index 229e17bf5..d13547f78 100644 --- a/chat/lib/agentLoop.js +++ b/chat/lib/agentLoop.js @@ -4,6 +4,7 @@ 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. diff --git a/mcp/src/stac.ts b/mcp/src/stac.ts index f9de6f5a2..48edb8804 100644 --- a/mcp/src/stac.ts +++ b/mcp/src/stac.ts @@ -76,11 +76,19 @@ export async function searchCollections( if (!nextHref) break url = new URL(nextHref, url).toString() } - let collections = rawCollections.map((c: any) => ({ - id: c.id, - ...(c.title ? { title: c.title } : {}), - ...(c.description ? { description: c.description } : {}), - })) + let 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 } : {}), + } + }) if (keyword) { const k = keyword.toLowerCase() collections = collections.filter((c: any) => diff --git a/mcp/tests/stac.spec.ts b/mcp/tests/stac.spec.ts index 65e3d2af6..4bc65c5f8 100644 --- a/mcp/tests/stac.spec.ts +++ b/mcp/tests/stac.spec.ts @@ -152,3 +152,21 @@ describe('stacItemToTileLayer item-path style', () => { ) }) }) + +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]) + }) +}) From 585d86a1036d7b49237749b7325ba9458112bab5 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sun, 26 Jul 2026 22:51:49 -0500 Subject: [PATCH 66/71] [mcp] Dedupe tool-handler error wrapping and confirmation previews Add tools/result.ts's wrap() helper and route every admin/catalog/dashboard/ edit tool handler through it instead of a per-handler try/catch, and factor admin.ts's five confirm-preview payloads through a shared confirmationNeeded() helper. geodataset_ingest now only re-fetches the geodataset list when a preview is actually needed (confirm !== true). --- mcp/src/config.ts | 46 ++++++------ mcp/src/configEdit.ts | 16 +++++ mcp/src/generator.ts | 38 ++++++++-- mcp/src/profileBuilder.ts | 15 ++-- mcp/src/stac.ts | 59 ++++++++++++---- mcp/src/tools/admin.ts | 128 ++++++++++++---------------------- mcp/src/tools/catalog.ts | 62 ++++++++-------- mcp/src/tools/dashboard.ts | 61 +++++----------- mcp/src/tools/edit.ts | 67 +++++------------- mcp/src/tools/result.ts | 10 +++ mcp/tests/admin.spec.ts | 5 +- mcp/tests/catalog.spec.ts | 18 ++++- mcp/tests/dashboard.spec.ts | 29 ++++++-- mcp/tests/edit.spec.ts | 5 +- mcp/tests/helpers.ts | 9 +++ mcp/tests/mmgisClient.spec.ts | 5 +- mcp/tests/stac.spec.ts | 17 +++-- 17 files changed, 313 insertions(+), 277 deletions(-) create mode 100644 mcp/tests/helpers.ts diff --git a/mcp/src/config.ts b/mcp/src/config.ts index 70bfe1497..ba2fbc5ff 100644 --- a/mcp/src/config.ts +++ b/mcp/src/config.ts @@ -38,6 +38,24 @@ function assertStacCatalogsShape(value: unknown): asserts value is 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( @@ -50,28 +68,12 @@ export function loadConfig(env: Record = process.env 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)), '..', '..') - let stacCatalogs = DEFAULT_STAC_CATALOGS - if (env.STAC_CATALOGS) { - let parsed: unknown - try { - parsed = JSON.parse(env.STAC_CATALOGS) - } catch { - throw new Error(STAC_CATALOGS_SHAPE_ERROR) - } - assertStacCatalogsShape(parsed) - stacCatalogs = parsed - } - let stacTilers = DEFAULT_STAC_TILERS - if (env.STAC_TILERS) { - let parsedTilers: unknown - try { - parsedTilers = JSON.parse(env.STAC_TILERS) - } catch { - throw new Error('STAC_TILERS must be a JSON object mapping catalog name to tiler URL') - } - assertStacCatalogsShape(parsedTilers) - stacTilers = parsedTilers - } + 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, diff --git a/mcp/src/configEdit.ts b/mcp/src/configEdit.ts index 30d26b204..db4baf45b 100644 --- a/mcp/src/configEdit.ts +++ b/mcp/src/configEdit.ts @@ -39,3 +39,19 @@ 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 index b7e055710..8df566360 100644 --- a/mcp/src/generator.ts +++ b/mcp/src/generator.ts @@ -34,14 +34,42 @@ export function resolvePlaceholders(config: any, mapboxToken: string): any { 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 minimal = JSON.parse( - fs.readFileSync(path.join(repoRoot, 'mission-profiles', 'minimal.json'), 'utf8') - ) - const probe = JSON.parse(JSON.stringify(minimal)) + 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) - return config.tools.map((t: { name: string }) => t.name) + const names = config.tools.map((t: { name: string }) => t.name) + availableToolsCache.set(repoRoot, names) + return names } diff --git a/mcp/src/profileBuilder.ts b/mcp/src/profileBuilder.ts index 1bbb7ca0a..879da3cbb 100644 --- a/mcp/src/profileBuilder.ts +++ b/mcp/src/profileBuilder.ts @@ -1,6 +1,5 @@ import { randomUUID } from 'node:crypto' -import fs from 'node:fs' -import path from 'node:path' +import { loadMinimalProfile } from './generator.js' export interface DashboardSpec { missionName: string @@ -21,10 +20,16 @@ export const AGENT_BRIDGE_COMPONENT = { 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 = JSON.parse( - fs.readFileSync(path.join(repoRoot, 'mission-profiles', 'minimal.json'), 'utf8') - ) + const minimal = loadMinimalProfile(repoRoot) const profile = JSON.parse(JSON.stringify(minimal)) profile.name = `agent-${spec.missionName}` profile.description = 'Generated by the MMGIS MCP server' diff --git a/mcp/src/stac.ts b/mcp/src/stac.ts index 48edb8804..60fb2d9b1 100644 --- a/mcp/src/stac.ts +++ b/mcp/src/stac.ts @@ -7,6 +7,10 @@ export interface StacItemSummary { 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 { @@ -45,7 +49,8 @@ function summarizeItem(feature: any): StacItemSummary { export async function searchStac( catalogUrl: string, params: { bbox?: number[]; datetime?: string; collections?: string[]; limit?: number }, - fetchFn: typeof fetch = fetch + fetchFn: typeof fetch = fetch, + catalogName?: string ): Promise { const body: Record = { limit: params.limit ?? 10 } if (params.bbox) body.bbox = params.bbox @@ -56,17 +61,29 @@ export async function searchStac( { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, fetchFn ) - return (json.features || []).map(summarizeItem) + 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 -export async function searchCollections( - catalogUrl: string, - keyword?: string, - fetchFn: typeof fetch = fetch -): Promise<{ id: string; title?: string; description?: string }[]> { +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++) { @@ -76,7 +93,7 @@ export async function searchCollections( if (!nextHref) break url = new URL(nextHref, url).toString() } - let collections = rawCollections.map((c: any) => { + const collections = rawCollections.map((c: any) => { const temporal = c.extent?.temporal?.interval?.[0] const spatial = c.extent?.spatial?.bbox?.[0] return { @@ -89,15 +106,29 @@ export async function searchCollections( ...(spatial ? { spatialExtent: spatial } : {}), } }) - if (keyword) { - const k = keyword.toLowerCase() - collections = collections.filter((c: any) => - [c.id, c.title, c.description].some((s) => s && s.toLowerCase().includes(k)) - ) - } + 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: { diff --git a/mcp/src/tools/admin.ts b/mcp/src/tools/admin.ts index 7567eedd1..2cba6a000 100644 --- a/mcp/src/tools/admin.ts +++ b/mcp/src/tools/admin.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import type { MmgisClient } from '../mmgisClient.js' import { MMGISError } from '../mmgisClient.js' -import { type ToolDef, toToolResult, toErrorResult } from './result.js' +import { type ToolDef, toToolResult, toErrorResult, wrap } from './result.js' const MAX_GEOJSON_BYTES = 20 * 1024 * 1024 @@ -11,32 +11,28 @@ 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: async () => { - try { - return toToolResult({ missions: await client.listMissions() }) - } catch (err) { - return toErrorResult(err) - } - }, + 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: async ({ mission }: { mission: string }) => { - try { + handler: ({ mission }: { mission: string }) => + wrap(async () => { const out = await client.getMission(mission) return toToolResult({ mission: out.mission, version: out.version, config: out.config }) - } catch (err) { - return toErrorResult(err) - } - }, + }), }, { name: 'mission_clone', @@ -45,14 +41,11 @@ export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetc fromMission: z.string().describe('Existing mission to copy'), toMission: z.string().describe('Name for the new mission'), }, - handler: async ({ fromMission, toMission }: any) => { - try { + handler: ({ fromMission, toMission }: any) => + wrap(async () => { const out = await client.cloneMission(fromMission, toMission) return toToolResult({ mission: toMission, ...out }) - } catch (err) { - return toErrorResult(err) - } - }, + }), }, { name: 'mission_delete', @@ -61,8 +54,8 @@ export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetc mission: z.string(), confirm: z.boolean().optional().describe('Must be true to actually delete'), }, - handler: async ({ mission, confirm }: any) => { - try { + handler: ({ mission, confirm }: any) => + wrap(async () => { const missions = await client.listMissions() if (!missions.includes(mission)) { return toErrorResult( @@ -72,29 +65,19 @@ export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetc ) } if (confirm !== true) { - return toToolResult({ - needsConfirmation: true, + return confirmationNeeded({ wouldDelete: `Mission "${mission}" and every config version of it (the Missions/ folder is renamed, not erased).`, - hint: CONFIRM_RETRY_HINT, }) } return toToolResult(await client.destroyMission(mission)) - } 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) - } - }, + handler: () => + wrap(async () => toToolResult({ geodatasets: await client.geodatasetEntries() })), }, { name: 'geodataset_ingest', @@ -105,19 +88,19 @@ export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetc 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: async ({ name, geojson, url, confirm }: any) => { - try { + handler: ({ name, geojson, url, confirm }: any) => + wrap(async () => { if (!geojson === !url) { return toErrorResult(new MMGISError('Provide exactly one of geojson or url')) } - const existing = await client.geodatasetEntries() - const match = (existing as any[]).find((e: any) => e.name === name) - if (match && confirm !== true) { - return toToolResult({ - needsConfirmation: true, - wouldReplace: `Geodataset "${name}" already exists with ${match.num_features} features — ingesting replaces ALL of them.`, - hint: CONFIRM_RETRY_HINT, - }) + 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) { @@ -138,41 +121,28 @@ export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetc } 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 { + handler: ({ name, confirm }: any) => + wrap(async () => { if (confirm !== true) { - return toToolResult({ - needsConfirmation: true, + return confirmationNeeded({ wouldDelete: `Geodataset "${name}" and its feature table. Layers referencing geodatasets:${name} will break.`, - hint: CONFIRM_RETRY_HINT, }) } return toToolResult(await client.geodatasetRemove(name)) - } catch (err) { - return toErrorResult(err) - } - }, + }), }, { 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) - } - }, + handler: () => + wrap(async () => toToolResult({ users: await client.accountEntries() })), }, { name: 'user_create', @@ -182,21 +152,16 @@ export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetc password: z.string().describe('8+ chars with upper, lower, number, symbol'), confirm: z.boolean().optional(), }, - handler: async ({ username, password, confirm }: any) => { - try { + handler: ({ username, password, confirm }: any) => + wrap(async () => { if (confirm !== true) { - return toToolResult({ - needsConfirmation: true, + return confirmationNeeded({ wouldCreate: `User "${username}" with Viewer (001) permission.`, - hint: CONFIRM_RETRY_HINT, }) } const out = await client.userSignup(username, password) return toToolResult({ username: out.username ?? username, created: true }) - } catch (err) { - return toErrorResult(err) - } - }, + }), }, { name: 'user_set_permission', @@ -207,13 +172,11 @@ export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetc missionsManaging: z.array(z.string()).optional().describe("Missions an Admin ('110') manages"), confirm: z.boolean().optional(), }, - handler: async ({ username, permission, missionsManaging, confirm }: any) => { - try { + handler: ({ username, permission, missionsManaging, confirm }: any) => + wrap(async () => { if (confirm !== true) { - return toToolResult({ - needsConfirmation: true, + return confirmationNeeded({ wouldChange: `Set "${username}" permission to ${permission}${missionsManaging ? ` managing [${missionsManaging.join(', ')}]` : ''}.`, - hint: CONFIRM_RETRY_HINT, }) } const users = await client.accountEntries() @@ -223,10 +186,7 @@ export function makeAdminTools(client: MmgisClient, fetchFn: typeof fetch = fetc } await client.accountUpdate({ id: user.id, permission, ...(missionsManaging ? { missionsManaging } : {}) }) return toToolResult({ username, permission, ...(missionsManaging ? { missionsManaging } : {}) }) - } catch (err) { - return toErrorResult(err) - } - }, + }), }, ] } diff --git a/mcp/src/tools/catalog.ts b/mcp/src/tools/catalog.ts index b43a72834..40047f7bb 100644 --- a/mcp/src/tools/catalog.ts +++ b/mcp/src/tools/catalog.ts @@ -1,22 +1,27 @@ import { z } from 'zod' import type { McpConfig } from '../config.js' -import { searchStac, searchCollections, stacItemToTileLayer } from '../stac.js' +import { searchStac, searchCollections, filterCollections, stacItemToTileLayer } from '../stac.js' import { MMGISError } from '../mmgisClient.js' -import { type ToolDef, toToolResult, toErrorResult } from './result.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 — match the item's self link to a catalog origin and -// use that catalog's dedicated tiler when one is configured. +// 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 + 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]) { - // Dedicated tilers are titiler-pgstac (eoAPI) deployments — - // they serve items by path, not by a ?url= parameter. return { titilerUrl: cfg.stacTilers[name].replace(/\/+$/, ''), urlStyle: 'item-path' } } } catch { @@ -39,6 +44,12 @@ function resolveCatalog(cfg: McpConfig, catalog: string): string { 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 [ { @@ -49,12 +60,12 @@ export function makeCatalogTools(cfg: McpConfig, fetchFn: typeof fetch = fetch): 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 { + handler: ({ catalog, keyword }: { catalog: string; keyword?: string }) => + wrap(async () => { const url = resolveCatalog(cfg, catalog) - const collections = await searchCollections(url, keyword, fetchFn) + const all = await searchCollections(url, undefined, fetchFn) + const collections = keyword ? filterCollections(all, keyword) : all if (keyword && collections.length === 0) { - const all = await searchCollections(url, undefined, fetchFn) 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.`, @@ -63,10 +74,7 @@ export function makeCatalogTools(cfg: McpConfig, fetchFn: typeof fetch = fetch): }) } return toToolResult({ collections }) - } catch (err) { - return toErrorResult(err) - } - }, + }), }, { name: 'catalog_search', @@ -78,13 +86,12 @@ export function makeCatalogTools(cfg: McpConfig, fetchFn: typeof fetch = fetch): 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) - } - }, + 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', @@ -97,9 +104,9 @@ export function makeCatalogTools(cfg: McpConfig, fetchFn: typeof fetch = fetch): 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 { - const tiler = tilerForItem(cfg, item?.selfHref ?? null) + handler: ({ item, name, asset, rescale, colormap }: any) => + wrap(async () => { + const tiler = tilerForItem(cfg, item?.selfHref ?? null, item?.catalogName) return toToolResult({ layer: stacItemToTileLayer(item, { name, @@ -110,10 +117,7 @@ export function makeCatalogTools(cfg: McpConfig, fetchFn: typeof fetch = fetch): colormap, }), }) - } catch (err) { - return toErrorResult(err) - } - }, + }), }, ] } diff --git a/mcp/src/tools/dashboard.ts b/mcp/src/tools/dashboard.ts index 81b0170cc..3e9f5531d 100644 --- a/mcp/src/tools/dashboard.ts +++ b/mcp/src/tools/dashboard.ts @@ -1,9 +1,9 @@ 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' +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: { @@ -124,40 +124,24 @@ export function makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef 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) - } - }, + 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: async (args: Omit & { mission: string; updateExisting?: boolean; returnConfig?: boolean }) => { - try { + 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) - let config = await generateConfig(profile, cfg.repoRoot) - const neededMapboxToken = JSON.stringify(config).includes('{{MAPBOX_TOKEN}}') - config = resolvePlaceholders(config, cfg.mapboxToken) - const warnings: string[] = [] + 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. - if (neededMapboxToken && cfg.mapboxToken === '' && config?.msv?.basemap?.provider === 'mapbox') { - config.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') - } - // Injected after generation: `components` is not a template key, - // and /api/configure/add does not run backend validation. - config.components = [AGENT_BRIDGE_COMPONENT] + const { config, warnings } = finalizeBasemap(generated, cfg.mapboxToken) + ensureAgentBridgeComponent(config) const out = await installMission(client, args.mission, config, args.updateExisting) return toToolResult({ mission: out.mission, @@ -166,10 +150,7 @@ export function makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef ...(warnings.length > 0 ? { warnings } : {}), ...(args.returnConfig ? { config } : {}), }) - } catch (err) { - return toErrorResult(err) - } - }, + }), }, { name: 'dashboard_create_from_config', @@ -180,30 +161,20 @@ export function makeDashboardTools(client: MmgisClient, cfg: McpConfig): ToolDef 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: { mission: string; config: any; updateExisting?: boolean }) => { - try { + handler: (args: { mission: string; config: any; updateExisting?: boolean }) => + wrap(async () => { const nameError = validateMissionName(args.mission) 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 { config, warnings } = finalizeBasemap(args.config, cfg.mapboxToken) + ensureAgentBridgeComponent(config) const out = await installMission(client, args.mission, 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.mission)}`, ...(warnings.length > 0 ? { warnings } : {}), }) - } catch (err) { - return toErrorResult(err) - } - }, + }), }, ] } diff --git a/mcp/src/tools/edit.ts b/mcp/src/tools/edit.ts index 40e1880d5..975b17c66 100644 --- a/mcp/src/tools/edit.ts +++ b/mcp/src/tools/edit.ts @@ -1,18 +1,14 @@ 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' +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.' -function layerNames(config: any): string { - return (config?.layers ?? []).map((l: any) => l.name).join(', ') || '(none)' -} - export function makeEditTools(client: MmgisClient): ToolDef[] { const mission = z.string().describe('Mission to edit (see mission_list)') return [ @@ -24,18 +20,15 @@ export function makeEditTools(client: MmgisClient): ToolDef[] { mission, patch: z.record(z.any()).describe('Merge patch, e.g. {"look": {"pagename": "New Name"}} or {"msv": {"basemap": {...}}}'), }, - handler: async ({ mission, patch }: any) => { - try { + 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 }) - } catch (err) { - return toErrorResult(err) - } - }, + }), }, { name: 'layer_add', @@ -45,8 +38,8 @@ export function makeEditTools(client: MmgisClient): ToolDef[] { 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 ({ mission, layer, position }: any) => { - try { + 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'), { @@ -62,10 +55,7 @@ export function makeEditTools(client: MmgisClient): ToolDef[] { return { info: { type: 'addLayer', layerName: entry.name } } }) return toToolResult({ ...out, layer: entry, refresh: LIVE_NOTE }) - } catch (err) { - return toErrorResult(err) - } - }, + }), }, { name: 'layer_update', @@ -75,56 +65,40 @@ export function makeEditTools(client: MmgisClient): ToolDef[] { 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 ({ mission, layer, patch }: any) => { - try { + handler: ({ mission, layer, patch }: any) => + wrap(async () => { let updatedName = '' const out = await editConfig(client, mission, (config) => { - const idx = findLayerIndex(config, layer) - if (idx === -1) { - throw Object.assign(new Error(`Unknown layer: ${layer}`), { - hint: `Available layers: ${layerNames(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 }) - } catch (err) { - return toErrorResult(err) - } - }, + }), }, { 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: async ({ mission, layer }: any) => { - try { + handler: ({ mission, layer }: any) => + wrap(async () => { let removedName = '' const out = await editConfig(client, mission, (config) => { - const idx = findLayerIndex(config, layer) - if (idx === -1) { - throw Object.assign(new Error(`Unknown layer: ${layer}`), { - hint: `Available layers: ${layerNames(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 }) - } catch (err) { - return toErrorResult(err) - } - }, + }), }, { 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: async ({ mission, toolName, on }: any) => { - try { + 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) { @@ -135,10 +109,7 @@ export function makeEditTools(client: MmgisClient): ToolDef[] { tool.on = on }) return toToolResult({ ...out, tool: toolName, on, refresh: RELOAD_NOTE }) - } catch (err) { - return toErrorResult(err) - } - }, + }), }, ] } diff --git a/mcp/src/tools/result.ts b/mcp/src/tools/result.ts index cfabec329..72ce3f2f3 100644 --- a/mcp/src/tools/result.ts +++ b/mcp/src/tools/result.ts @@ -23,3 +23,13 @@ export function toErrorResult(err: unknown) { ], } } + +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/tests/admin.spec.ts b/mcp/tests/admin.spec.ts index d6f1396b1..d1c200f41 100644 --- a/mcp/tests/admin.spec.ts +++ b/mcp/tests/admin.spec.ts @@ -1,16 +1,13 @@ 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 -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])) diff --git a/mcp/tests/catalog.spec.ts b/mcp/tests/catalog.spec.ts index c6e9de4d3..a7aa3121d 100644 --- a/mcp/tests/catalog.spec.ts +++ b/mcp/tests/catalog.spec.ts @@ -1,5 +1,7 @@ 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 @@ -11,12 +13,13 @@ describe('catalog tools', () => { 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') + 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 }, @@ -30,7 +33,7 @@ describe('catalog_collections empty-match fallback', () => { })) 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 = JSON.parse((await t.catalog_collections.handler({ catalog: 'test', keyword: 'air quality' })).content[0].text) + 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') @@ -54,4 +57,15 @@ describe('tilerForItem', () => { 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/dashboard.spec.ts b/mcp/tests/dashboard.spec.ts index 31c5b8d46..7e0e5f5ff 100644 --- a/mcp/tests/dashboard.spec.ts +++ b/mcp/tests/dashboard.spec.ts @@ -3,6 +3,7 @@ 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 = { @@ -15,10 +16,6 @@ const cfg = { 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])) @@ -174,6 +171,30 @@ describe('dashboard tools', () => { 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 = { diff --git a/mcp/tests/edit.spec.ts b/mcp/tests/edit.spec.ts index e0b591670..713613254 100644 --- a/mcp/tests/edit.spec.ts +++ b/mcp/tests/edit.spec.ts @@ -1,9 +1,6 @@ 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) -} +import { parse } from './helpers.js' function fakeClient(config: any) { return { 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 index 30b0fe841..8901a7622 100644 --- a/mcp/tests/mmgisClient.spec.ts +++ b/mcp/tests/mmgisClient.spec.ts @@ -1,9 +1,6 @@ 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 -} +import { fakeFetch } from './helpers.js' describe('MmgisClient', () => { it('sends the Authorization header and returns mission names', async () => { diff --git a/mcp/tests/stac.spec.ts b/mcp/tests/stac.spec.ts index 4bc65c5f8..06c2f6588 100644 --- a/mcp/tests/stac.spec.ts +++ b/mcp/tests/stac.spec.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, vi } from 'vitest' -import { searchStac, searchCollections, stacItemToTileLayer } from '../src/stac.js' +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', @@ -11,13 +12,15 @@ const ITEM = { 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 -} +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({ features: [ITEM] }) + 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({ @@ -49,7 +52,7 @@ describe('searchStac', () => { describe('searchCollections', () => { it('filters collections by keyword across id/title/description', async () => { - const f = fakeFetch({ + const f = fakeFetch(200, { collections: [ { id: 'no2-monthly', title: 'NO2 Monthly', description: 'Nitrogen dioxide' }, { id: 'dem', title: 'Elevation', description: 'Terrain' }, From 8639ec0e3a3f8da431088629bf2e282996a89d18 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sun, 26 Jul 2026 22:52:00 -0500 Subject: [PATCH 67/71] [backend] Extract shared SuperAdmin check and token-decoration helper Add API/Backend/Utils/permissions.js's isSuperAdminRequest() and use it in configs.js's /add guard and both signup-gate clauses in users.js (behavior unchanged; the users.js clauses are the negation of the same check). Also factor scripts/server.js's repeated long-term-token -> req field mapping (ensureAdmin, ensureUser, annotateLongTermToken) into one decorateReqFromTokenData() helper. --- API/Backend/Config/routes/configs.js | 5 ++--- API/Backend/Users/routes/users.js | 8 +++---- API/Backend/Utils/permissions.js | 7 ++++++ scripts/server.js | 32 +++++++++++++++++----------- 4 files changed, 32 insertions(+), 20 deletions(-) create mode 100644 API/Backend/Utils/permissions.js diff --git a/API/Backend/Config/routes/configs.js b/API/Backend/Config/routes/configs.js index 34d0df88f..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,9 +382,7 @@ function add(req, res, next, cb) { if (fullAccess) router.post("/add", function (req, res, next) { - const isSuperAdmin = - req.session.permission === "111" || - (req.isLongTermToken === true && req.tokenUserPermission === "111"); + const isSuperAdmin = isSuperAdminRequest(req); if (!isSuperAdmin) { res.send({ status: "failure", diff --git a/API/Backend/Users/routes/users.js b/API/Backend/Users/routes/users.js index c0baca61c..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,15 +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" && - !(req.isLongTermToken === true && req.tokenUserPermission === "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" && - !(req.isLongTermToken === true && req.tokenUserPermission === "111")) + (process.env.AUTH === "off" && !isSuperAdminRequest(req)) ) { res.send({ status: "failure", 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/scripts/server.js b/scripts/server.js index 2266ad5a2..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(); }, () => { @@ -454,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(); }, () => { @@ -488,10 +499,7 @@ function annotateLongTermToken(req, res, next) { 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(); }, () => next() From 968f10692954881df9192560712c06a5c6ad3ce4 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Sun, 26 Jul 2026 22:52:07 -0500 Subject: [PATCH 68/71] [chat, frontend] Small dead-code and duplication cleanups - chat/lib/agentLoop.js: drop the redundant parsed = null reassignment in the JSON.parse catch block. - chat/public/app.js: add an appendAndScroll() helper and use it in addBubble, addToolCard, and the streaming-text scroll update. - AgentBridge.js: factor the repeated ok ? {ok:true,...} : {ok:false,...} shape in the modern openTool branches into a small toOpenResult() helper. --- chat/lib/agentLoop.js | 4 +--- chat/public/app.js | 16 +++++++++------- .../AgentBridge/AgentBridge.js | 19 +++++++++++-------- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/chat/lib/agentLoop.js b/chat/lib/agentLoop.js index d13547f78..0d94ea3a8 100644 --- a/chat/lib/agentLoop.js +++ b/chat/lib/agentLoop.js @@ -41,9 +41,7 @@ export async function runAgentLoop({ messages, openai, bridge, model, onEvent, m let parsed = null try { parsed = c.args ? JSON.parse(c.args) : {} - } catch { - parsed = null - } + } catch {} onEvent({ type: 'tool_call', id: c.id, name: c.name, args: parsed ?? {} }) const result = parsed === null diff --git a/chat/public/app.js b/chat/public/app.js index a6a97134f..cfdb9ccb2 100644 --- a/chat/public/app.js +++ b/chat/public/app.js @@ -81,13 +81,17 @@ if (typeof document !== 'undefined') { 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 - transcript.appendChild(div) - transcript.scrollTop = transcript.scrollHeight - return div + return appendAndScroll(div) } function addToolCard(name, args) { @@ -99,9 +103,7 @@ if (typeof document !== 'undefined') { 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 + return appendAndScroll(details) } function finishToolCard(card, result, isError) { @@ -271,7 +273,7 @@ if (typeof document !== 'undefined') { bubbleText += ev.delta fullText += ev.delta assistantDiv.textContent = bubbleText - transcript.scrollTop = transcript.scrollHeight + appendAndScroll(assistantDiv) } else if (ev.type === 'tool_call') { // a new assistant bubble will follow the tool round assistantDiv = null diff --git a/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js index 4bfc50d7c..8d97e1443 100644 --- a/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js @@ -26,6 +26,13 @@ function isModernMission() { // — 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() @@ -51,24 +58,20 @@ function buildToolAdapter() { if (loaded && !hidden) { // Already visible; treat as a success (idempotent open). - return { ok: true, activeTool: toolId } + return toOpenResult(true, toolId, name) } if (loaded && hidden) { // Loaded but hidden (hidePlugin / startHidden) — reveal it. - return api.showPlugin(toolId) - ? { ok: true, activeTool: toolId } - : { ok: false, error: `Unknown or unopenable tool: ${name}` } + return toOpenResult(api.showPlugin(toolId), toolId, name) } if (!loaded && hidden) { // Deferred (startUnloaded / previously unloadPlugin'd) — load it. - return api.loadPlugin(toolId) - ? { ok: true, activeTool: toolId } - : { ok: false, error: `Unknown or unopenable tool: ${name}` } + 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 { ok: false, error: `Unknown or unopenable tool: ${name}` } + return toOpenResult(false, toolId, name) }, } } From 58d41f9b03bb92ceef55e3f038c177425e00f977 Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Mon, 27 Jul 2026 18:49:14 -0500 Subject: [PATCH 69/71] Match mission names leniently in the agent bridge --- .../AgentBridge/AgentBridge.js | 4 ++-- .../AgentBridge/commands.js | 12 +++++++++++- tests/unit/agentBridgeCommands.spec.js | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js index 8d97e1443..c5d427dcd 100644 --- a/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/AgentBridge.js @@ -3,7 +3,7 @@ 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, shouldReloadForFrame } from './commands' +import { executeCommand, resolveToolId, sameMission, shouldReloadForFrame } from './commands' // Envelope contract shared with mcp/src/bridge.ts — keep in sync. const FRAME_TYPE = 'agent-bridge' @@ -185,7 +185,7 @@ const AgentBridge = { 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 + if (parsed.body == null || !sameMission(parsed.body.mission, L_.mission)) return const { id, command, args } = parsed.agent let outcome diff --git a/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js b/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js index 0235e6d5a..a49c409ba 100644 --- a/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js +++ b/src/essence/MMGIS-Plugin-Components/AgentBridge/commands.js @@ -37,10 +37,20 @@ export function getViewState(deps) { // 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 || parsed.body.mission !== mission) 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) } diff --git a/tests/unit/agentBridgeCommands.spec.js b/tests/unit/agentBridgeCommands.spec.js index 4ca4aa5a8..bf8ebecdb 100644 --- a/tests/unit/agentBridgeCommands.spec.js +++ b/tests/unit/agentBridgeCommands.spec.js @@ -200,3 +200,18 @@ describe('resolveToolId', () => { 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) + }) +}) From de64936bff90fb01d4203ed73401bdddbbffe3e6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 28 Jul 2026 04:02:38 +0000 Subject: [PATCH 70/71] chore: bump version to 4.2.19-20260728 [version bump] --- configure/package.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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": { From c0d7641a4c8e11dac384cb926ba360f9d92fa4ff Mon Sep 17 00:00:00 2001 From: ajinkyakulkarni Date: Wed, 29 Jul 2026 09:52:47 -0500 Subject: [PATCH 71/71] Explain MMGIS_URL and MMGIS_TOKEN in the setup docs --- chat/.env.example | 13 +++++++++---- chat/README.md | 12 +++++++++--- mcp/README.md | 32 ++++++++++++++++---------------- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/chat/.env.example b/chat/.env.example index beb899c40..e50f60aa2 100644 --- a/chat/.env.example +++ b/chat/.env.example @@ -7,11 +7,16 @@ CHAT_PORT=8895 MCP_COMMAND=node MCP_ARGS=../mcp/dist/index.js -# Passed through to the MCP server process -MMGIS_URL=http://localhost:8891 +# 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 -# (dev: webpack serves the UI on PORT+1 and its redirect drops query params) -MMGIS_DASHBOARD_URL=http://localhost:8892 +# (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/README.md b/chat/README.md index 0b581f9a7..dffb5695f 100644 --- a/chat/README.md +++ b/chat/README.md @@ -8,8 +8,12 @@ all from a browser chat. 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`). +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 @@ -39,7 +43,9 @@ MMGIS REST + websocket. Conversation state lives in your browser | `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 | +| `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 diff --git a/mcp/README.md b/mcp/README.md index 49a242db2..1aa339df4 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -9,20 +9,20 @@ catalogs for data layers, and control a live browser session. 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). +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`. @@ -30,8 +30,8 @@ catalogs for data layers, and control a live browser session. | 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_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 |