From 690b90abcfc2623c50b44385cbfe6ed928df6089 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 28 Feb 2026 09:11:59 +0100 Subject: [PATCH] feat: first cut at mcp and mcp apps --- .claude/settings.local.json | 3 +- MCP_PLAN.md | 160 ++++ TODO.md | 512 +++++++++++-- package-lock.json | 1091 +++++++++++++++++++++++++++- package.json | 2 + src/main/engine/MCPServer.ts | 690 ++++++++++++++++++ src/main/engine/ProposalStore.ts | 68 ++ src/main/engine/mcp-views.ts | 258 +++++++ src/main/main.ts | 15 + tests/engine/MCPServer.test.ts | 390 ++++++++++ tests/engine/ProposalStore.test.ts | 148 ++++ tsconfig.main.json | 3 + 12 files changed, 3276 insertions(+), 64 deletions(-) create mode 100644 MCP_PLAN.md create mode 100644 src/main/engine/MCPServer.ts create mode 100644 src/main/engine/ProposalStore.ts create mode 100644 src/main/engine/mcp-views.ts create mode 100644 tests/engine/MCPServer.test.ts create mode 100644 tests/engine/ProposalStore.test.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b16e5d3..a658218 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,8 @@ "Bash(npm test)", "Bash(ls -la /Users/gb/Projects/bDS/*.md)", "Bash(grep -n \"templateSlug\\\\|postTemplateSlug\" /Users/gb/Projects/bDS/src/main/engine/*.ts)", - "Bash(npm test -- tests/renderer/i18nLocaleCompleteness.test.ts)" + "Bash(npm test -- tests/renderer/i18nLocaleCompleteness.test.ts)", + "WebFetch(domain:ricmac.org)" ] } } diff --git a/MCP_PLAN.md b/MCP_PLAN.md new file mode 100644 index 0000000..ca5862f --- /dev/null +++ b/MCP_PLAN.md @@ -0,0 +1,160 @@ +# Chapter 3: MCP Server — Agent-Assisted Content Creation + +## Summary + +Run a standalone MCP server on its own port (default 4124). External AI agents connect via Streamable HTTP at `/mcp`. Read access is open; writes go through user-reviewable proposals with MCP App review UIs powered by `@modelcontextprotocol/ext-apps`. + +## Dependencies + +- `@modelcontextprotocol/sdk` (v1.27+) — `McpServer`, `StreamableHTTPServerTransport`, `createMcpExpressApp` +- `@modelcontextprotocol/ext-apps` (v1.1+) — `registerAppTool`, `registerAppResource`, `RESOURCE_MIME_TYPE` for interactive review UIs rendered inline in compliant hosts (Claude, ChatGPT, VS Code, etc.) +- `express` / `cors` — pulled in transitively by the SDK; used by `createMcpExpressApp` + +zod v4 already in project satisfies peer dep requirements. + +## Architecture + +``` +PreviewServer (HTTP on 127.0.0.1:4123) ← unchanged + └── /* → Blog preview routes + +MCPServer (HTTP on 127.0.0.1:4124) ← NEW, standalone + └── /mcp → Streamable HTTP (POST + GET/DELETE) + ├── tools, resources, prompts + └── ui:// resources → MCP App review Views (via ext-apps) +``` + +The MCP SDK provides `createMcpExpressApp` which sets up Express with the correct Streamable HTTP handling. We use stateless mode (new `McpServer` per request) to avoid session management complexity. + +`MCPServer` is a new engine class that owns the `McpServer` factory, tool/resource/prompt registration, and the `ProposalStore`. It runs independently of PreviewServer. + +## Implementation Steps + +### Step 1: Install deps +``` +npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps +``` + +### Step 2: ProposalStore (`src/main/engine/ProposalStore.ts`) +- `Map` with TTL (30 min default) +- `create(type, data) → id`, `get(id)`, `remove(id)`, `cleanup()` +- Draft posts tracked by mapping proposalId → 'draftPost' (actual data in DB) +- Periodic cleanup via `setInterval` + +### Step 3: MCPServer engine (`src/main/engine/MCPServer.ts`) +- Constructor: inject engine getters (PostEngine, MediaEngine, ScriptEngine, TemplateEngine, MetaEngine, PostMediaEngine, TagEngine) +- `createServer()` → factory that instantiates a fresh `McpServer` and registers all tools/resources/prompts (stateless mode — one per request) +- `start(port)` → uses `createMcpExpressApp` + `StreamableHTTPServerTransport` in stateless mode, listens on `127.0.0.1:port` +- `stop()` → close HTTP server +- `cleanup()` → discard proposals, stop intervals, stop server +- Singleton pattern with `getMCPServer()` getter + +### Step 4: Resources (read-only data) +Register MCP resources mapping to existing engine methods: + +| Resource URI | Engine Method | +|---|---| +| `bds://posts` | PostEngine.getAllPosts() | +| `bds://posts/{id}` | PostEngine.getPost(id) | +| `bds://media` | MediaEngine.getAllMedia() | +| `bds://media/{id}` | MediaEngine.getMedia(id) | +| `bds://tags` | PostEngine.getTagsWithCounts() | +| `bds://categories` | PostEngine.getCategoriesWithCounts() | +| `bds://stats` | PostEngine.getBlogStats() + MediaEngine | +| `bds://posts/{id}/backlinks` | PostEngine.getLinkedBy(id) | +| `bds://posts/{id}/outlinks` | PostEngine.getLinksTo(id) | +| `bds://posts/{id}/media` | PostMediaEngine.getLinkedMediaDataForPost(id) | +| `bds://media/{id}/posts` | PostMediaEngine.getLinkedPostsForMedia(id) | + +### Step 5: Read tools +- `search_posts` — annotations: `{ readOnlyHint: true, openWorldHint: false }` + - Wraps PostEngine.searchPosts() with filter args (query, category, tags, year/month, offset, limit) + +### Step 6: Proposal tools (with MCP App Views) +Each proposal tool uses `registerAppTool` from `@modelcontextprotocol/ext-apps/server` to link the tool to a `ui://` resource. The host renders the review View inline. + +Each review View is a bundled HTML page (built with Vite `vite-plugin-singlefile` into a single self-contained HTML) that uses the `App` class from `@modelcontextprotocol/ext-apps` for bidirectional communication with the host. + +| Tool | Action | `ui://` Resource | Accept | Discard | +|---|---|---|---|---| +| `draft_post` | PostEngine.createPost(draft) | `ui://bds/review-post` | publishPost() | deletePost() | +| `propose_script` | Store in ProposalStore | `ui://bds/review-script` | ScriptEngine.createScript() | remove from store | +| `propose_template` | Store in ProposalStore | `ui://bds/review-template` | TemplateEngine.createTemplate() | remove from store | +| `propose_media_metadata` | Store diff in ProposalStore | `ui://bds/review-metadata` | MediaEngine.updateMedia() | remove from store | +| `propose_post_metadata` | Store diff in ProposalStore | `ui://bds/review-metadata` | PostEngine.updatePost() | remove from store | + +Resource registration uses `registerAppResource` from `@modelcontextprotocol/ext-apps/server`. Each resource returns a bundled HTML string with `mimeType: RESOURCE_MIME_TYPE`. + +### Step 7: Accept/discard tools +- `accept_proposal({ proposalId })` — dispatch by type, commit change +- `discard_proposal({ proposalId })` — dispatch by type, clean up +- Annotations: idempotentHint: true + +### Step 8: MCP Prompts +- `draft-blog-post(topic?, category?)` — structured prompt guiding agent to read context and draft +- `improve-media-metadata(scope)` — guide agent to review media and propose alt/caption +- `content-audit(category?)` — guide agent to review posts for quality + +### Step 9: MCP App Review Views (`src/main/mcp-apps/`) +Four View HTML pages, each using vanilla JS + `App` class from `@modelcontextprotocol/ext-apps`: + +- `review-post.html` — title, metadata, rendered markdown, word count; accept/discard buttons call `app.callServerTool()` → `accept_proposal`/`discard_proposal` +- `review-script.html` — title, syntax-highlighted Python, validation status +- `review-template.html` — title, kind badge, syntax-highlighted Liquid, validation +- `review-metadata.html` — side-by-side diff (current vs proposed) + +Each View: +1. Receives tool result data from host via `app.ontoolresult` +2. Renders focused review interface +3. On accept/discard: calls `app.callServerTool({ name: 'accept_proposal' | 'discard_proposal', arguments: { proposalId } })` +4. Updates UI to show outcome + +Build step: `vite build` with `vite-plugin-singlefile` bundles each into a self-contained HTML string. These are read at runtime by `registerAppResource` handlers. + +### Step 10: Lifecycle in main.ts +- MCPServer starts independently alongside PreviewServer during app init +- On `before-quit`: call `MCPServer.cleanup()` (stops server + discards proposals) + +### Step 11: Update TODO.md +- Remove stdio references per user request +- Mark standalone server approach in the doc + +### Step 12: Tests (TDD per CLAUDE.md) +Each step above is preceded by failing tests: + +- **ProposalStore**: create/get/remove, TTL expiry, cleanup +- **MCPServer tools**: tool definitions (names, schemas, annotations) +- **MCPServer resources**: URI resolution, data returned +- **MCPServer prompts**: message structure, arguments +- **Accept/discard**: draft_post→accept publishes, propose_script→discard removes +- **Integration**: HTTP POST to `http://127.0.0.1:{port}/mcp`, tool call, response format +- **Review Views**: `registerAppResource` returns valid HTML with `RESOURCE_MIME_TYPE` + +## Key Files + +| File | Purpose | +|---|---| +| `src/main/engine/ProposalStore.ts` | NEW — in-memory proposal storage | +| `src/main/engine/MCPServer.ts` | NEW — standalone MCP server engine | +| `src/main/mcp-apps/review-post.html` | NEW — post review View | +| `src/main/mcp-apps/review-script.html` | NEW — script review View | +| `src/main/mcp-apps/review-template.html` | NEW — template review View | +| `src/main/mcp-apps/review-metadata.html` | NEW — metadata diff View | +| `src/main/mcp-apps/src/*.ts` | NEW — View scripts using `App` class | +| `src/main/mcp-apps/vite.config.ts` | NEW — builds Views into single HTML files | +| `src/main/main.ts` | MODIFY — start/cleanup MCPServer | +| `tests/engine/ProposalStore.test.ts` | NEW | +| `tests/engine/MCPServer.test.ts` | NEW | +| `tests/engine/MCPServer.integration.test.ts` | NEW | +| `TODO.md` | MODIFY — remove stdio, update transport section | + +## Verification + +1. `npm test` — all tests pass (new + existing) +2. `npm run build` — clean build +3. Manual: start app, connect Claude Code or curl to `http://127.0.0.1:4124/mcp` with MCP protocol, verify tool listing and resource reading +4. Manual: call `draft_post` tool, verify draft created, review View renders inline in host, accept publishes post + +## Unresolved Questions + +None — scope is clear: standalone HTTP server on port 4124, MCP App Views via `@modelcontextprotocol/ext-apps`, no settings UI. diff --git a/TODO.md b/TODO.md index 9e6fa4c..6ca0d73 100644 --- a/TODO.md +++ b/TODO.md @@ -256,13 +256,20 @@ In `PageRenderer` and `BlogGenerationEngine`: --- -## 3. MCP Server +## 3. MCP Server — Agent-Assisted Content Creation ### Goal Host an MCP (Model Context Protocol) server inside the application so external -AI agents (Claude Code, Cursor, etc.) can connect and use bDS tools to query -and manage blog content. +AI agents (Claude Code, Cursor, etc.) can connect and work with bDS. The core +principle: **read access is open, write access goes through user-reviewable +drafts**. External agents never silently mutate data — they propose changes +that the user accepts or discards inside the agent's UI. + +This enables powerful workflows: an agent can read existing posts, draft a new +one with pre-filled content, propose a Python script or Liquid template, or +suggest image metadata improvements — and the user always has final say before +anything enters the system. ### Current State @@ -270,14 +277,93 @@ and manage blog content. tools with full implementations (`getToolDefinitions()`, `executeTool()`). - `PreviewServer` provides the architectural pattern for an in-process HTTP server with lifecycle management. -- No MCP SDK dependency exists. +- Posts already have a `draft` status (DB-only, no disk writes until publish) + — the exact pattern needed for MCP draft posts. +- Scripts and templates are file-first (immediately persisted) — MCP proposals + need an in-memory staging layer before acceptance. +- **`@modelcontextprotocol/sdk` v1.27.1 and `@modelcontextprotocol/ext-apps` + v1.1.2 are installed.** +- **`ProposalStore` engine is implemented and tested (18 tests).** +- **`MCPServer` engine is implemented and tested (37 tests).** Standalone HTTP + server on port 4124, stateless mode (new `McpServer` per request), registers + 5 static resources, 6 resource templates, 8 tools, 3 prompts, and 4 + `ui://` review-app resources. +- **`mcp-views.ts` provides review HTML** for posts, scripts, templates, and + metadata diffs via `@modelcontextprotocol/ext-apps` App class. +- **Lifecycle integrated in `main.ts`** — MCP server starts on app ready and + cleans up on before-quit. + +### Design Principles + +1. **Use all three MCP primitives** — expose blog data via the appropriate + MCP primitive for each use case: + - **Resources** for passive, read-only data (post content, media metadata, + category/tag lists, blog stats). These are application-controlled — the + host decides when to fetch them. + - **Tools** for parameterized actions (search, drafting, proposing changes). + These are model-controlled — the LLM decides when to call them. + - **Prompts** for user-triggered workflow templates (e.g., "draft a blog + post", "audit content quality"). These surface as slash commands in the + host UI. + +2. **Tool annotations** — every tool declares MCP `annotations` to advertise + its behavior to the host: + - Read-only tools: `{ readOnlyHint: true, openWorldHint: false }` + - Proposal tools: `{ readOnlyHint: false, destructiveHint: false }` + - `accept_proposal`: `{ readOnlyHint: false, destructiveHint: false, + idempotentHint: true }` + - `discard_proposal`: `{ readOnlyHint: false, destructiveHint: true, + idempotentHint: true }` + + All annotations are hints — hosts must not make security decisions based + on them alone, but they help hosts choose appropriate UI (e.g., + auto-approving reads, showing confirmation for destructive actions). + +3. **Tool metadata** — every tool includes `title` (human-readable display + name) and `description` (detailed explanation of what it does and when to + use it). Descriptions are critical — they are what the LLM reads to decide + whether and when to call a tool. + +4. **Draft/Propose pattern for writes via MCP Apps** — every mutation goes + through a user-gated flow using the MCP Apps extension: + - Agent calls a `draft_*` or `propose_*` tool. These tools declare a + `_meta.ui.resourceUri` pointing to a review UI (`ui://` resource). + - The host (Claude Desktop, VS Code, etc.) renders the review app as a + sandboxed iframe inline in the conversation. + - The app shows the proposal (post preview, code block, metadata diff) + with accept/discard buttons. The agent LLM is **not** in the loop. + - User clicks accept → the app calls `accept_proposal` tool via the + MCP App bridge (postMessage) → server commits the change. + - User clicks discard → the app calls `discard_proposal` tool → + server cleans up. + - The tool result flows back to the agent so it can continue. + +5. **Aligned with internal editors** — the MCP App review UIs mirror what + the app's own editors show (post metadata + content, script content + + validation, template content + validation, image metadata diff). This + keeps the user experience consistent whether they work inside bDS or + through an external agent. + +6. **Capability negotiation** — during MCP initialization, the server + declares its supported capabilities: `tools`, `resources`, `prompts`. + For MCP Apps, the extension capability `io.modelcontextprotocol/ui` is + negotiated via the `extensions` field. The server checks whether the + client supports the Apps extension and adjusts proposal tool responses + accordingly (structured preview data for Apps-capable hosts, formatted + text for others). + +7. **Input validation and rate limiting** — all tool inputs are validated + at the MCP boundary before forwarding to engine methods. The server + rate-limits tool invocations to prevent abuse. Do not rely solely on + downstream engine validation. ### Implementation Plan #### 3.1 Dependencies -Add `@modelcontextprotocol/sdk` to `package.json`. This provides the standard -MCP server implementation with transport handling. +- `@modelcontextprotocol/sdk` — standard MCP server with transport handling. +- `@modelcontextprotocol/ext-apps` — MCP Apps extension for serving + interactive UI resources. #### 3.2 Engine Class — `MCPServer` @@ -288,72 +374,416 @@ src/main/engine/MCPServer.ts ``` - Constructor accepts dependency injection (engines via getters). -- `start(port)` — create HTTP server implementing MCP protocol, or use stdio - transport for local agent integration. +- `start(port)` — create standalone HTTP server on port 4124 using + `StreamableHTTPServerTransport` in stateless mode. - `stop()` — clean shutdown. -- `getToolDefinitions()` — convert OpenCodeManager's Anthropic-format tool - definitions to MCP schema format. -- `executeTool(name, args)` — delegate to OpenCodeManager's `executeTool()`. +- Manages an in-memory `ProposalStore` for pending proposals (scripts, + templates, metadata changes). Posts use the existing draft mechanism instead. +- Serves `ui://` resources for the MCP App review UIs. +- Exposes three MCP primitive types: + - **Resources** — read-only blog data (posts, media, tags, categories, + stats) accessible via URI-based `resources/read`. + - **Tools** — parameterized actions (search, draft, propose, accept, + discard). Agent-facing tools are visible to the LLM; app-internal + tools (`accept_proposal`, `discard_proposal`) are called only by the + MCP App via the App Bridge. + - **Prompts** — user-triggered workflow templates surfaced as slash + commands in the host. -#### 3.3 Tool Mapping +#### 3.3 Resources (Read-Only Data) -Map the existing OpenCodeManager tools to MCP tools. The tool signatures are -nearly identical between Anthropic tool_use format and MCP — both use JSON -Schema for input definitions. The mapping is mechanical: +Expose blog data as MCP Resources using URI templates. Resources are +application-controlled — the host fetches them for context, they are not +actions. Each resource is registered via `resources/list` and read via +`resources/read`. -| OpenCodeManager Tool | MCP Tool Name | -|------------------------|------------------------| -| search_posts | search_posts | -| read_post | read_post | -| list_posts | list_posts | -| get_media | get_media | -| list_media | list_media | -| update_post_metadata | update_post_metadata | -| update_media_metadata | update_media_metadata | -| list_tags | list_tags | -| list_categories | list_categories | -| get_blog_stats | get_blog_stats | -| view_image | view_image | -| get_post_backlinks | get_post_backlinks | -| get_post_outlinks | get_post_outlinks | -| get_post_media | get_post_media | -| get_media_posts | get_media_posts | +| Resource URI | Source | Description | +|----------------------------------|----------------------------------|------------------------------------| +| `bds://posts/{id}` | OpenCodeManager.read_post | Full post content + metadata | +| `bds://posts` | OpenCodeManager.list_posts | Paginated post list | +| `bds://media/{id}` | OpenCodeManager.get_media | Media item metadata | +| `bds://media` | OpenCodeManager.list_media | Paginated media list | +| `bds://tags` | OpenCodeManager.list_tags | All tags with counts | +| `bds://categories` | OpenCodeManager.list_categories | All categories | +| `bds://stats` | OpenCodeManager.get_blog_stats | Blog-wide statistics | +| `bds://posts/{id}/backlinks` | OpenCodeManager.get_post_backlinks | Posts linking to this post | +| `bds://posts/{id}/outlinks` | OpenCodeManager.get_post_outlinks | Posts this post links to | +| `bds://posts/{id}/media` | OpenCodeManager.get_post_media | Media attached to a post | +| `bds://media/{id}/posts` | OpenCodeManager.get_media_posts | Posts using a media item | +| `bds://media/{id}/image` | OpenCodeManager.view_image | Image binary (for visual context) | -Exclude A2UI render tools (they are UI-specific and not useful for external +Use `bds://` as the custom URI scheme. Parameterized URIs use MCP resource +templates (`resources/templates/list`). Emit `notifications/resources/ +list_changed` when posts, media, or tags are created/updated/deleted so +the host can refresh cached data. + +List resources (`bds://posts`, `bds://media`) support cursor-based +pagination following the MCP pagination spec. The initial response +includes a `nextCursor` if more results exist; the host passes it back +on the next `resources/read` call. + +#### 3.4 Read Tools (Parameterized Queries) + +Not all read operations fit the Resources model. Parameterized queries +that accept complex search criteria are better modeled as tools with +`readOnlyHint: true`. + +| MCP Tool Name | Source | Annotations | +|------------------------|----------------------------------|------------------------------------------| +| search_posts | OpenCodeManager.search_posts | `readOnlyHint: true, openWorldHint: false` | + +Each tool includes a `title`, a `description` explaining when it should +be used, and a JSON Schema `inputSchema`. `search_posts` accepts query +text, filters (status, category, tag, date range), and pagination +parameters (cursor, limit). + +Exclude `update_post_metadata`, `update_media_metadata` (writes go through +proposals), and A2UI render tools (UI-specific, not useful for external agents). -#### 3.4 Transport +#### 3.5 Proposal Tools (Draft-Based Writes) + +These tools stage content for user review. Each declares a `_meta.ui` field +pointing to a review UI resource, so the host renders an MCP App inline in the +conversation. Proposals have a TTL (e.g., 30 min) and are auto-discarded if +not accepted. All proposal tools use annotations +`{ readOnlyHint: false, destructiveHint: false }`. + +##### 3.5.1 `draft_post` + +Creates a draft post using the existing PostEngine draft workflow. + +**Input:** `{ title, content (markdown), excerpt?, tags?, categoryId? }` + +**Tool definition `_meta`:** +```json +{ "ui": { "resourceUri": "ui://bds/review-post" } } +``` + +**Action:** +1. Call `PostEngine.createPost()` with status `draft`. +2. Set tags and category if provided. +3. Return `{ proposalId (= postId), type: 'draftPost', preview: { title, + excerpt, tags, category, content, wordCount } }`. + +**On user accept (via app):** `PostEngine.publishPost(postId)` — post +transitions to published, markdown file is written to disk. + +**On user discard (via app):** `PostEngine.deletePost(postId)` — draft is +removed from the database. + +##### 3.5.2 `propose_script` + +Stages a Python script in memory for review. + +**Input:** `{ title, content (python source), description? }` + +**Tool definition `_meta`:** +```json +{ "ui": { "resourceUri": "ui://bds/review-script" } } +``` + +**Action:** +1. Validate Python syntax (basic parse check). +2. Store in `ProposalStore` with a generated ID. +3. Return `{ proposalId, type: 'script', preview: { title, slug (generated), + description, content, syntaxValid, syntaxErrors? } }`. + +**On user accept (via app):** `ScriptEngine.createScript()` — file + DB entry +created. + +**On user discard (via app):** Remove from `ProposalStore` — nothing was +persisted. + +##### 3.5.3 `propose_template` + +Stages a Liquid template in memory for review. + +**Input:** `{ title, content (liquid source), kind ('post'|'list'|'not-found'| +'partial') }` + +**Tool definition `_meta`:** +```json +{ "ui": { "resourceUri": "ui://bds/review-template" } } +``` + +**Action:** +1. Validate Liquid syntax via `TemplateEngine.validateTemplate()`. +2. Store in `ProposalStore`. +3. Return `{ proposalId, type: 'template', preview: { title, slug, kind, + content, syntaxValid, syntaxErrors? } }`. + +**On user accept (via app):** `TemplateEngine.createTemplate()` — file + DB +entry created. + +**On user discard (via app):** Remove from `ProposalStore`. + +##### 3.5.4 `propose_media_metadata` + +Stages metadata changes for an existing media item. + +**Input:** `{ mediaId, title?, alt?, caption? }` + +**Tool definition `_meta`:** +```json +{ "ui": { "resourceUri": "ui://bds/review-metadata" } } +``` + +**Action:** +1. Load current metadata via `MediaEngine`. +2. Compute diff (current vs proposed for each changed field). +3. Store in `ProposalStore`. +4. Return `{ proposalId, type: 'mediaMetadata', preview: { filename, + diff: { field, current, proposed }[] } }`. + +**On user accept (via app):** Apply changes via `MediaEngine.updateMedia()`. + +**On user discard (via app):** Remove from `ProposalStore`. + +##### 3.5.5 `propose_post_metadata` + +Stages metadata changes for an existing post (title, excerpt, slug, tags). + +**Input:** `{ postId, title?, excerpt?, slug?, tags? }` + +**Tool definition `_meta`:** +```json +{ "ui": { "resourceUri": "ui://bds/review-metadata" } } +``` + +**Action:** +1. Load current post via `PostEngine`. +2. Compute diff. +3. Store in `ProposalStore`. +4. Return `{ proposalId, type: 'postMetadata', preview: { currentTitle, + diff: { field, current, proposed }[] } }`. + +**On user accept (via app):** Apply via `PostEngine.updatePost()`. + +**On user discard (via app):** Remove from `ProposalStore`. + +#### 3.6 App-Internal Tools (Accept / Discard) + +These tools are called by the MCP App (via the App Bridge's `tools/call` +mechanism), **not** by the agent LLM. The host forwards the call from the +sandboxed iframe to the MCP server. They are not listed in `tools/list` +responses to agents. + +##### `accept_proposal` + +**Input:** `{ proposalId }` + +**Annotations:** `{ readOnlyHint: false, destructiveHint: false, +idempotentHint: true }` + +Looks up the proposal type (draftPost, script, template, mediaMetadata, +postMetadata) and executes the commit action: +- draftPost → `PostEngine.publishPost()` +- script → `ScriptEngine.createScript()` +- template → `TemplateEngine.createTemplate()` +- mediaMetadata → `MediaEngine.updateMedia()` +- postMetadata → `PostEngine.updatePost()` + +Returns `{ success, message }`. + +##### `discard_proposal` + +**Input:** `{ proposalId }` + +**Annotations:** `{ readOnlyHint: false, destructiveHint: true, +idempotentHint: true }` + +Executes the cleanup action: +- draftPost → `PostEngine.deletePost()` +- All others → remove from `ProposalStore` + +Returns `{ success, message }`. + +#### 3.7 MCP App Review UIs + +The review UIs are HTML pages served as `ui://` resources by the MCP server. +They render inside the host's sandboxed iframe and use the +`@modelcontextprotocol/ext-apps` App class for bidirectional communication. + +Each review app: +1. Receives the tool result data (proposal preview) from the host. +2. Renders a focused review interface: + - **Post review** (`ui://bds/review-post`): title, metadata fields, + rendered markdown content preview, word count. + - **Script review** (`ui://bds/review-script`): title, syntax-highlighted + Python code, validation status/errors. + - **Template review** (`ui://bds/review-template`): title, kind badge, + syntax-highlighted Liquid code, validation status/errors. + - **Metadata review** (`ui://bds/review-metadata`): side-by-side diff of + current vs proposed values for each changed field. +3. Shows accept and discard buttons. +4. On user action, calls `accept_proposal` or `discard_proposal` via the + App Bridge's `tools/call`. +5. Updates its UI to show the outcome ("Published", "Created", "Discarded"). + +These apps are small, self-contained HTML pages — the "slim MCP apps" concept. +They can share a common layout/style and differ only in the content they +render. Since the host provides the sandboxing, the apps don't need their own +authentication or security layer. + +The review UIs should visually align with what the bDS internal editors show, +so the user experience is consistent. Where practical, share CSS/component +patterns between the bDS renderer UI and the MCP App review UIs. + +#### 3.8 ProposalStore + +In-memory store for pending proposals (not posts — those use the DB draft +mechanism): + +```typescript +interface Proposal { + id: string; + type: 'script' | 'template' | 'mediaMetadata' | 'postMetadata'; + data: Record; // type-specific payload + createdAt: number; + ttlMs: number; // default 30 minutes +} +``` + +- Simple `Map` with periodic cleanup of expired entries. +- On app shutdown, all pending proposals are discarded (they were never + committed). +- No persistence needed — proposals are ephemeral by design. + +For draft posts, the `proposalId` is the post ID itself. The `ProposalStore` +tracks a mapping from `proposalId → 'draftPost'` so the accept/discard +tools know to call PostEngine rather than look in the store. + +#### 3.9 Transport Support two transports: - **stdio** — for local integration (agent runs `bds --mcp` or connects via - named pipe). This is the standard for MCP in coding agents. -- **HTTP/SSE** — for network access, running alongside PreviewServer on a - different port (e.g., 5174). + named pipe). This is the standard for MCP in coding agents. Credentials + come from the environment. +- **Streamable HTTP** — for network access, running alongside PreviewServer + on a different port (e.g., 5174). Uses the current MCP Streamable HTTP + transport: a single HTTP endpoint that accepts JSON-RPC POST requests and + responds with either `application/json` (single response) or + `text/event-stream` (SSE stream for multiple messages). Supports session + management via `Mcp-Session-Id` headers and requires + `MCP-Protocol-Version` headers after initialization. Start with stdio since that is what Claude Code and Cursor use. -#### 3.5 Lifecycle Integration +**Security requirements for Streamable HTTP:** + +- Bind to `127.0.0.1` (localhost only) when running locally. +- Validate the `Origin` header on all requests to prevent DNS rebinding + attacks. +- Use cryptographically random, non-deterministic session IDs. +- Implement a session token or shared secret for authentication (generated + on server start, displayed in the settings UI for the user to configure + in their agent). +- Rate-limit incoming requests. +- Set appropriate timeouts for tool invocations. + +#### 3.10 Lifecycle Integration In `main.ts`: - Initialize `MCPServer` in `initialize()`. - Start alongside `PreviewServer` in `app.whenReady()`. -- Stop in `before-quit` handler. +- Stop in `before-quit` handler (discard all pending proposals). - Respect active project context (tools operate on the active project). -#### 3.6 Configuration +#### 3.11 Configuration In `SettingsView`, add an "MCP Server" section: - Enable/disable toggle. - Port number (for HTTP transport). - Show connection instructions (stdio command or URL). +- Proposal TTL setting (default 30 min). -#### 3.7 Testing +#### 3.12 MCP Prompts (Workflow Templates) + +Expose MCP Prompts for common agent workflows. Prompts are user-controlled — +they surface as slash commands or command palette entries in the host and +produce pre-structured messages that guide the LLM. + +| Prompt Name | Arguments | Description | +|------------------------|----------------------------|--------------------------------------| +| `draft-blog-post` | `topic?`, `category?` | Guides agent through reading existing posts, understanding the blog's style, and drafting a new post on the given topic | +| `improve-media-metadata` | `scope` (`all`\|`missing`) | Guides agent through reviewing media items and proposing alt text, captions, and titles | +| `content-audit` | `category?` | Guides agent through reviewing posts for quality, broken links, missing metadata, and suggesting fixes | + +Each prompt returns a structured message array (system + user messages) that +sets up the LLM with context about the blog and clear instructions for the +workflow. The host renders the prompt as a one-click action. + +This is a lower-priority addition — prompts enhance discoverability but the +core read/write flow works without them. + +#### 3.13 Example Workflows + +**Agent creates a blog post:** +1. Agent LLM reads `bds://categories` and `bds://tags` resources to + understand the blog. +2. Agent LLM calls `draft_post({ title: "...", content: "...", tags: [...] })`. +3. MCP server creates draft post (DB only), returns preview data. +4. Host sees `_meta.ui.resourceUri`, fetches `ui://bds/review-post`, renders + sandboxed iframe inline in conversation. +5. Review app shows the full post preview with accept/discard buttons. +6. User clicks accept → app calls `accept_proposal` via App Bridge → + server publishes post → app shows "Published" → agent is informed. + +**Agent writes a Python script:** +1. Agent LLM reads `bds://posts` resource to understand the content model. +2. Agent LLM calls `propose_script({ title: "Tag Cleanup", content: "..." })`. +3. MCP server validates syntax, stages in ProposalStore, returns preview. +4. Host renders `ui://bds/review-script` — shows syntax-highlighted code + with validation results. +5. User reviews code, clicks accept → app calls `accept_proposal` → + server creates script via ScriptEngine. + +**Agent suggests image metadata:** +1. Agent LLM reads `bds://media/{id}/image` resource to see the image. +2. Agent LLM calls `propose_media_metadata({ mediaId, alt: "...", + caption: "..." })`. +3. MCP server computes diff, stages in ProposalStore, returns diff preview. +4. Host renders `ui://bds/review-metadata` — shows current vs proposed diff. +5. User reviews diff, clicks accept → metadata is updated. + +#### 3.14 MCP Apps Client Support + +MCP Apps are currently supported by Claude (claude.ai), Claude Desktop, +VS Code GitHub Copilot, Goose, Postman, and MCPJam. For hosts that don't +support MCP Apps, the proposal tools still return structured text content +that the agent can present as formatted text — the user would then need +to instruct the agent to accept or discard via conversation, falling back +to a simpler flow. + +For hosts without Apps support, the MCP **Elicitation** primitive +(`elicitation/request`) can serve as a lighter-weight confirmation +mechanism — the server asks the user to confirm or reject a proposal via +a simple dialog rather than a full review UI. This requires the host to +support the `elicitation` capability. When neither Apps nor Elicitation is +available, fall back to text-based accept/discard in the conversation. + +#### 3.15 Testing - Unit tests for tool definition mapping (Anthropic → MCP format). +- Unit tests for resource URI resolution and resource template listing. +- Unit tests for `ProposalStore` (create, accept, discard, TTL expiry). +- Unit tests for accept/discard tool handlers. +- Unit tests for tool annotations (verify correct hints on each tool). +- Unit tests for prompt templates (verify message structure and arguments). +- Integration tests: draft_post → app accept flow, propose_script → app + discard flow. - Integration tests: start MCP server, send tool calls, verify responses. +- Integration tests: capability negotiation (with/without Apps extension). +- Integration tests: resource read, resource list with pagination, resource + change notifications. +- MCP App UI tests: render review apps, simulate user actions, verify + tool calls through App Bridge. +- Security tests: Origin header validation, session management, rate + limiting (for Streamable HTTP transport). - Follow existing engine test patterns with mocked dependencies. --- diff --git a/package-lock.json b/package-lock.json index d7f670b..b24bfcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,8 @@ "@milkdown/plugin-trailing": "^7.18.0", "@milkdown/react": "^7.18.0", "@milkdown/theme-nord": "^7.18.0", + "@modelcontextprotocol/ext-apps": "^1.1.2", + "@modelcontextprotocol/sdk": "^1.27.1", "@monaco-editor/react": "^4.7.0", "@picocss/pico": "^2.1.1", "@xmldom/xmldom": "^0.8.11", @@ -2654,6 +2656,18 @@ "node": ">=12.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -4555,6 +4569,111 @@ "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", "license": "BSD-2-Clause" }, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.1.2.tgz", + "integrity": "sha512-Gx4TEo3/F8yq1Ix6LdgLwMrKqfZqD7++eakZdbMUewrYtHeeJn3nKpeNhgEfO7nYRwonqWYomOAszWZWJS0IbA==", + "hasInstallScript": true, + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "^1.2.21", + "@oven/bun-darwin-x64": "^1.2.21", + "@oven/bun-darwin-x64-baseline": "^1.2.21", + "@oven/bun-linux-aarch64": "^1.2.21", + "@oven/bun-linux-aarch64-musl": "^1.2.21", + "@oven/bun-linux-x64": "^1.2.21", + "@oven/bun-linux-x64-baseline": "^1.2.21", + "@oven/bun-linux-x64-musl": "^1.2.21", + "@oven/bun-linux-x64-musl-baseline": "^1.2.21", + "@oven/bun-windows-x64": "^1.2.21", + "@oven/bun-windows-x64-baseline": "^1.2.21", + "@rollup/rollup-darwin-arm64": "^4.53.3", + "@rollup/rollup-darwin-x64": "^4.53.3", + "@rollup/rollup-linux-arm64-gnu": "^4.53.3", + "@rollup/rollup-linux-x64-gnu": "^4.53.3", + "@rollup/rollup-win32-arm64-msvc": "^4.53.3", + "@rollup/rollup-win32-x64-msvc": "^4.53.3" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.24.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "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/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "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/@modelcontextprotocol/sdk/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/@monaco-editor/loader": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", @@ -4643,6 +4762,149 @@ "url": "https://github.com/sponsors/ocavue" } }, + "node_modules/@oven/bun-darwin-aarch64": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.10.tgz", + "integrity": "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.10.tgz", + "integrity": "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64-baseline": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.10.tgz", + "integrity": "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-linux-aarch64": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.10.tgz", + "integrity": "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-aarch64-musl": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.10.tgz", + "integrity": "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.10.tgz", + "integrity": "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-baseline": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.10.tgz", + "integrity": "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.10.tgz", + "integrity": "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl-baseline": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.10.tgz", + "integrity": "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-windows-x64": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.10.tgz", + "integrity": "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oven/bun-windows-x64-baseline": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.10.tgz", + "integrity": "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@picocss/pico": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@picocss/pico/-/pico-2.1.1.tgz", @@ -4727,7 +4989,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4741,7 +5002,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4811,7 +5071,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4937,7 +5196,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4993,7 +5251,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5035,7 +5292,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6085,6 +6341,44 @@ "node": "^18.17.0 || >=20.5.0" } }, + "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/accepts/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/accepts/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/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -6141,7 +6435,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -6159,7 +6452,6 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -6176,7 +6468,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, "node_modules/ajv-keywords": { @@ -6680,6 +6971,46 @@ "readable-stream": "^3.4.0" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "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/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -6863,6 +7194,15 @@ "node": ">= 10.0.0" } }, + "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/cacache": { "version": "19.0.1", "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", @@ -6984,6 +7324,22 @@ "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/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -7387,6 +7743,28 @@ "node": ">=10" } }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "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/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -7394,6 +7772,24 @@ "dev": true, "license": "MIT" }, + "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/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -7402,6 +7798,23 @@ "license": "MIT", "optional": true }, + "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/cpu-features": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", @@ -7514,7 +7927,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -7786,6 +8198,15 @@ "node": ">=0.4.0" } }, + "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/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -8232,6 +8653,12 @@ "dev": true, "license": "MIT" }, + "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/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -8476,6 +8903,15 @@ "dev": true, "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/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -8642,6 +9078,12 @@ "node": ">=6" } }, + "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/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -8886,6 +9328,36 @@ "node": ">=0.10.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.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -8903,6 +9375,101 @@ "dev": true, "license": "Apache-2.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.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/express/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/express/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/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -8977,7 +9544,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, "funding": [ { "type": "github", @@ -9112,6 +9678,27 @@ "node": ">=10" } }, + "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/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -9229,6 +9816,24 @@ "node": ">=12.20.0" } }, + "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/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -9678,6 +10283,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/hono": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", + "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -9738,6 +10353,26 @@ "dev": true, "license": "BSD-2-Clause" }, + "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/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -9905,7 +10540,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ip-address": { @@ -9918,6 +10552,15 @@ "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-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", @@ -9989,6 +10632,12 @@ "dev": true, "license": "MIT" }, + "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/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -10019,7 +10668,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -10137,6 +10785,15 @@ "node": ">= 20" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-base64": { "version": "3.7.8", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", @@ -10235,7 +10892,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -10881,6 +11537,15 @@ "dev": true, "license": "CC0-1.0" }, + "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/memfs": { "version": "4.56.10", "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.56.10.tgz", @@ -10917,6 +11582,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "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/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -11865,7 +12542,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -12075,6 +12751,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -12097,11 +12794,22 @@ ], "license": "MIT" }, + "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==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -12269,6 +12977,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=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-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -12293,7 +13010,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12323,6 +13039,16 @@ "dev": true, "license": "ISC" }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "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", @@ -12372,6 +13098,15 @@ "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/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -12791,6 +13526,19 @@ } } }, + "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/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -12832,6 +13580,21 @@ "node": ">=18.0.0" } }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -12845,6 +13608,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "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/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -13146,7 +13949,6 @@ "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==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13328,6 +14130,22 @@ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", "license": "MIT" }, + "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/rsyncwrapper": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/rsyncwrapper/-/rsyncwrapper-3.1.0.tgz", @@ -13444,6 +14262,57 @@ "license": "MIT", "optional": true }, + "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/send/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/send/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/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", @@ -13475,6 +14344,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -13535,7 +14429,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -13548,7 +14441,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13567,6 +14459,78 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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", @@ -13791,6 +14755,15 @@ "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", "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", @@ -14196,6 +15169,15 @@ "tmp": "^0.2.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/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -14848,6 +15830,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/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/type-is/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/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -15016,6 +16037,15 @@ "node": ">= 4.0.0" } }, + "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/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -15103,6 +16133,15 @@ "url": "https://buymeacoffee.com/uvarov" } }, + "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/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", @@ -15912,7 +16951,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -15992,7 +17030,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -16118,10 +17155,20 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, "node_modules/zustand": { "version": "5.0.11", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", diff --git a/package.json b/package.json index 8fe85da..6bfc294 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,8 @@ "@milkdown/plugin-trailing": "^7.18.0", "@milkdown/react": "^7.18.0", "@milkdown/theme-nord": "^7.18.0", + "@modelcontextprotocol/ext-apps": "^1.1.2", + "@modelcontextprotocol/sdk": "^1.27.1", "@monaco-editor/react": "^4.7.0", "@picocss/pico": "^2.1.1", "@xmldom/xmldom": "^0.8.11", diff --git a/src/main/engine/MCPServer.ts b/src/main/engine/MCPServer.ts new file mode 100644 index 0000000..ef90617 --- /dev/null +++ b/src/main/engine/MCPServer.ts @@ -0,0 +1,690 @@ +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + registerAppTool, + registerAppResource, + RESOURCE_MIME_TYPE, +} from '@modelcontextprotocol/ext-apps/server'; +import { createServer as createHttpServer, type Server } from 'http'; +import { z } from 'zod'; +import { ProposalStore, type ProposalType } from './ProposalStore'; +import { + reviewPostHtml, + reviewScriptHtml, + reviewTemplateHtml, + reviewMetadataHtml, +} from './mcp-views'; + +// ── Dependency contracts ────────────────────────────────────────────── + +interface PostEngineContract { + getAllPosts: (options?: { limit?: number; offset?: number }) => Promise<{ items: Array>; hasMore: boolean; total: number }>; + getPost: (id: string) => Promise | null>; + searchPosts: (query: string) => Promise>; + createPost: (data: Record) => Promise>; + updatePost: (id: string, data: Record) => Promise | null>; + publishPost: (id: string) => Promise | null>; + deletePost: (id: string) => Promise; + getTagsWithCounts: () => Promise>; + getCategoriesWithCounts: () => Promise>; + getBlogStats: () => Promise>; + getLinkedBy: (postId: string) => Promise>; + getLinksTo: (postId: string) => Promise>; + getPostsFiltered: (filter: Record) => Promise>>; +} + +interface MediaEngineContract { + getAllMedia: () => Promise>>; + getMedia: (id: string) => Promise | null>; + updateMedia: (id: string, data: Record) => Promise | null>; +} + +interface ScriptEngineContract { + createScript: (input: Record) => Promise>; +} + +interface TemplateEngineContract { + createTemplate: (input: Record) => Promise>; +} + +interface MetaEngineContract { + getProjectMetadata: () => Promise | null>; +} + +interface PostMediaEngineContract { + getLinkedMediaDataForPost: (postId: string) => Promise>>; + getLinkedPostsForMedia: (mediaId: string) => Promise>>; +} + +interface TagEngineContract { + getTagsWithCounts: () => Promise>>; +} + +export interface MCPServerDependencies { + getPostEngine: () => PostEngineContract; + getMediaEngine: () => MediaEngineContract; + getScriptEngine: () => ScriptEngineContract; + getTemplateEngine: () => TemplateEngineContract; + getMetaEngine: () => MetaEngineContract; + getPostMediaEngine: () => PostMediaEngineContract; + getTagEngine: () => TagEngineContract; +} + +// ── MCPServer engine ────────────────────────────────────────────────── + +export class MCPServer { + readonly proposalStore: ProposalStore; + private readonly deps: MCPServerDependencies; + private httpServer: Server | null = null; + private port: number | null = null; + + constructor(deps: MCPServerDependencies) { + this.deps = deps; + this.proposalStore = new ProposalStore(); + } + + /** Create a fresh McpServer with all tools/resources/prompts registered (stateless — one per request). */ + createMcpServer(): McpServer { + const server = new McpServer({ + name: 'Blogging Desktop Server', + version: '1.0.0', + }); + + this.registerResources(server); + this.registerResourceTemplates(server); + this.registerReadTools(server); + this.registerProposalTools(server); + this.registerAcceptDiscardTools(server); + this.registerPrompts(server); + + return server; + } + + // ── Server lifecycle ──────────────────────────────────────────────── + + async start(preferredPort = 4124): Promise { + if (this.httpServer && this.port !== null) { + return this.port; + } + + const app = createHttpServer(async (req, res) => { + // CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id'); + res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + const mcpServer = this.createMcpServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // stateless + }); + + res.on('close', () => { + transport.close().catch(() => {}); + mcpServer.close().catch(() => {}); + }); + + try { + await mcpServer.connect(transport); + await transport.handleRequest(req, res, await parseBody(req)); + } catch (error) { + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal server error' }, + id: null, + })); + } + } + }); + + await new Promise((resolve, reject) => { + app.once('error', reject); + app.listen(preferredPort, '127.0.0.1', () => { + app.off('error', reject); + resolve(); + }); + }); + + this.httpServer = app; + const address = app.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to get MCP server address'); + } + this.port = address.port; + return this.port; + } + + async stop(): Promise { + if (!this.httpServer) { + this.port = null; + return; + } + + await new Promise((resolve, reject) => { + if (!this.httpServer) { resolve(); return; } + this.httpServer.close((error) => { + if (error) { reject(error); return; } + resolve(); + }); + }); + + this.httpServer = null; + this.port = null; + } + + async cleanup(): Promise { + this.proposalStore.destroy(); + await this.stop(); + } + + getPort(): number | null { + return this.port; + } + + // ── Accept / Discard ──────────────────────────────────────────────── + + async acceptProposal(proposalId: string): Promise<{ success: boolean; message: string }> { + const proposal = this.proposalStore.get(proposalId); + if (!proposal) { + return { success: false, message: `Proposal ${proposalId} not found.` }; + } + + try { + switch (proposal.type) { + case 'draftPost': { + const postId = proposal.data.postId as string; + await this.deps.getPostEngine().publishPost(postId); + break; + } + case 'proposeScript': { + await this.deps.getScriptEngine().createScript(proposal.data); + break; + } + case 'proposeTemplate': { + await this.deps.getTemplateEngine().createTemplate(proposal.data); + break; + } + case 'proposeMediaMetadata': { + const mediaId = proposal.data.mediaId as string; + const changes = proposal.data.changes as Record; + await this.deps.getMediaEngine().updateMedia(mediaId, changes); + break; + } + case 'proposePostMetadata': { + const postId = proposal.data.postId as string; + const changes = proposal.data.changes as Record; + await this.deps.getPostEngine().updatePost(postId, changes); + break; + } + } + this.proposalStore.remove(proposalId); + return { success: true, message: `Proposal ${proposalId} accepted.` }; + } catch (error) { + return { success: false, message: `Failed to accept proposal: ${error instanceof Error ? error.message : String(error)}` }; + } + } + + async discardProposal(proposalId: string): Promise<{ success: boolean; message: string }> { + const proposal = this.proposalStore.get(proposalId); + if (!proposal) { + return { success: false, message: `Proposal ${proposalId} not found.` }; + } + + try { + if (proposal.type === 'draftPost') { + const postId = proposal.data.postId as string; + await this.deps.getPostEngine().deletePost(postId); + } + this.proposalStore.remove(proposalId); + return { success: true, message: `Proposal ${proposalId} discarded.` }; + } catch (error) { + return { success: false, message: `Failed to discard proposal: ${error instanceof Error ? error.message : String(error)}` }; + } + } + + // ── Resource registration ────────────────────────────────────────── + + private registerResources(server: McpServer): void { + server.registerResource('posts', 'bds://posts', { description: 'All blog posts' }, async () => { + const result = await this.deps.getPostEngine().getAllPosts(); + return { contents: [{ uri: 'bds://posts', mimeType: 'application/json', text: JSON.stringify(result) }] }; + }); + + server.registerResource('media', 'bds://media', { description: 'All media files' }, async () => { + const result = await this.deps.getMediaEngine().getAllMedia(); + return { contents: [{ uri: 'bds://media', mimeType: 'application/json', text: JSON.stringify(result) }] }; + }); + + server.registerResource('tags', 'bds://tags', { description: 'Tags with post counts' }, async () => { + const result = await this.deps.getPostEngine().getTagsWithCounts(); + return { contents: [{ uri: 'bds://tags', mimeType: 'application/json', text: JSON.stringify(result) }] }; + }); + + server.registerResource('categories', 'bds://categories', { description: 'Categories with post counts' }, async () => { + const result = await this.deps.getPostEngine().getCategoriesWithCounts(); + return { contents: [{ uri: 'bds://categories', mimeType: 'application/json', text: JSON.stringify(result) }] }; + }); + + server.registerResource('stats', 'bds://stats', { description: 'Blog statistics' }, async () => { + const result = await this.deps.getPostEngine().getBlogStats(); + return { contents: [{ uri: 'bds://stats', mimeType: 'application/json', text: JSON.stringify(result) }] }; + }); + } + + private registerResourceTemplates(server: McpServer): void { + server.registerResource('post', new ResourceTemplate('bds://posts/{id}', { list: undefined }), { description: 'A single post by ID' }, async (uri, { id }) => { + const result = await this.deps.getPostEngine().getPost(id as string); + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] }; + }); + + server.registerResource('media-item', new ResourceTemplate('bds://media/{id}', { list: undefined }), { description: 'A single media item by ID' }, async (uri, { id }) => { + const result = await this.deps.getMediaEngine().getMedia(id as string); + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] }; + }); + + server.registerResource('post-backlinks', new ResourceTemplate('bds://posts/{id}/backlinks', { list: undefined }), { description: 'Posts linking to this post' }, async (uri, { id }) => { + const result = await this.deps.getPostEngine().getLinkedBy(id as string); + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] }; + }); + + server.registerResource('post-outlinks', new ResourceTemplate('bds://posts/{id}/outlinks', { list: undefined }), { description: 'Posts this post links to' }, async (uri, { id }) => { + const result = await this.deps.getPostEngine().getLinksTo(id as string); + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] }; + }); + + server.registerResource('post-media', new ResourceTemplate('bds://posts/{id}/media', { list: undefined }), { description: 'Media linked to a post' }, async (uri, { id }) => { + const result = await this.deps.getPostMediaEngine().getLinkedMediaDataForPost(id as string); + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] }; + }); + + server.registerResource('media-posts', new ResourceTemplate('bds://media/{id}/posts', { list: undefined }), { description: 'Posts linked to a media item' }, async (uri, { id }) => { + const result = await this.deps.getPostMediaEngine().getLinkedPostsForMedia(id as string); + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] }; + }); + } + + // ── Tool registration ────────────────────────────────────────────── + + private registerReadTools(server: McpServer): void { + server.registerTool('search_posts', { + title: 'Search Posts', + description: 'Search blog posts by query, category, tags, or date range.', + inputSchema: { + query: z.string().optional().describe('Full-text search query'), + category: z.string().optional().describe('Filter by category'), + tags: z.array(z.string()).optional().describe('Filter by tags'), + year: z.number().optional().describe('Filter by year'), + month: z.number().optional().describe('Filter by month (1-12)'), + status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by status'), + offset: z.number().optional().describe('Pagination offset'), + limit: z.number().optional().describe('Max results to return'), + }, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, async (args) => { + if (args.query) { + const results = await this.deps.getPostEngine().searchPosts(args.query); + return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] }; + } + const filter: Record = {}; + if (args.category) filter.categories = [args.category]; + if (args.tags) filter.tags = args.tags; + if (args.year) filter.year = args.year; + if (args.month) filter.month = args.month; + if (args.status) filter.status = args.status; + const results = await this.deps.getPostEngine().getPostsFiltered(filter); + return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] }; + }); + } + + private registerProposalTools(server: McpServer): void { + // ── draft_post ── + registerAppTool(server, 'draft_post', { + title: 'Draft Post', + description: 'Create a new draft blog post for review before publishing.', + inputSchema: { + title: z.string().describe('Post title'), + content: z.string().describe('Post content in Markdown'), + excerpt: z.string().optional().describe('Short excerpt/summary'), + tags: z.array(z.string()).optional().describe('Tags for the post'), + categories: z.array(z.string()).optional().describe('Categories for the post'), + author: z.string().optional().describe('Post author name'), + }, + _meta: { ui: { resourceUri: 'ui://bds/review-post' } }, + }, async (args: { title: string; content: string; excerpt?: string; tags?: string[]; categories?: string[]; author?: string }) => { + const post = await this.deps.getPostEngine().createPost({ + title: args.title, + content: args.content, + excerpt: args.excerpt, + tags: args.tags ?? [], + categories: args.categories ?? [], + author: args.author, + status: 'draft', + }); + const proposalId = this.proposalStore.create('draftPost', { postId: (post as Record).id }); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, post }) }], + }; + }); + + // ── propose_script ── + registerAppTool(server, 'propose_script', { + title: 'Propose Script', + description: 'Propose a new Python script (macro, utility, or transform) for review.', + inputSchema: { + title: z.string().describe('Script title'), + kind: z.enum(['macro', 'utility', 'transform']).describe('Script type'), + content: z.string().describe('Python source code'), + entrypoint: z.string().optional().describe('Entry point function name'), + }, + _meta: { ui: { resourceUri: 'ui://bds/review-script' } }, + }, async (args: { title: string; kind: 'macro' | 'utility' | 'transform'; content: string; entrypoint?: string }) => { + const proposalId = this.proposalStore.create('proposeScript', { + title: args.title, + kind: args.kind, + content: args.content, + entrypoint: args.entrypoint, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length } }) }], + }; + }); + + // ── propose_template ── + registerAppTool(server, 'propose_template', { + title: 'Propose Template', + description: 'Propose a new Liquid template for review.', + inputSchema: { + title: z.string().describe('Template title'), + kind: z.enum(['post', 'list', 'not-found', 'partial']).describe('Template type'), + content: z.string().describe('Liquid template content'), + }, + _meta: { ui: { resourceUri: 'ui://bds/review-template' } }, + }, async (args: { title: string; kind: 'post' | 'list' | 'not-found' | 'partial'; content: string }) => { + const proposalId = this.proposalStore.create('proposeTemplate', { + title: args.title, + kind: args.kind, + content: args.content, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length } }) }], + }; + }); + + // ── propose_media_metadata ── + registerAppTool(server, 'propose_media_metadata', { + title: 'Propose Media Metadata', + description: 'Propose metadata changes for a media item (alt text, caption, title, tags).', + inputSchema: { + mediaId: z.string().describe('ID of the media item to update'), + alt: z.string().optional().describe('New alt text'), + caption: z.string().optional().describe('New caption'), + title: z.string().optional().describe('New title'), + tags: z.array(z.string()).optional().describe('New tags'), + }, + _meta: { ui: { resourceUri: 'ui://bds/review-metadata' } }, + }, async (args: { mediaId: string; alt?: string; caption?: string; title?: string; tags?: string[] }) => { + const { mediaId, ...changes } = args; + const current = await this.deps.getMediaEngine().getMedia(mediaId); + const proposalId = this.proposalStore.create('proposeMediaMetadata', { + mediaId, + changes, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, current, proposed: changes }) }], + }; + }); + + // ── propose_post_metadata ── + registerAppTool(server, 'propose_post_metadata', { + title: 'Propose Post Metadata', + description: 'Propose metadata changes for a post (title, excerpt, tags, categories).', + inputSchema: { + postId: z.string().describe('ID of the post to update'), + title: z.string().optional().describe('New title'), + excerpt: z.string().optional().describe('New excerpt'), + tags: z.array(z.string()).optional().describe('New tags'), + categories: z.array(z.string()).optional().describe('New categories'), + }, + _meta: { ui: { resourceUri: 'ui://bds/review-metadata' } }, + }, async (args: { postId: string; title?: string; excerpt?: string; tags?: string[]; categories?: string[] }) => { + const { postId, ...changes } = args; + const current = await this.deps.getPostEngine().getPost(postId); + const proposalId = this.proposalStore.create('proposePostMetadata', { + postId, + changes, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, current, proposed: changes }) }], + }; + }); + + // ── Register ui:// resources for review Views ── + this.registerReviewResources(server); + } + + private registerReviewResources(server: McpServer): void { + registerAppResource(server, 'Post Review', 'ui://bds/review-post', { + description: 'Interactive review UI for draft posts', + }, async () => ({ + contents: [{ uri: 'ui://bds/review-post', mimeType: RESOURCE_MIME_TYPE, text: reviewPostHtml() }], + })); + + registerAppResource(server, 'Script Review', 'ui://bds/review-script', { + description: 'Interactive review UI for proposed scripts', + }, async () => ({ + contents: [{ uri: 'ui://bds/review-script', mimeType: RESOURCE_MIME_TYPE, text: reviewScriptHtml() }], + })); + + registerAppResource(server, 'Template Review', 'ui://bds/review-template', { + description: 'Interactive review UI for proposed templates', + }, async () => ({ + contents: [{ uri: 'ui://bds/review-template', mimeType: RESOURCE_MIME_TYPE, text: reviewTemplateHtml() }], + })); + + registerAppResource(server, 'Metadata Review', 'ui://bds/review-metadata', { + description: 'Interactive review UI for proposed metadata changes', + }, async () => ({ + contents: [{ uri: 'ui://bds/review-metadata', mimeType: RESOURCE_MIME_TYPE, text: reviewMetadataHtml() }], + })); + } + + private registerAcceptDiscardTools(server: McpServer): void { + server.registerTool('accept_proposal', { + title: 'Accept Proposal', + description: 'Accept a pending proposal, committing the proposed change.', + inputSchema: { + proposalId: z.string().describe('ID of the proposal to accept'), + }, + annotations: { idempotentHint: true }, + }, async (args) => { + const result = await this.acceptProposal(args.proposalId); + return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; + }); + + server.registerTool('discard_proposal', { + title: 'Discard Proposal', + description: 'Discard a pending proposal, rolling back any draft changes.', + inputSchema: { + proposalId: z.string().describe('ID of the proposal to discard'), + }, + annotations: { idempotentHint: true }, + }, async (args) => { + const result = await this.discardProposal(args.proposalId); + return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; + }); + } + + // ── Prompt registration ──────────────────────────────────────────── + + private registerPrompts(server: McpServer): void { + server.registerPrompt('draft-blog-post', { + title: 'Draft Blog Post', + description: 'Guides you through drafting a new blog post with proper structure and metadata.', + argsSchema: { + topic: z.string().optional().describe('Topic or title idea for the post'), + category: z.string().optional().describe('Category to write for'), + }, + }, async (args) => ({ + messages: [{ + role: 'user', + content: { + type: 'text', + text: buildDraftPostPrompt(args.topic, args.category), + }, + }], + })); + + server.registerPrompt('improve-media-metadata', { + title: 'Improve Media Metadata', + description: 'Guides you through reviewing and improving media metadata (alt text, captions).', + argsSchema: { + scope: z.enum(['all', 'missing-alt', 'missing-caption']).optional().describe('Which media items to review'), + }, + }, async (args) => ({ + messages: [{ + role: 'user', + content: { + type: 'text', + text: buildMediaMetadataPrompt(args.scope), + }, + }], + })); + + server.registerPrompt('content-audit', { + title: 'Content Audit', + description: 'Guides you through auditing blog content for quality, SEO, and consistency.', + argsSchema: { + category: z.string().optional().describe('Category to audit (omit for all)'), + }, + }, async (args) => ({ + messages: [{ + role: 'user', + content: { + type: 'text', + text: buildContentAuditPrompt(args.category), + }, + }], + })); + } +} + +// ── Prompt builders ───────────────────────────────────────────────── + +function buildDraftPostPrompt(topic?: string, category?: string): string { + const parts = [ + 'You are helping draft a blog post for a Blogging Desktop Server instance.', + '', + 'Steps:', + '1. Read the existing content using the `bds://posts`, `bds://tags`, and `bds://categories` resources to understand the blog\'s style and topics.', + '2. Draft a new post with a compelling title, well-structured Markdown content, appropriate tags and categories.', + '3. Use the `draft_post` tool to create the draft for the user to review.', + '', + ]; + if (topic) parts.push(`Suggested topic: ${topic}`); + if (category) parts.push(`Target category: ${category}`); + parts.push('', 'Ensure the post matches the existing blog style and quality standards.'); + return parts.join('\n'); +} + +function buildMediaMetadataPrompt(scope?: string): string { + const parts = [ + 'You are helping improve media metadata in a Blogging Desktop Server instance.', + '', + 'Steps:', + '1. Read the `bds://media` resource to see all media items.', + '2. Review each item\'s alt text, caption, and title.', + ]; + if (scope === 'missing-alt') { + parts.push('3. Focus on items that are missing alt text.'); + } else if (scope === 'missing-caption') { + parts.push('3. Focus on items that are missing captions.'); + } else { + parts.push('3. Review all items for completeness and quality.'); + } + parts.push( + '4. For each item that needs improvement, use `propose_media_metadata` to suggest changes.', + '', + 'Write descriptive, accessible alt text and informative captions.', + ); + return parts.join('\n'); +} + +function buildContentAuditPrompt(category?: string): string { + const parts = [ + 'You are helping audit blog content in a Blogging Desktop Server instance.', + '', + 'Steps:', + '1. Read the blog content using `bds://posts`, `bds://stats`, `bds://tags`, and `bds://categories` resources.', + ]; + if (category) { + parts.push(`2. Focus your audit on posts in the "${category}" category.`); + } else { + parts.push('2. Review all posts across all categories.'); + } + parts.push( + '3. Check for:', + ' - Posts with missing or poor excerpts', + ' - Inconsistent tagging or categorization', + ' - Posts that could benefit from better titles', + ' - Orphaned posts with no backlinks or outlinks', + '4. Use `propose_post_metadata` for any metadata improvements you recommend.', + '', + 'Provide a summary of your findings and the proposals you created.', + ); + return parts.join('\n'); +} + +// ── Helpers ───────────────────────────────────────────────────────── + +function parseBody(req: { on: (event: string, cb: (data: unknown) => void) => void }): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: unknown) => chunks.push(chunk as Buffer)); + req.on('end', () => { + try { + const body = Buffer.concat(chunks).toString('utf-8'); + resolve(body ? JSON.parse(body) : undefined); + } catch (e) { + reject(e); + } + }); + req.on('error', reject); + }); +} + +// ── Singleton ─────────────────────────────────────────────────────── + +let mcpServerInstance: MCPServer | null = null; + +export function getMCPServer(deps?: MCPServerDependencies): MCPServer { + if (!mcpServerInstance) { + if (!deps) { + // Import singletons lazily to avoid circular deps + const { getPostEngine } = require('./PostEngine'); + const { getMediaEngine } = require('./MediaEngine'); + const { getScriptEngine } = require('./ScriptEngine'); + const { getTemplateEngine } = require('./TemplateEngine'); + const { getMetaEngine } = require('./MetaEngine'); + const { getPostMediaEngine } = require('./PostMediaEngine'); + const { getTagEngine } = require('./TagEngine'); + deps = { + getPostEngine, getMediaEngine, getScriptEngine, + getTemplateEngine, getMetaEngine, getPostMediaEngine, getTagEngine, + }; + } + mcpServerInstance = new MCPServer(deps); + } + return mcpServerInstance; +} + +export function resetMCPServer(): void { + mcpServerInstance = null; +} diff --git a/src/main/engine/ProposalStore.ts b/src/main/engine/ProposalStore.ts new file mode 100644 index 0000000..0860329 --- /dev/null +++ b/src/main/engine/ProposalStore.ts @@ -0,0 +1,68 @@ +import { randomUUID } from 'crypto'; + +export type ProposalType = + | 'draftPost' + | 'proposeScript' + | 'proposeTemplate' + | 'proposeMediaMetadata' + | 'proposePostMetadata'; + +export interface Proposal { + id: string; + type: ProposalType; + data: Record; + createdAt: number; +} + +const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes + +export class ProposalStore { + private readonly proposals = new Map(); + private readonly ttlMs: number; + private cleanupInterval: ReturnType | null = null; + + constructor(ttlMs: number = DEFAULT_TTL_MS) { + this.ttlMs = ttlMs; + this.cleanupInterval = setInterval(() => this.cleanup(), this.ttlMs); + } + + create(type: ProposalType, data: Record): string { + const id = randomUUID(); + this.proposals.set(id, { + id, + type, + data, + createdAt: Date.now(), + }); + return id; + } + + get(id: string): Proposal | undefined { + return this.proposals.get(id); + } + + remove(id: string): void { + this.proposals.delete(id); + } + + getAll(): Proposal[] { + return Array.from(this.proposals.values()); + } + + cleanup(): void { + const now = Date.now(); + for (const [id, proposal] of this.proposals) { + if (now - proposal.createdAt > this.ttlMs) { + this.proposals.delete(id); + } + } + } + + destroy(): void { + this.proposals.clear(); + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + } +} diff --git a/src/main/engine/mcp-views.ts b/src/main/engine/mcp-views.ts new file mode 100644 index 0000000..153a118 --- /dev/null +++ b/src/main/engine/mcp-views.ts @@ -0,0 +1,258 @@ +/** + * MCP App Review Views — inline HTML strings for review UIs. + * + * Each function returns a self-contained HTML page that uses the + * `App` class from `@modelcontextprotocol/ext-apps` (loaded via + * the `app-with-deps` bundle that includes its own dependencies). + * + * These Views are served as `ui://` resources and rendered inline + * in MCP hosts that support MCP Apps (Claude, ChatGPT, VS Code, etc.). + */ + +function baseStyles(): string { + return ` + * { box-sizing: border-box; margin: 0; padding: 0; } + body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; padding: 16px; color: #1a1a1a; background: #fff; line-height: 1.5; } + h1 { font-size: 1.25rem; margin-bottom: 12px; } + h2 { font-size: 1rem; margin: 12px 0 8px; color: #555; } + .meta { color: #666; font-size: 0.875rem; margin-bottom: 8px; } + .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; } + .badge-draft { background: #fef3cd; color: #856404; } + .badge-kind { background: #d1ecf1; color: #0c5460; } + .content-preview { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 12px; margin: 8px 0; overflow-x: auto; white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; max-height: 400px; overflow-y: auto; } + .actions { display: flex; gap: 8px; margin-top: 16px; } + .btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem; font-weight: 500; } + .btn-accept { background: #28a745; color: #fff; } + .btn-accept:hover { background: #218838; } + .btn-discard { background: #dc3545; color: #fff; } + .btn-discard:hover { background: #c82333; } + .btn:disabled { opacity: 0.5; cursor: not-allowed; } + .status { margin-top: 12px; padding: 8px 12px; border-radius: 6px; font-size: 0.875rem; } + .status-success { background: #d4edda; color: #155724; } + .status-error { background: #f8d7da; color: #721c24; } + .diff-table { width: 100%; border-collapse: collapse; margin: 8px 0; } + .diff-table th, .diff-table td { padding: 6px 10px; border: 1px solid #dee2e6; text-align: left; font-size: 0.85rem; } + .diff-table th { background: #f1f3f5; font-weight: 600; } + .diff-old { background: #ffeef0; } + .diff-new { background: #e6ffed; } + .word-count { color: #888; font-size: 0.8rem; } + `; +} + +function appScript(): string { + return ` + import { App } from "@modelcontextprotocol/ext-apps/app-with-deps"; + + const app = new App({ name: "bDS Review", version: "1.0.0" }); + + let currentData = null; + + app.ontoolresult = (result) => { + try { + const textContent = result.content?.find(c => c.type === "text"); + if (textContent?.text) { + currentData = JSON.parse(textContent.text); + renderReview(currentData); + } + } catch (e) { + showStatus("Failed to parse tool result: " + e.message, "error"); + } + }; + + window.acceptProposal = async () => { + if (!currentData?.proposalId) return; + setButtonsDisabled(true); + try { + const result = await app.callServerTool({ + name: "accept_proposal", + arguments: { proposalId: currentData.proposalId } + }); + const text = result.content?.find(c => c.type === "text")?.text; + const parsed = text ? JSON.parse(text) : {}; + showStatus(parsed.success ? "Accepted!" : (parsed.message || "Failed"), parsed.success ? "success" : "error"); + } catch (e) { + showStatus("Error: " + e.message, "error"); + } + }; + + window.discardProposal = async () => { + if (!currentData?.proposalId) return; + setButtonsDisabled(true); + try { + const result = await app.callServerTool({ + name: "discard_proposal", + arguments: { proposalId: currentData.proposalId } + }); + const text = result.content?.find(c => c.type === "text")?.text; + const parsed = text ? JSON.parse(text) : {}; + showStatus(parsed.success ? "Discarded." : (parsed.message || "Failed"), parsed.success ? "success" : "error"); + } catch (e) { + showStatus("Error: " + e.message, "error"); + } + }; + + function setButtonsDisabled(disabled) { + document.querySelectorAll(".btn").forEach(b => b.disabled = disabled); + } + + function showStatus(message, type) { + const el = document.getElementById("status"); + if (el) { + el.textContent = message; + el.className = "status status-" + type; + el.style.display = "block"; + } + } + + window.showStatus = showStatus; + window.renderReview = window.renderReview || (() => {}); + + app.connect().catch(e => console.error("App connect failed:", e)); + `; +} + +export function reviewPostHtml(): string { + return ` + +Review Post + + + +
+

Waiting for post data...

+
+ + + +`; +} + +export function reviewScriptHtml(): string { + return ` + +Review Script + + + +
+

Waiting for script data...

+
+ + + +`; +} + +export function reviewTemplateHtml(): string { + return ` + +Review Template + + + +
+

Waiting for template data...

+
+ + + +`; +} + +export function reviewMetadataHtml(): string { + return ` + +Review Metadata Changes + + + +
+

Waiting for metadata data...

+
+ + + +`; +} diff --git a/src/main/main.ts b/src/main/main.ts index 31ed9f2..f263c49 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -11,6 +11,7 @@ import { getMetaEngine } from './engine/MetaEngine'; import { getTemplateEngine } from './engine/TemplateEngine'; import { getBlogmarkTransformService } from './engine/BlogmarkTransformService'; import { PreviewServer } from './engine/PreviewServer'; +import { getMCPServer } from './engine/MCPServer'; import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_GROUPS, APP_MENU_ITEM_IDS, type AppMenuAction, type AppMenuItemDefinition } from './shared/menuCommands'; import { resolveUiLanguageFromSystemLocale, translateMenu } from './shared/i18n'; import { buildBlogmarkMarkdownLink, extractBlogmarkPayloadFromDeepLink, normalizeBlogmarkCategory } from './shared/blogmark'; @@ -24,6 +25,7 @@ let blogmarkQueueProcessing = false; let pendingBlogmarkCreatedEvents: unknown[] = []; let rendererReady = false; const PREVIEW_SERVER_PORT = 4123; +const MCP_SERVER_PORT = 4124; const BLOG_PREVIEW_POST_MENU_ID = APP_MENU_ITEM_IDS.previewPost; const BLOGMARK_PROTOCOL = 'bds'; const BLOGMARK_NEW_POST_PREFIX = `${BLOGMARK_PROTOCOL}://new-post`; @@ -864,6 +866,12 @@ app.whenReady().then(async () => { } catch (error) { console.error('Failed to start preview server on app startup:', error); } + try { + const mcpServer = getMCPServer(); + await mcpServer.start(MCP_SERVER_PORT); + } catch (error) { + console.error('Failed to start MCP server on app startup:', error); + } createWindow(); await activeProjectContextReady; @@ -897,6 +905,13 @@ app.on('before-quit', async () => { previewServer = null; } + try { + const mcpServer = getMCPServer(); + await mcpServer.cleanup(); + } catch (error) { + console.error('Failed to cleanup MCP server:', error); + } + const db = getDatabase(); await db.close(); }); diff --git a/tests/engine/MCPServer.test.ts b/tests/engine/MCPServer.test.ts new file mode 100644 index 0000000..d4fe865 --- /dev/null +++ b/tests/engine/MCPServer.test.ts @@ -0,0 +1,390 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { MCPServer, type MCPServerDependencies } from '../../src/main/engine/MCPServer'; + +// Mock all engine singletons +vi.mock('../../src/main/engine/PostEngine', () => ({ + getPostEngine: vi.fn(), +})); +vi.mock('../../src/main/engine/MediaEngine', () => ({ + getMediaEngine: vi.fn(), +})); +vi.mock('../../src/main/engine/ScriptEngine', () => ({ + getScriptEngine: vi.fn(), +})); +vi.mock('../../src/main/engine/TemplateEngine', () => ({ + getTemplateEngine: vi.fn(), +})); +vi.mock('../../src/main/engine/MetaEngine', () => ({ + getMetaEngine: vi.fn(), +})); +vi.mock('../../src/main/engine/PostMediaEngine', () => ({ + getPostMediaEngine: vi.fn(), +})); +vi.mock('../../src/main/engine/TagEngine', () => ({ + getTagEngine: vi.fn(), +})); + +function createMockPostEngine() { + return { + getAllPosts: vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 }), + getPost: vi.fn().mockResolvedValue(null), + searchPosts: vi.fn().mockResolvedValue([]), + createPost: vi.fn().mockResolvedValue({ + id: 'post-1', title: 'Test', slug: 'test', content: '', status: 'draft', + tags: [], categories: [], createdAt: new Date(), updatedAt: new Date(), + }), + updatePost: vi.fn().mockResolvedValue(null), + publishPost: vi.fn().mockResolvedValue(null), + deletePost: vi.fn().mockResolvedValue(true), + getTagsWithCounts: vi.fn().mockResolvedValue([]), + getCategoriesWithCounts: vi.fn().mockResolvedValue([]), + getBlogStats: vi.fn().mockResolvedValue({ + totalPosts: 0, draftCount: 0, publishedCount: 0, archivedCount: 0, + oldestPostDate: null, newestPostDate: null, postsPerYear: [], tagCount: 0, categoryCount: 0, + }), + getLinkedBy: vi.fn().mockResolvedValue([]), + getLinksTo: vi.fn().mockResolvedValue([]), + getPostsFiltered: vi.fn().mockResolvedValue([]), + }; +} + +function createMockMediaEngine() { + return { + getAllMedia: vi.fn().mockResolvedValue([]), + getMedia: vi.fn().mockResolvedValue(null), + updateMedia: vi.fn().mockResolvedValue(null), + }; +} + +function createMockScriptEngine() { + return { + createScript: vi.fn().mockResolvedValue({ + id: 'script-1', title: 'Test', slug: 'test', kind: 'macro', + entrypoint: 'main.py', content: '', enabled: true, version: 1, + filePath: '/test', createdAt: new Date(), updatedAt: new Date(), + }), + }; +} + +function createMockTemplateEngine() { + return { + createTemplate: vi.fn().mockResolvedValue({ + id: 'tpl-1', title: 'Test', slug: 'test', kind: 'post', + enabled: true, version: 1, filePath: '/test', content: '', + createdAt: new Date(), updatedAt: new Date(), + }), + }; +} + +function createMockMetaEngine() { + return { + getProjectMetadata: vi.fn().mockResolvedValue(null), + }; +} + +function createMockPostMediaEngine() { + return { + getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]), + getLinkedPostsForMedia: vi.fn().mockResolvedValue([]), + }; +} + +function createMockTagEngine() { + return { + getTagsWithCounts: vi.fn().mockResolvedValue([]), + }; +} + +/** Create stable mock instances that are returned by getter functions */ +function createDependencies() { + const mockPostEngine = createMockPostEngine(); + const mockMediaEngine = createMockMediaEngine(); + const mockScriptEngine = createMockScriptEngine(); + const mockTemplateEngine = createMockTemplateEngine(); + const mockMetaEngine = createMockMetaEngine(); + const mockPostMediaEngine = createMockPostMediaEngine(); + const mockTagEngine = createMockTagEngine(); + + const deps: MCPServerDependencies = { + getPostEngine: () => mockPostEngine, + getMediaEngine: () => mockMediaEngine, + getScriptEngine: () => mockScriptEngine, + getTemplateEngine: () => mockTemplateEngine, + getMetaEngine: () => mockMetaEngine, + getPostMediaEngine: () => mockPostMediaEngine, + getTagEngine: () => mockTagEngine, + }; + + return { deps, mockPostEngine, mockMediaEngine, mockScriptEngine, mockTemplateEngine, mockMetaEngine, mockPostMediaEngine, mockTagEngine }; +} + +/** Helper: check if a key exists in an McpServer internal registry (plain object) */ +function hasRegistered(mcpServer: unknown, registry: string, name: string): boolean { + const obj = (mcpServer as Record>)[registry]; + return obj != null && name in obj; +} + +describe('MCPServer', () => { + let server: MCPServer; + let deps: MCPServerDependencies; + let mockPostEngine: ReturnType; + let mockMediaEngine: ReturnType; + let mockScriptEngine: ReturnType; + let mockTemplateEngine: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + const mocks = createDependencies(); + deps = mocks.deps; + mockPostEngine = mocks.mockPostEngine; + mockMediaEngine = mocks.mockMediaEngine; + mockScriptEngine = mocks.mockScriptEngine; + mockTemplateEngine = mocks.mockTemplateEngine; + server = new MCPServer(deps); + }); + + describe('constructor', () => { + it('creates an MCPServer instance', () => { + expect(server).toBeInstanceOf(MCPServer); + }); + }); + + describe('createMcpServer', () => { + it('creates an McpServer with registered tools', () => { + const mcpServer = server.createMcpServer(); + expect(mcpServer).toBeDefined(); + }); + }); + + describe('proposal store', () => { + it('exposes the proposal store', () => { + expect(server.proposalStore).toBeDefined(); + }); + }); + + describe('registered tools', () => { + it('registers search_posts tool', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredTools', 'search_posts')).toBe(true); + }); + + it('registers draft_post tool', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredTools', 'draft_post')).toBe(true); + }); + + it('registers propose_script tool', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredTools', 'propose_script')).toBe(true); + }); + + it('registers propose_template tool', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredTools', 'propose_template')).toBe(true); + }); + + it('registers propose_media_metadata tool', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredTools', 'propose_media_metadata')).toBe(true); + }); + + it('registers propose_post_metadata tool', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredTools', 'propose_post_metadata')).toBe(true); + }); + + it('registers accept_proposal tool', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredTools', 'accept_proposal')).toBe(true); + }); + + it('registers discard_proposal tool', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredTools', 'discard_proposal')).toBe(true); + }); + }); + + describe('registered resources', () => { + it('registers bds://posts resource', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredResources', 'bds://posts')).toBe(true); + }); + + it('registers bds://media resource', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredResources', 'bds://media')).toBe(true); + }); + + it('registers bds://tags resource', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredResources', 'bds://tags')).toBe(true); + }); + + it('registers bds://categories resource', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredResources', 'bds://categories')).toBe(true); + }); + + it('registers bds://stats resource', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredResources', 'bds://stats')).toBe(true); + }); + }); + + describe('registered resource templates', () => { + it('registers post resource template', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'post')).toBe(true); + }); + + it('registers media-item resource template', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'media-item')).toBe(true); + }); + + it('registers post-backlinks resource template', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'post-backlinks')).toBe(true); + }); + + it('registers post-outlinks resource template', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'post-outlinks')).toBe(true); + }); + + it('registers post-media resource template', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'post-media')).toBe(true); + }); + + it('registers media-posts resource template', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'media-posts')).toBe(true); + }); + }); + + describe('registered prompts', () => { + it('registers draft-blog-post prompt', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredPrompts', 'draft-blog-post')).toBe(true); + }); + + it('registers improve-media-metadata prompt', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredPrompts', 'improve-media-metadata')).toBe(true); + }); + + it('registers content-audit prompt', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredPrompts', 'content-audit')).toBe(true); + }); + }); + + describe('start and stop', () => { + it('starts the server and returns a port', async () => { + const port = await server.start(0); + expect(port).toBeGreaterThan(0); + await server.stop(); + }); + + it('stop is idempotent', async () => { + await server.start(0); + await server.stop(); + await expect(server.stop()).resolves.toBeUndefined(); + }); + }); + + describe('cleanup', () => { + it('cleans up proposals and stops the server', async () => { + server.proposalStore.create('draftPost', { postId: 'test' }); + await server.start(0); + await server.cleanup(); + expect(server.proposalStore.getAll()).toHaveLength(0); + }); + }); + + describe('accept_proposal', () => { + it('accepts a draftPost proposal by publishing', async () => { + mockPostEngine.publishPost.mockResolvedValue({ + id: 'post-1', title: 'Test', slug: 'test', content: '', status: 'published', + tags: [], categories: [], createdAt: new Date(), updatedAt: new Date(), publishedAt: new Date(), + }); + + const proposalId = server.proposalStore.create('draftPost', { postId: 'post-1' }); + const result = await server.acceptProposal(proposalId); + expect(result.success).toBe(true); + expect(mockPostEngine.publishPost).toHaveBeenCalledWith('post-1'); + expect(server.proposalStore.get(proposalId)).toBeUndefined(); + }); + + it('accepts a proposeScript proposal by creating script', async () => { + const proposalId = server.proposalStore.create('proposeScript', { + title: 'My Script', kind: 'macro', content: 'print("hello")', + }); + const result = await server.acceptProposal(proposalId); + expect(result.success).toBe(true); + expect(mockScriptEngine.createScript).toHaveBeenCalledWith({ + title: 'My Script', kind: 'macro', content: 'print("hello")', + }); + expect(server.proposalStore.get(proposalId)).toBeUndefined(); + }); + + it('accepts a proposeTemplate proposal by creating template', async () => { + const proposalId = server.proposalStore.create('proposeTemplate', { + title: 'My Template', kind: 'post', content: '

{{ title }}

', + }); + const result = await server.acceptProposal(proposalId); + expect(result.success).toBe(true); + expect(mockTemplateEngine.createTemplate).toHaveBeenCalledWith({ + title: 'My Template', kind: 'post', content: '

{{ title }}

', + }); + }); + + it('accepts a proposeMediaMetadata proposal by updating media', async () => { + mockMediaEngine.updateMedia.mockResolvedValue({ id: 'media-1' }); + const proposalId = server.proposalStore.create('proposeMediaMetadata', { + mediaId: 'media-1', changes: { alt: 'New alt text' }, + }); + const result = await server.acceptProposal(proposalId); + expect(result.success).toBe(true); + expect(mockMediaEngine.updateMedia).toHaveBeenCalledWith('media-1', { alt: 'New alt text' }); + }); + + it('accepts a proposePostMetadata proposal by updating post', async () => { + mockPostEngine.updatePost.mockResolvedValue({ id: 'post-1' }); + const proposalId = server.proposalStore.create('proposePostMetadata', { + postId: 'post-1', changes: { title: 'Updated Title' }, + }); + const result = await server.acceptProposal(proposalId); + expect(result.success).toBe(true); + expect(mockPostEngine.updatePost).toHaveBeenCalledWith('post-1', { title: 'Updated Title' }); + }); + + it('returns failure for non-existent proposal', async () => { + const result = await server.acceptProposal('non-existent'); + expect(result.success).toBe(false); + }); + }); + + describe('discard_proposal', () => { + it('discards a draftPost proposal by deleting the post', async () => { + const proposalId = server.proposalStore.create('draftPost', { postId: 'post-1' }); + const result = await server.discardProposal(proposalId); + expect(result.success).toBe(true); + expect(mockPostEngine.deletePost).toHaveBeenCalledWith('post-1'); + expect(server.proposalStore.get(proposalId)).toBeUndefined(); + }); + + it('discards a proposeScript proposal by removing from store', async () => { + const proposalId = server.proposalStore.create('proposeScript', { title: 'Script' }); + const result = await server.discardProposal(proposalId); + expect(result.success).toBe(true); + expect(server.proposalStore.get(proposalId)).toBeUndefined(); + }); + + it('returns failure for non-existent proposal', async () => { + const result = await server.discardProposal('non-existent'); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/tests/engine/ProposalStore.test.ts b/tests/engine/ProposalStore.test.ts new file mode 100644 index 0000000..1d9ca48 --- /dev/null +++ b/tests/engine/ProposalStore.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { ProposalStore, type Proposal, type ProposalType } from '../../src/main/engine/ProposalStore'; + +describe('ProposalStore', () => { + let store: ProposalStore; + + beforeEach(() => { + vi.useFakeTimers(); + store = new ProposalStore(); + }); + + afterEach(() => { + store.destroy(); + vi.useRealTimers(); + }); + + describe('create', () => { + it('creates a proposal and returns an id', () => { + const id = store.create('draftPost', { postId: 'post-1' }); + expect(id).toBeTruthy(); + expect(typeof id).toBe('string'); + }); + + it('generates unique ids for each proposal', () => { + const id1 = store.create('draftPost', { postId: 'post-1' }); + const id2 = store.create('draftPost', { postId: 'post-2' }); + expect(id1).not.toBe(id2); + }); + }); + + describe('get', () => { + it('returns a created proposal', () => { + const id = store.create('proposeScript', { title: 'My Script', content: 'print("hello")' }); + const proposal = store.get(id); + expect(proposal).toBeDefined(); + expect(proposal!.type).toBe('proposeScript'); + expect(proposal!.data).toEqual({ title: 'My Script', content: 'print("hello")' }); + }); + + it('returns undefined for non-existent id', () => { + expect(store.get('non-existent')).toBeUndefined(); + }); + }); + + describe('remove', () => { + it('removes a proposal', () => { + const id = store.create('draftPost', { postId: 'post-1' }); + expect(store.get(id)).toBeDefined(); + store.remove(id); + expect(store.get(id)).toBeUndefined(); + }); + + it('does nothing when removing non-existent id', () => { + expect(() => store.remove('non-existent')).not.toThrow(); + }); + }); + + describe('getAll', () => { + it('returns all proposals', () => { + store.create('draftPost', { postId: 'post-1' }); + store.create('proposeScript', { title: 'Script' }); + const all = store.getAll(); + expect(all).toHaveLength(2); + }); + + it('returns empty array when no proposals exist', () => { + expect(store.getAll()).toHaveLength(0); + }); + }); + + describe('TTL expiry', () => { + it('expires proposals after TTL', () => { + const id = store.create('draftPost', { postId: 'post-1' }); + expect(store.get(id)).toBeDefined(); + + // Advance past 30-minute TTL + vi.advanceTimersByTime(31 * 60 * 1000); + store.cleanup(); + + expect(store.get(id)).toBeUndefined(); + }); + + it('keeps proposals within TTL', () => { + const id = store.create('draftPost', { postId: 'post-1' }); + + // Advance less than TTL + vi.advanceTimersByTime(15 * 60 * 1000); + store.cleanup(); + + expect(store.get(id)).toBeDefined(); + }); + + it('supports custom TTL', () => { + const shortStore = new ProposalStore(5 * 60 * 1000); // 5 minutes + const id = shortStore.create('draftPost', { postId: 'post-1' }); + + vi.advanceTimersByTime(6 * 60 * 1000); + shortStore.cleanup(); + + expect(shortStore.get(id)).toBeUndefined(); + shortStore.destroy(); + }); + }); + + describe('cleanup', () => { + it('removes only expired proposals', () => { + const id1 = store.create('draftPost', { postId: 'post-1' }); + + // Advance 20 minutes + vi.advanceTimersByTime(20 * 60 * 1000); + + const id2 = store.create('proposeScript', { title: 'Script' }); + + // Advance 15 more minutes (id1 = 35 min old = expired, id2 = 15 min old = ok) + vi.advanceTimersByTime(15 * 60 * 1000); + store.cleanup(); + + expect(store.get(id1)).toBeUndefined(); + expect(store.get(id2)).toBeDefined(); + }); + }); + + describe('destroy', () => { + it('clears all proposals', () => { + store.create('draftPost', { postId: 'post-1' }); + store.create('proposeScript', { title: 'Script' }); + store.destroy(); + expect(store.getAll()).toHaveLength(0); + }); + }); + + describe('proposal types', () => { + const types: ProposalType[] = [ + 'draftPost', + 'proposeScript', + 'proposeTemplate', + 'proposeMediaMetadata', + 'proposePostMetadata', + ]; + + it.each(types)('stores and retrieves %s type', (type) => { + const id = store.create(type, { test: true }); + const proposal = store.get(id); + expect(proposal).toBeDefined(); + expect(proposal!.type).toBe(type); + }); + }); +}); diff --git a/tsconfig.main.json b/tsconfig.main.json index b986c94..f06ec16 100644 --- a/tsconfig.main.json +++ b/tsconfig.main.json @@ -10,6 +10,9 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, + "paths": { + "@modelcontextprotocol/ext-apps/server": ["./node_modules/@modelcontextprotocol/ext-apps/dist/src/server/index.d.ts"] + }, "declaration": true, "declarationMap": true, "sourceMap": true,