From 690b90abcfc2623c50b44385cbfe6ed928df6089 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 28 Feb 2026 09:11:59 +0100 Subject: [PATCH 1/9] 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, From 9efe007791cf96d74cc1cdb18a795d1f5eeca34a Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 28 Feb 2026 09:31:58 +0100 Subject: [PATCH 2/9] feat: mcp server implementation round 2 --- src/main/engine/MCPServer.ts | 60 +++-- src/main/main.ts | 13 +- tests/engine/MCPServer.integration.test.ts | 166 ++++++++++++ tests/engine/MCPServer.test.ts | 279 +++++++++++++++++++++ tests/engine/mcp-views.test.ts | 173 +++++++++++++ 5 files changed, 674 insertions(+), 17 deletions(-) create mode 100644 tests/engine/MCPServer.integration.test.ts create mode 100644 tests/engine/mcp-views.test.ts diff --git a/src/main/engine/MCPServer.ts b/src/main/engine/MCPServer.ts index ef90617..f975c8f 100644 --- a/src/main/engine/MCPServer.ts +++ b/src/main/engine/MCPServer.ts @@ -37,6 +37,7 @@ interface MediaEngineContract { getAllMedia: () => Promise>>; getMedia: (id: string) => Promise | null>; updateMedia: (id: string, data: Record) => Promise | null>; + getThumbnailDataUrl: (mediaId: string, size: 'small' | 'medium' | 'large') => Promise; } interface ScriptEngineContract { @@ -309,6 +310,22 @@ export class MCPServer { const result = await this.deps.getPostMediaEngine().getLinkedPostsForMedia(id as string); return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] }; }); + + server.registerResource('media-image', new ResourceTemplate('bds://media/{id}/image', { list: undefined }), { description: 'Image thumbnail (medium size, base64 WebP) for visual context' }, async (uri, { id }) => { + const mediaId = id as string; + const media = await this.deps.getMediaEngine().getMedia(mediaId); + if (!media || !(media as Record).mimeType || !String((media as Record).mimeType).startsWith('image/')) { + return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'Not an image or media not found' }] }; + } + const dataUrl = await this.deps.getMediaEngine().getThumbnailDataUrl(mediaId, 'medium'); + if (!dataUrl) { + return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'Thumbnail not available' }] }; + } + const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, ''); + return { + contents: [{ uri: uri.href, mimeType: 'image/webp', blob: base64Data }], + }; + }); } // ── Tool registration ────────────────────────────────────────────── @@ -329,17 +346,34 @@ export class MCPServer { }, annotations: { readOnlyHint: true, openWorldHint: false }, }, async (args) => { - if (args.query) { + const hasFilters = args.category || args.tags || args.year || args.month || args.status; + + if (args.query && !hasFilters) { + // Pure text search — use FTS const results = await this.deps.getPostEngine().searchPosts(args.query); return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] }; } + + // Filter-based query (optionally narrowed by text search) 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); + let results = await this.deps.getPostEngine().getPostsFiltered(filter); + + // Client-side text filter when query is combined with structured filters + if (args.query) { + const q = args.query.toLowerCase(); + results = results.filter((p: Record) => { + const title = String(p.title ?? '').toLowerCase(); + const content = String(p.content ?? '').toLowerCase(); + const excerpt = String(p.excerpt ?? '').toLowerCase(); + return title.includes(q) || content.includes(q) || excerpt.includes(q); + }); + } + return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] }; }); } @@ -357,6 +391,7 @@ export class MCPServer { categories: z.array(z.string()).optional().describe('Categories for the post'), author: z.string().optional().describe('Post author name'), }, + annotations: { readOnlyHint: false, destructiveHint: false }, _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({ @@ -384,6 +419,7 @@ export class MCPServer { content: z.string().describe('Python source code'), entrypoint: z.string().optional().describe('Entry point function name'), }, + annotations: { readOnlyHint: false, destructiveHint: false }, _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', { @@ -406,6 +442,7 @@ export class MCPServer { kind: z.enum(['post', 'list', 'not-found', 'partial']).describe('Template type'), content: z.string().describe('Liquid template content'), }, + annotations: { readOnlyHint: false, destructiveHint: false }, _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', { @@ -429,6 +466,7 @@ export class MCPServer { title: z.string().optional().describe('New title'), tags: z.array(z.string()).optional().describe('New tags'), }, + annotations: { readOnlyHint: false, destructiveHint: false }, _meta: { ui: { resourceUri: 'ui://bds/review-metadata' } }, }, async (args: { mediaId: string; alt?: string; caption?: string; title?: string; tags?: string[] }) => { const { mediaId, ...changes } = args; @@ -453,6 +491,7 @@ export class MCPServer { tags: z.array(z.string()).optional().describe('New tags'), categories: z.array(z.string()).optional().describe('New categories'), }, + annotations: { readOnlyHint: false, destructiveHint: false }, _meta: { ui: { resourceUri: 'ui://bds/review-metadata' } }, }, async (args: { postId: string; title?: string; excerpt?: string; tags?: string[]; categories?: string[] }) => { const { postId, ...changes } = args; @@ -503,7 +542,7 @@ export class MCPServer { inputSchema: { proposalId: z.string().describe('ID of the proposal to accept'), }, - annotations: { idempotentHint: true }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, }, async (args) => { const result = await this.acceptProposal(args.proposalId); return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; @@ -515,7 +554,7 @@ export class MCPServer { inputSchema: { proposalId: z.string().describe('ID of the proposal to discard'), }, - annotations: { idempotentHint: true }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, }, async (args) => { const result = await this.discardProposal(args.proposalId); return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; @@ -667,18 +706,7 @@ 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, - }; + throw new Error('MCPServer dependencies must be provided on first call to getMCPServer()'); } mcpServerInstance = new MCPServer(deps); } diff --git a/src/main/main.ts b/src/main/main.ts index f263c49..28ec7a1 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -9,6 +9,9 @@ import { getMediaEngine } from './engine/MediaEngine'; import { getPostEngine } from './engine/PostEngine'; import { getMetaEngine } from './engine/MetaEngine'; import { getTemplateEngine } from './engine/TemplateEngine'; +import { getScriptEngine } from './engine/ScriptEngine'; +import { getPostMediaEngine } from './engine/PostMediaEngine'; +import { getTagEngine } from './engine/TagEngine'; import { getBlogmarkTransformService } from './engine/BlogmarkTransformService'; import { PreviewServer } from './engine/PreviewServer'; import { getMCPServer } from './engine/MCPServer'; @@ -867,7 +870,15 @@ app.whenReady().then(async () => { console.error('Failed to start preview server on app startup:', error); } try { - const mcpServer = getMCPServer(); + const mcpServer = getMCPServer({ + getPostEngine: () => getPostEngine() as never, + getMediaEngine: () => getMediaEngine() as never, + getScriptEngine: () => getScriptEngine() as never, + getTemplateEngine: () => getTemplateEngine() as never, + getMetaEngine: () => getMetaEngine() as never, + getPostMediaEngine: () => getPostMediaEngine() as never, + getTagEngine: () => getTagEngine() as never, + }); await mcpServer.start(MCP_SERVER_PORT); } catch (error) { console.error('Failed to start MCP server on app startup:', error); diff --git a/tests/engine/MCPServer.integration.test.ts b/tests/engine/MCPServer.integration.test.ts new file mode 100644 index 0000000..351e41c --- /dev/null +++ b/tests/engine/MCPServer.integration.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { MCPServer, type MCPServerDependencies } from '../../src/main/engine/MCPServer'; + +/** + * Integration tests for MCPServer HTTP transport. + * These start the actual HTTP server and send MCP protocol requests. + */ + +function createMockDeps(): MCPServerDependencies { + return { + getPostEngine: () => ({ + getAllPosts: vi.fn().mockResolvedValue({ items: [{ id: 'p1', title: 'Test Post' }], hasMore: false, total: 1 }), + getPost: vi.fn().mockResolvedValue({ id: 'p1', title: 'Test Post', content: '# Hello', slug: 'test' }), + searchPosts: vi.fn().mockResolvedValue([{ id: 'p1', title: 'Test Post', slug: 'test' }]), + createPost: vi.fn().mockResolvedValue({ id: 'draft-1', title: 'Draft', status: 'draft' }), + updatePost: vi.fn().mockResolvedValue({ id: 'p1', title: 'Updated' }), + publishPost: vi.fn().mockResolvedValue({ id: 'draft-1', status: 'published' }), + deletePost: vi.fn().mockResolvedValue(true), + getTagsWithCounts: vi.fn().mockResolvedValue([{ tag: 'js', count: 5 }]), + getCategoriesWithCounts: vi.fn().mockResolvedValue([{ category: 'tech', count: 3 }]), + getBlogStats: vi.fn().mockResolvedValue({ totalPosts: 10 }), + getLinkedBy: vi.fn().mockResolvedValue([]), + getLinksTo: vi.fn().mockResolvedValue([]), + getPostsFiltered: vi.fn().mockResolvedValue([]), + }), + getMediaEngine: () => ({ + getAllMedia: vi.fn().mockResolvedValue([]), + getMedia: vi.fn().mockResolvedValue(null), + updateMedia: vi.fn().mockResolvedValue(null), + getThumbnailDataUrl: vi.fn().mockResolvedValue(null), + }), + getScriptEngine: () => ({ + createScript: vi.fn().mockResolvedValue({ id: 's1' }), + }), + getTemplateEngine: () => ({ + createTemplate: vi.fn().mockResolvedValue({ id: 't1' }), + }), + getMetaEngine: () => ({ + getProjectMetadata: vi.fn().mockResolvedValue(null), + }), + getPostMediaEngine: () => ({ + getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]), + getLinkedPostsForMedia: vi.fn().mockResolvedValue([]), + }), + getTagEngine: () => ({ + getTagsWithCounts: vi.fn().mockResolvedValue([]), + }), + }; +} + +async function sendMcpRequest(port: number, method: string, params?: unknown, id = 1): Promise { + const body = JSON.stringify({ + jsonrpc: '2.0', + id, + method, + params: params ?? {}, + }); + + const response = await fetch(`http://127.0.0.1:${port}/mcp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body, + }); + + const text = await response.text(); + const contentType = response.headers.get('content-type') ?? ''; + + // SSE format: "event: message\ndata: {json}\n\n" + if (contentType.includes('text/event-stream')) { + const dataLine = text.split('\n').find(l => l.startsWith('data: ')); + if (dataLine) { + return JSON.parse(dataLine.slice(6)); + } + } + + return JSON.parse(text); +} + +async function initializeSession(port: number): Promise { + return sendMcpRequest(port, 'initialize', { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' }, + }); +} + +describe('MCPServer integration', () => { + let server: MCPServer; + + beforeEach(() => { + server = new MCPServer(createMockDeps()); + }); + + afterEach(async () => { + await server.cleanup(); + }); + + it('starts on a random port and responds to initialize', async () => { + const port = await server.start(0); + expect(port).toBeGreaterThan(0); + + const result = await initializeSession(port) as { result?: { serverInfo?: { name: string } } }; + expect(result.result?.serverInfo?.name).toBe('Blogging Desktop Server'); + }); + + it('returns CORS headers', async () => { + const port = await server.start(0); + const response = await fetch(`http://127.0.0.1:${port}/mcp`, { + method: 'OPTIONS', + }); + expect(response.status).toBe(204); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response.headers.get('Access-Control-Allow-Methods')).toContain('POST'); + }); + + it('lists tools via tools/list after initialize', async () => { + const port = await server.start(0); + + // MCP requires single request per connection in stateless mode — each request creates a new session + // Send initialize + tools/list in same session is not possible in stateless mode + // Instead, we can verify the server responds to a standalone initialize + const initResult = await initializeSession(port) as { result?: { capabilities?: { tools?: unknown } } }; + expect(initResult.result?.capabilities?.tools).toBeDefined(); + }); + + it('getPort returns the listening port', async () => { + expect(server.getPort()).toBeNull(); + const port = await server.start(0); + expect(server.getPort()).toBe(port); + await server.stop(); + expect(server.getPort()).toBeNull(); + }); + + it('handles multiple sequential requests', async () => { + const port = await server.start(0); + const r1 = await initializeSession(port) as { result?: unknown }; + const r2 = await initializeSession(port) as { result?: unknown }; + expect(r1.result).toBeDefined(); + expect(r2.result).toBeDefined(); + }); + + it('returns 500 for malformed JSON', async () => { + const port = await server.start(0); + const response = await fetch(`http://127.0.0.1:${port}/mcp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: '{invalid json', + }); + // The server should handle this gracefully + expect(response.status).toBeGreaterThanOrEqual(400); + }); + + it('stop is idempotent', async () => { + await server.start(0); + await server.stop(); + await expect(server.stop()).resolves.toBeUndefined(); + }); + + it('cleanup stops server and clears proposals', async () => { + server.proposalStore.create('draftPost', { postId: 'p1' }); + await server.start(0); + await server.cleanup(); + expect(server.proposalStore.getAll()).toHaveLength(0); + expect(server.getPort()).toBeNull(); + }); +}); diff --git a/tests/engine/MCPServer.test.ts b/tests/engine/MCPServer.test.ts index d4fe865..1978eb1 100644 --- a/tests/engine/MCPServer.test.ts +++ b/tests/engine/MCPServer.test.ts @@ -53,6 +53,7 @@ function createMockMediaEngine() { getAllMedia: vi.fn().mockResolvedValue([]), getMedia: vi.fn().mockResolvedValue(null), updateMedia: vi.fn().mockResolvedValue(null), + getThumbnailDataUrl: vi.fn().mockResolvedValue(null), }; } @@ -131,6 +132,7 @@ describe('MCPServer', () => { let mockMediaEngine: ReturnType; let mockScriptEngine: ReturnType; let mockTemplateEngine: ReturnType; + let mockPostMediaEngine: ReturnType; beforeEach(() => { vi.clearAllMocks(); @@ -140,6 +142,7 @@ describe('MCPServer', () => { mockMediaEngine = mocks.mockMediaEngine; mockScriptEngine = mocks.mockScriptEngine; mockTemplateEngine = mocks.mockTemplateEngine; + mockPostMediaEngine = mocks.mockPostMediaEngine; server = new MCPServer(deps); }); @@ -261,6 +264,11 @@ describe('MCPServer', () => { const mcpServer = server.createMcpServer(); expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'media-posts')).toBe(true); }); + + it('registers media-image resource template', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'media-image')).toBe(true); + }); }); describe('registered prompts', () => { @@ -387,4 +395,275 @@ describe('MCPServer', () => { expect(result.success).toBe(false); }); }); + + // ── Tool annotations ──────────────────────────────────────────────── + + describe('tool annotations', () => { + function getToolAnnotations(toolName: string): Record | undefined { + const mcpServer = server.createMcpServer(); + const tool = (mcpServer as Record }>>)._registeredTools[toolName]; + return tool?.annotations; + } + + it('search_posts has readOnlyHint true', () => { + const annotations = getToolAnnotations('search_posts'); + expect(annotations).toEqual({ readOnlyHint: true, openWorldHint: false }); + }); + + it('draft_post has readOnlyHint false, destructiveHint false', () => { + const annotations = getToolAnnotations('draft_post'); + expect(annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false }); + }); + + it('propose_script has readOnlyHint false, destructiveHint false', () => { + const annotations = getToolAnnotations('propose_script'); + expect(annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false }); + }); + + it('propose_template has readOnlyHint false, destructiveHint false', () => { + const annotations = getToolAnnotations('propose_template'); + expect(annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false }); + }); + + it('propose_media_metadata has readOnlyHint false, destructiveHint false', () => { + const annotations = getToolAnnotations('propose_media_metadata'); + expect(annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false }); + }); + + it('propose_post_metadata has readOnlyHint false, destructiveHint false', () => { + const annotations = getToolAnnotations('propose_post_metadata'); + expect(annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false }); + }); + + it('accept_proposal has readOnlyHint false, destructiveHint false, idempotentHint true', () => { + const annotations = getToolAnnotations('accept_proposal'); + expect(annotations).toEqual({ readOnlyHint: false, destructiveHint: false, idempotentHint: true }); + }); + + it('discard_proposal has readOnlyHint false, destructiveHint true, idempotentHint true', () => { + const annotations = getToolAnnotations('discard_proposal'); + expect(annotations).toEqual({ readOnlyHint: false, destructiveHint: true, idempotentHint: true }); + }); + }); + + // ── Resource handler behavior ─────────────────────────────────────── + + describe('resource handlers', () => { + function getResource(mcpServer: unknown, uri: string) { + return (mcpServer as Record Promise }>>)._registeredResources[uri]; + } + + function getResourceTemplate(mcpServer: unknown, name: string) { + return (mcpServer as Record Promise }>>)._registeredResourceTemplates[name]; + } + + it('bds://posts calls getAllPosts and returns JSON', async () => { + const postsData = { items: [{ id: 'p1', title: 'Hello' }], hasMore: false, total: 1 }; + mockPostEngine.getAllPosts.mockResolvedValue(postsData); + const mcpServer = server.createMcpServer(); + const resource = getResource(mcpServer, 'bds://posts'); + const result = await resource.readCallback(new URL('bds://posts'), {}) as { contents: Array<{ text: string }> }; + expect(mockPostEngine.getAllPosts).toHaveBeenCalled(); + expect(JSON.parse(result.contents[0].text)).toEqual(postsData); + }); + + it('bds://stats calls getBlogStats and returns JSON', async () => { + const stats = { totalPosts: 42 }; + mockPostEngine.getBlogStats.mockResolvedValue(stats); + const mcpServer = server.createMcpServer(); + const resource = getResource(mcpServer, 'bds://stats'); + const result = await resource.readCallback(new URL('bds://stats'), {}) as { contents: Array<{ text: string }> }; + expect(JSON.parse(result.contents[0].text)).toEqual(stats); + }); + + it('bds://posts/{id} calls getPost with correct id', async () => { + const post = { id: 'post-1', title: 'Test' }; + mockPostEngine.getPost.mockResolvedValue(post); + const mcpServer = server.createMcpServer(); + const tpl = getResourceTemplate(mcpServer, 'post'); + const result = await tpl.readCallback(new URL('bds://posts/post-1'), { id: 'post-1' }, {}) as { contents: Array<{ text: string }> }; + expect(mockPostEngine.getPost).toHaveBeenCalledWith('post-1'); + expect(JSON.parse(result.contents[0].text)).toEqual(post); + }); + + it('bds://posts/{id}/media calls getLinkedMediaDataForPost', async () => { + const linkedMedia = [{ id: 'm1', filename: 'photo.jpg' }]; + mockPostMediaEngine.getLinkedMediaDataForPost.mockResolvedValue(linkedMedia); + const mcpServer = server.createMcpServer(); + const tpl = getResourceTemplate(mcpServer, 'post-media'); + const result = await tpl.readCallback(new URL('bds://posts/p1/media'), { id: 'p1' }, {}) as { contents: Array<{ text: string }> }; + expect(mockPostMediaEngine.getLinkedMediaDataForPost).toHaveBeenCalledWith('p1'); + expect(JSON.parse(result.contents[0].text)).toEqual(linkedMedia); + }); + + it('bds://media/{id}/image returns thumbnail blob for images', async () => { + mockMediaEngine.getMedia.mockResolvedValue({ id: 'img-1', mimeType: 'image/jpeg', filename: 'photo.jpg' }); + mockMediaEngine.getThumbnailDataUrl.mockResolvedValue('data:image/webp;base64,AAAA'); + const mcpServer = server.createMcpServer(); + const tpl = getResourceTemplate(mcpServer, 'media-image'); + const result = await tpl.readCallback(new URL('bds://media/img-1/image'), { id: 'img-1' }, {}) as { contents: Array<{ mimeType: string; blob: string }> }; + expect(mockMediaEngine.getThumbnailDataUrl).toHaveBeenCalledWith('img-1', 'medium'); + expect(result.contents[0].mimeType).toBe('image/webp'); + expect(result.contents[0].blob).toBe('AAAA'); + }); + + it('bds://media/{id}/image returns text error for non-images', async () => { + mockMediaEngine.getMedia.mockResolvedValue({ id: 'doc-1', mimeType: 'application/pdf', filename: 'doc.pdf' }); + const mcpServer = server.createMcpServer(); + const tpl = getResourceTemplate(mcpServer, 'media-image'); + const result = await tpl.readCallback(new URL('bds://media/doc-1/image'), { id: 'doc-1' }, {}) as { contents: Array<{ mimeType: string; text: string }> }; + expect(result.contents[0].mimeType).toBe('text/plain'); + expect(result.contents[0].text).toContain('Not an image'); + }); + + it('bds://media/{id}/image returns text error when thumbnail unavailable', async () => { + mockMediaEngine.getMedia.mockResolvedValue({ id: 'img-2', mimeType: 'image/png', filename: 'pic.png' }); + mockMediaEngine.getThumbnailDataUrl.mockResolvedValue(null); + const mcpServer = server.createMcpServer(); + const tpl = getResourceTemplate(mcpServer, 'media-image'); + const result = await tpl.readCallback(new URL('bds://media/img-2/image'), { id: 'img-2' }, {}) as { contents: Array<{ mimeType: string; text: string }> }; + expect(result.contents[0].text).toContain('Thumbnail not available'); + }); + }); + + // ── Tool handler behavior ────────────────────────────────────────── + + describe('tool handlers', () => { + function getTool(mcpServer: unknown, name: string) { + return (mcpServer as Record Promise }>>)._registeredTools[name]; + } + + it('search_posts with query only calls searchPosts', async () => { + const searchResults = [{ id: 'p1', title: 'Found', slug: 'found' }]; + mockPostEngine.searchPosts.mockResolvedValue(searchResults); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'search_posts'); + const result = await tool.handler({ query: 'test' }, {}) as { content: Array<{ text: string }> }; + expect(mockPostEngine.searchPosts).toHaveBeenCalledWith('test'); + expect(JSON.parse(result.content[0].text)).toEqual(searchResults); + }); + + it('search_posts with filters only calls getPostsFiltered', async () => { + const filtered = [{ id: 'p2', title: 'Filtered' }]; + mockPostEngine.getPostsFiltered.mockResolvedValue(filtered); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'search_posts'); + const result = await tool.handler({ category: 'tech', status: 'published' }, {}) as { content: Array<{ text: string }> }; + expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ categories: ['tech'], status: 'published' }); + expect(JSON.parse(result.content[0].text)).toEqual(filtered); + }); + + it('search_posts with query + filters uses getPostsFiltered and client-side text filter', async () => { + const allFiltered = [ + { id: 'p1', title: 'TypeScript Guide', content: 'Learn TS', excerpt: '' }, + { id: 'p2', title: 'Python Guide', content: 'Learn Python', excerpt: '' }, + ]; + mockPostEngine.getPostsFiltered.mockResolvedValue(allFiltered); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'search_posts'); + const result = await tool.handler({ query: 'typescript', category: 'tech' }, {}) as { content: Array<{ text: string }> }; + expect(mockPostEngine.getPostsFiltered).toHaveBeenCalled(); + expect(mockPostEngine.searchPosts).not.toHaveBeenCalled(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toHaveLength(1); + expect(parsed[0].title).toBe('TypeScript Guide'); + }); + + it('draft_post creates a draft and stores proposal', async () => { + const createdPost = { id: 'new-post', title: 'Draft Title', status: 'draft' }; + mockPostEngine.createPost.mockResolvedValue(createdPost); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'draft_post'); + const result = await tool.handler({ title: 'Draft Title', content: '# Hello' }, {}) as { content: Array<{ text: string }> }; + expect(mockPostEngine.createPost).toHaveBeenCalledWith(expect.objectContaining({ title: 'Draft Title', content: '# Hello', status: 'draft' })); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.proposalId).toBeTruthy(); + expect(parsed.post).toEqual(createdPost); + // Verify proposal is in the store + const proposal = server.proposalStore.get(parsed.proposalId); + expect(proposal).toBeDefined(); + expect(proposal!.type).toBe('draftPost'); + expect(proposal!.data.postId).toBe('new-post'); + }); + + it('propose_script stores proposal in ProposalStore', async () => { + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'propose_script'); + const result = await tool.handler({ title: 'My Script', kind: 'macro', content: 'print("hi")' }, {}) as { content: Array<{ text: string }> }; + const parsed = JSON.parse(result.content[0].text); + expect(parsed.proposalId).toBeTruthy(); + const proposal = server.proposalStore.get(parsed.proposalId); + expect(proposal).toBeDefined(); + expect(proposal!.type).toBe('proposeScript'); + expect(proposal!.data.content).toBe('print("hi")'); + }); + + it('propose_media_metadata loads current media and stores proposal', async () => { + const currentMedia = { id: 'img-1', alt: 'Old alt', title: 'Old title' }; + mockMediaEngine.getMedia.mockResolvedValue(currentMedia); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'propose_media_metadata'); + const result = await tool.handler({ mediaId: 'img-1', alt: 'New alt' }, {}) as { content: Array<{ text: string }> }; + const parsed = JSON.parse(result.content[0].text); + expect(parsed.current).toEqual(currentMedia); + expect(parsed.proposed).toEqual({ alt: 'New alt' }); + const proposal = server.proposalStore.get(parsed.proposalId); + expect(proposal!.type).toBe('proposeMediaMetadata'); + expect(proposal!.data.mediaId).toBe('img-1'); + }); + + it('propose_post_metadata loads current post and stores proposal', async () => { + const currentPost = { id: 'post-1', title: 'Old Title', excerpt: 'Old excerpt' }; + mockPostEngine.getPost.mockResolvedValue(currentPost); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'propose_post_metadata'); + const result = await tool.handler({ postId: 'post-1', title: 'New Title' }, {}) as { content: Array<{ text: string }> }; + const parsed = JSON.parse(result.content[0].text); + expect(parsed.current).toEqual(currentPost); + expect(parsed.proposed).toEqual({ title: 'New Title' }); + const proposal = server.proposalStore.get(parsed.proposalId); + expect(proposal!.type).toBe('proposePostMetadata'); + }); + }); + + // ── Prompt handler behavior ──────────────────────────────────────── + + describe('prompt handlers', () => { + function getPrompt(mcpServer: unknown, name: string) { + return (mcpServer as Record Promise }>>)._registeredPrompts[name]; + } + + it('draft-blog-post returns messages with topic', async () => { + const mcpServer = server.createMcpServer(); + const prompt = getPrompt(mcpServer, 'draft-blog-post'); + const result = await prompt.callback({ topic: 'AI Safety' }, {}) as { messages: Array<{ role: string; content: { type: string; text: string } }> }; + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe('user'); + expect(result.messages[0].content.text).toContain('AI Safety'); + expect(result.messages[0].content.text).toContain('draft_post'); + }); + + it('improve-media-metadata returns messages with scope', async () => { + const mcpServer = server.createMcpServer(); + const prompt = getPrompt(mcpServer, 'improve-media-metadata'); + const result = await prompt.callback({ scope: 'missing-alt' }, {}) as { messages: Array<{ role: string; content: { type: string; text: string } }> }; + expect(result.messages).toHaveLength(1); + expect(result.messages[0].content.text).toContain('missing alt text'); + }); + + it('content-audit returns messages with category', async () => { + const mcpServer = server.createMcpServer(); + const prompt = getPrompt(mcpServer, 'content-audit'); + const result = await prompt.callback({ category: 'tech' }, {}) as { messages: Array<{ role: string; content: { type: string; text: string } }> }; + expect(result.messages).toHaveLength(1); + expect(result.messages[0].content.text).toContain('tech'); + }); + + it('content-audit without category reviews all posts', async () => { + const mcpServer = server.createMcpServer(); + const prompt = getPrompt(mcpServer, 'content-audit'); + const result = await prompt.callback({}, {}) as { messages: Array<{ role: string; content: { type: string; text: string } }> }; + expect(result.messages[0].content.text).toContain('all posts'); + }); + }); }); diff --git a/tests/engine/mcp-views.test.ts b/tests/engine/mcp-views.test.ts new file mode 100644 index 0000000..4e72b42 --- /dev/null +++ b/tests/engine/mcp-views.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest'; +import { + reviewPostHtml, + reviewScriptHtml, + reviewTemplateHtml, + reviewMetadataHtml, +} from '../../src/main/engine/mcp-views'; + +describe('mcp-views', () => { + describe('reviewPostHtml', () => { + it('returns valid HTML document', () => { + const html = reviewPostHtml(); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('contains App import from ext-apps', () => { + const html = reviewPostHtml(); + expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); + expect(html).toContain('new App('); + }); + + it('contains accept and discard buttons', () => { + const html = reviewPostHtml(); + expect(html).toContain('acceptProposal()'); + expect(html).toContain('discardProposal()'); + }); + + it('calls accept_proposal and discard_proposal tools via app bridge', () => { + const html = reviewPostHtml(); + expect(html).toContain('app.callServerTool'); + expect(html).toContain('"accept_proposal"'); + expect(html).toContain('"discard_proposal"'); + }); + + it('contains post-specific UI elements', () => { + const html = reviewPostHtml(); + expect(html).toContain('Review Post'); + expect(html).toContain('Publish'); + expect(html).toContain('badge-draft'); + expect(html).toContain('word-count'); + }); + + it('renders tool result data via ontoolresult handler', () => { + const html = reviewPostHtml(); + expect(html).toContain('app.ontoolresult'); + expect(html).toContain('renderReview'); + }); + + it('uses XSS-safe escaping function', () => { + const html = reviewPostHtml(); + expect(html).toContain('function esc('); + expect(html).toContain('document.createElement("div")'); + }); + }); + + describe('reviewScriptHtml', () => { + it('returns valid HTML document', () => { + const html = reviewScriptHtml(); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('contains App import from ext-apps', () => { + const html = reviewScriptHtml(); + expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); + }); + + it('contains accept and discard buttons', () => { + const html = reviewScriptHtml(); + expect(html).toContain('acceptProposal()'); + expect(html).toContain('discardProposal()'); + }); + + it('contains script-specific UI elements', () => { + const html = reviewScriptHtml(); + expect(html).toContain('Review Script'); + expect(html).toContain('Create Script'); + expect(html).toContain('Python Code'); + }); + }); + + describe('reviewTemplateHtml', () => { + it('returns valid HTML document', () => { + const html = reviewTemplateHtml(); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('contains App import from ext-apps', () => { + const html = reviewTemplateHtml(); + expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); + }); + + it('contains accept and discard buttons', () => { + const html = reviewTemplateHtml(); + expect(html).toContain('acceptProposal()'); + expect(html).toContain('discardProposal()'); + }); + + it('contains template-specific UI elements', () => { + const html = reviewTemplateHtml(); + expect(html).toContain('Review Template'); + expect(html).toContain('Create Template'); + expect(html).toContain('Liquid Template'); + }); + }); + + describe('reviewMetadataHtml', () => { + it('returns valid HTML document', () => { + const html = reviewMetadataHtml(); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('contains App import from ext-apps', () => { + const html = reviewMetadataHtml(); + expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); + }); + + it('contains accept and discard buttons', () => { + const html = reviewMetadataHtml(); + expect(html).toContain('acceptProposal()'); + expect(html).toContain('discardProposal()'); + }); + + it('contains metadata-diff UI elements', () => { + const html = reviewMetadataHtml(); + expect(html).toContain('Metadata Changes'); + expect(html).toContain('Apply Changes'); + expect(html).toContain('diff-table'); + expect(html).toContain('Current'); + expect(html).toContain('Proposed'); + }); + + it('contains diff formatting function', () => { + const html = reviewMetadataHtml(); + expect(html).toContain('function fmt('); + expect(html).toContain('diff-old'); + expect(html).toContain('diff-new'); + }); + }); + + describe('shared behavior', () => { + const allViews = [ + { name: 'reviewPostHtml', fn: reviewPostHtml }, + { name: 'reviewScriptHtml', fn: reviewScriptHtml }, + { name: 'reviewTemplateHtml', fn: reviewTemplateHtml }, + { name: 'reviewMetadataHtml', fn: reviewMetadataHtml }, + ]; + + it.each(allViews)('$name connects the App on load', ({ fn }) => { + const html = fn(); + expect(html).toContain('app.connect()'); + }); + + it.each(allViews)('$name has a status display element', ({ fn }) => { + const html = fn(); + expect(html).toContain('id="status"'); + expect(html).toContain('showStatus'); + }); + + it.each(allViews)('$name disables buttons during action', ({ fn }) => { + const html = fn(); + expect(html).toContain('setButtonsDisabled(true)'); + }); + + it.each(allViews)('$name uses module script type', ({ fn }) => { + const html = fn(); + expect(html).toContain('type="module"'); + }); + }); +}); From e5463b10f99da5e9c4f9348200ff25c06bc2d5ba Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 28 Feb 2026 09:53:45 +0100 Subject: [PATCH 3/9] feat: mcp server implementation third round --- MCP_PLAN.md | 16 +++-- TODO.md | 68 ++++++++++------------ src/main/engine/MCPServer.ts | 50 +++++++++++++--- tests/engine/MCPServer.integration.test.ts | 45 +++++++++++++- tests/engine/MCPServer.test.ts | 65 +++++++++++++++++++++ 5 files changed, 188 insertions(+), 56 deletions(-) diff --git a/MCP_PLAN.md b/MCP_PLAN.md index ca5862f..7f421e8 100644 --- a/MCP_PLAN.md +++ b/MCP_PLAN.md @@ -24,7 +24,7 @@ MCPServer (HTTP on 127.0.0.1:4124) ← NEW, standalone └── 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. +The MCP SDK provides `StreamableHTTPServerTransport` for HTTP handling. We use Node's `http.createServer` directly with stateless mode (new `McpServer` per request) to avoid session management complexity and the Express dependency. `MCPServer` is a new engine class that owns the `McpServer` factory, tool/resource/prompt registration, and the `ProposalStore`. It runs independently of PreviewServer. @@ -44,7 +44,7 @@ npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps ### 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` +- `start(port)` → uses `http.createServer` + `StreamableHTTPServerTransport` in stateless mode, listens on `127.0.0.1:port`, validates Origin header - `stop()` → close HTTP server - `cleanup()` → discard proposals, stop intervals, stop server - Singleton pattern with `getMCPServer()` getter @@ -65,6 +65,7 @@ Register MCP resources mapping to existing engine methods: | `bds://posts/{id}/outlinks` | PostEngine.getLinksTo(id) | | `bds://posts/{id}/media` | PostMediaEngine.getLinkedMediaDataForPost(id) | | `bds://media/{id}/posts` | PostMediaEngine.getLinkedPostsForMedia(id) | +| `bds://media/{id}/image` | MediaEngine.getThumbnailDataUrl(id, 'medium') | ### Step 5: Read tools - `search_posts` — annotations: `{ readOnlyHint: true, openWorldHint: false }` @@ -88,6 +89,7 @@ Resource registration uses `registerAppResource` from `@modelcontextprotocol/ext ### Step 7: Accept/discard tools - `accept_proposal({ proposalId })` — dispatch by type, commit change - `discard_proposal({ proposalId })` — dispatch by type, clean up +- Registered via `registerAppTool` with `visibility: ["app"]` (app-only, hidden from agent LLM) - Annotations: idempotentHint: true ### Step 8: MCP Prompts @@ -109,7 +111,7 @@ Each View: 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. +Build step: Review Views are inline HTML template strings in `mcp-views.ts` — no separate build step needed. ### Step 10: Lifecycle in main.ts - MCPServer starts independently alongside PreviewServer during app init @@ -136,12 +138,8 @@ Each step above is preceded by failing tests: |---|---| | `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/engine/mcp-views.ts` | NEW — inline review View HTML templates | +| `tests/engine/mcp-views.test.ts` | NEW | | `src/main/main.ts` | MODIFY — start/cleanup MCPServer | | `tests/engine/ProposalStore.test.ts` | NEW | | `tests/engine/MCPServer.test.ts` | NEW | diff --git a/TODO.md b/TODO.md index 6ca0d73..537cea8 100644 --- a/TODO.md +++ b/TODO.md @@ -284,14 +284,18 @@ anything enters the system. - **`@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 +- **`MCPServer` engine is implemented and tested (70 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. + 5 static resources, 7 resource templates, 8 tools (6 model-facing + + 2 app-only), 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. +- **Origin validation** — rejects requests from non-localhost origins to + prevent DNS rebinding attacks. +- **`accept_proposal` / `discard_proposal` use app-only visibility** via + `registerAppTool` with `visibility: ["app"]` — hidden from the agent LLM. ### Design Principles @@ -352,10 +356,9 @@ anything enters the system. 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. +7. **Input validation** — all tool inputs are validated at the MCP + boundary (via Zod schemas in `inputSchema`) before forwarding to + engine methods. Do not rely solely on downstream engine validation. ### Implementation Plan @@ -413,9 +416,12 @@ actions. Each resource is registered via `resources/list` and read via | `bds://media/{id}/image` | OpenCodeManager.view_image | Image binary (for visual context) | 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. +templates (`resources/templates/list`). + +Note: `notifications/resources/list_changed` is not emitted because the +server runs in stateless mode (new `McpServer` per request, no persistent +connection). If the server moves to session-based mode in the future, +change notifications should be added. List resources (`bds://posts`, `bds://media`) support cursor-based pagination following the MCP pagination spec. The initial response @@ -565,9 +571,10 @@ Stages metadata changes for an existing post (title, excerpt, slug, tags). #### 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. +mechanism), **not** by the agent LLM. They are registered with +`registerAppTool` from `@modelcontextprotocol/ext-apps` using +`visibility: ["app"]`, which signals to compliant hosts that these tools +should not be shown to or invoked by the model. ##### `accept_proposal` @@ -656,32 +663,19 @@ tools know to call PostEngine rather than look in the store. #### 3.9 Transport -Support two transports: +**Streamable HTTP** — standalone HTTP server on port 4124 using +`StreamableHTTPServerTransport` in stateless mode (new `McpServer` per +request). A single HTTP endpoint at `/mcp` accepts JSON-RPC POST +requests and responds with `application/json` or `text/event-stream` +(SSE). -- **stdio** — for local integration (agent runs `bds --mcp` or connects via - 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. +**Security:** -Start with stdio since that is what Claude Code and Cursor use. - -**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. +- Bind to `127.0.0.1` (localhost only). +- Validate the `Origin` header on all requests — reject non-localhost + origins to prevent DNS rebinding attacks. +- Requests without an `Origin` header are allowed (CLI tools like curl + and local MCP clients typically do not send one). #### 3.10 Lifecycle Integration diff --git a/src/main/engine/MCPServer.ts b/src/main/engine/MCPServer.ts index f975c8f..838b763 100644 --- a/src/main/engine/MCPServer.ts +++ b/src/main/engine/MCPServer.ts @@ -17,6 +17,14 @@ import { // ── Dependency contracts ────────────────────────────────────────────── +export interface PostFilter { + status?: 'draft' | 'published' | 'archived'; + tags?: string[]; + categories?: string[]; + year?: number; + month?: number; +} + interface PostEngineContract { getAllPosts: (options?: { limit?: number; offset?: number }) => Promise<{ items: Array>; hasMore: boolean; total: number }>; getPost: (id: string) => Promise | null>; @@ -30,7 +38,7 @@ interface PostEngineContract { getBlogStats: () => Promise>; getLinkedBy: (postId: string) => Promise>; getLinksTo: (postId: string) => Promise>; - getPostsFiltered: (filter: Record) => Promise>>; + getPostsFiltered: (filter: PostFilter) => Promise>>; } interface MediaEngineContract { @@ -109,8 +117,26 @@ export class MCPServer { } const app = createHttpServer(async (req, res) => { + // Origin validation — reject requests from non-localhost origins + // to prevent DNS rebinding attacks + const origin = req.headers['origin']; + if (origin) { + try { + const originUrl = new URL(origin); + if (originUrl.hostname !== '127.0.0.1' && originUrl.hostname !== 'localhost') { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Forbidden: non-local origin' })); + return; + } + } catch { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Forbidden: invalid origin' })); + return; + } + } + // CORS headers - res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Origin', origin ?? 'http://127.0.0.1'); 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'); @@ -347,15 +373,18 @@ export class MCPServer { annotations: { readOnlyHint: true, openWorldHint: false }, }, async (args) => { const hasFilters = args.category || args.tags || args.year || args.month || args.status; + const offset = args.offset ?? 0; + const limit = args.limit ?? 50; if (args.query && !hasFilters) { // Pure text search — use FTS const results = await this.deps.getPostEngine().searchPosts(args.query); - return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] }; + const paginated = results.slice(offset, offset + limit); + return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] }; } // Filter-based query (optionally narrowed by text search) - const filter: Record = {}; + const filter: PostFilter = {}; if (args.category) filter.categories = [args.category]; if (args.tags) filter.tags = args.tags; if (args.year) filter.year = args.year; @@ -374,7 +403,8 @@ export class MCPServer { }); } - return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] }; + const paginated = results.slice(offset, offset + limit); + return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] }; }); } @@ -536,26 +566,28 @@ export class MCPServer { } private registerAcceptDiscardTools(server: McpServer): void { - server.registerTool('accept_proposal', { + registerAppTool(server, '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: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, - }, async (args) => { + _meta: { ui: { visibility: ['app'] } }, + }, async (args: { proposalId: string }) => { const result = await this.acceptProposal(args.proposalId); return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; }); - server.registerTool('discard_proposal', { + registerAppTool(server, '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: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, - }, async (args) => { + _meta: { ui: { visibility: ['app'] } }, + }, async (args: { proposalId: string }) => { const result = await this.discardProposal(args.proposalId); return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; }); diff --git a/tests/engine/MCPServer.integration.test.ts b/tests/engine/MCPServer.integration.test.ts index 351e41c..86db727 100644 --- a/tests/engine/MCPServer.integration.test.ts +++ b/tests/engine/MCPServer.integration.test.ts @@ -109,10 +109,53 @@ describe('MCPServer integration', () => { method: 'OPTIONS', }); expect(response.status).toBe(204); - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response.headers.get('Access-Control-Allow-Origin')).toBeTruthy(); expect(response.headers.get('Access-Control-Allow-Methods')).toContain('POST'); }); + it('rejects requests from non-local origins', async () => { + const port = await server.start(0); + const response = await fetch(`http://127.0.0.1:${port}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Origin': 'https://evil-site.com', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } }, + }), + }); + expect(response.status).toBe(403); + }); + + it('allows requests from localhost origins', async () => { + const port = await server.start(0); + const response = await fetch(`http://127.0.0.1:${port}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'Origin': `http://localhost:${port}`, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } }, + }), + }); + expect(response.status).not.toBe(403); + }); + + it('allows requests without Origin header', async () => { + const port = await server.start(0); + const result = await initializeSession(port) as { result?: { serverInfo?: { name: string } } }; + expect(result.result?.serverInfo?.name).toBe('Blogging Desktop Server'); + }); + it('lists tools via tools/list after initialize', async () => { const port = await server.start(0); diff --git a/tests/engine/MCPServer.test.ts b/tests/engine/MCPServer.test.ts index 1978eb1..9cf4070 100644 --- a/tests/engine/MCPServer.test.ts +++ b/tests/engine/MCPServer.test.ts @@ -446,6 +446,38 @@ describe('MCPServer', () => { }); }); + // ── Tool visibility ───────────────────────────────────────────────── + + describe('tool visibility', () => { + function getToolMeta(toolName: string): Record | undefined { + const mcpServer = server.createMcpServer(); + const tool = (mcpServer as Record }>>)._registeredTools[toolName]; + return tool?._meta; + } + + it('accept_proposal has app-only visibility', () => { + const meta = getToolMeta('accept_proposal'); + expect(meta).toBeDefined(); + const ui = (meta as Record).ui as Record; + expect(ui?.visibility).toEqual(['app']); + }); + + it('discard_proposal has app-only visibility', () => { + const meta = getToolMeta('discard_proposal'); + expect(meta).toBeDefined(); + const ui = (meta as Record).ui as Record; + expect(ui?.visibility).toEqual(['app']); + }); + + it('draft_post has model+app visibility (default)', () => { + const meta = getToolMeta('draft_post'); + expect(meta).toBeDefined(); + const ui = (meta as Record).ui as Record; + // no explicit visibility = default ["model", "app"] + expect(ui?.visibility).toBeUndefined(); + }); + }); + // ── Resource handler behavior ─────────────────────────────────────── describe('resource handlers', () => { @@ -543,6 +575,28 @@ describe('MCPServer', () => { expect(JSON.parse(result.content[0].text)).toEqual(searchResults); }); + it('search_posts with query applies offset and limit', async () => { + const searchResults = Array.from({ length: 10 }, (_, i) => ({ id: `p${i}`, title: `Post ${i}`, slug: `post-${i}` })); + mockPostEngine.searchPosts.mockResolvedValue(searchResults); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'search_posts'); + const result = await tool.handler({ query: 'test', offset: 2, limit: 3 }, {}) as { content: Array<{ text: string }> }; + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toHaveLength(3); + expect(parsed[0].id).toBe('p2'); + expect(parsed[2].id).toBe('p4'); + }); + + it('search_posts defaults to limit 50 when not specified', async () => { + const searchResults = Array.from({ length: 60 }, (_, i) => ({ id: `p${i}`, title: `Post ${i}`, slug: `post-${i}` })); + mockPostEngine.searchPosts.mockResolvedValue(searchResults); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'search_posts'); + const result = await tool.handler({ query: 'test' }, {}) as { content: Array<{ text: string }> }; + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toHaveLength(50); + }); + it('search_posts with filters only calls getPostsFiltered', async () => { const filtered = [{ id: 'p2', title: 'Filtered' }]; mockPostEngine.getPostsFiltered.mockResolvedValue(filtered); @@ -553,6 +607,17 @@ describe('MCPServer', () => { expect(JSON.parse(result.content[0].text)).toEqual(filtered); }); + it('search_posts with filters applies offset and limit', async () => { + const filtered = Array.from({ length: 10 }, (_, i) => ({ id: `p${i}`, title: `Post ${i}` })); + mockPostEngine.getPostsFiltered.mockResolvedValue(filtered); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'search_posts'); + const result = await tool.handler({ category: 'tech', offset: 3, limit: 2 }, {}) as { content: Array<{ text: string }> }; + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toHaveLength(2); + expect(parsed[0].id).toBe('p3'); + }); + it('search_posts with query + filters uses getPostsFiltered and client-side text filter', async () => { const allFiltered = [ { id: 'p1', title: 'TypeScript Guide', content: 'Learn TS', excerpt: '' }, From 591caf87333df60459ae2f89de69244909f98134 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 28 Feb 2026 10:18:26 +0100 Subject: [PATCH 4/9] feat: mcp server round four --- src/main/engine/MCPServer.ts | 116 +++++++++++++-------- src/main/engine/OpenCodeManager.ts | 55 +++++----- src/main/engine/PostEngine.ts | 101 ++++++++++++++++++ tests/engine/MCPServer.integration.test.ts | 1 + tests/engine/MCPServer.test.ts | 98 +++++++++++++++-- tests/engine/PostEngine.test.ts | 63 +++++++++++ 6 files changed, 358 insertions(+), 76 deletions(-) diff --git a/src/main/engine/MCPServer.ts b/src/main/engine/MCPServer.ts index 838b763..c167e97 100644 --- a/src/main/engine/MCPServer.ts +++ b/src/main/engine/MCPServer.ts @@ -29,6 +29,7 @@ interface PostEngineContract { getAllPosts: (options?: { limit?: number; offset?: number }) => Promise<{ items: Array>; hasMore: boolean; total: number }>; getPost: (id: string) => Promise | null>; searchPosts: (query: string) => Promise>; + searchPostsFiltered: (query: string, filter: PostFilter, pagination?: { offset?: number; limit?: number }) => Promise>>; createPost: (data: Record) => Promise>; updatePost: (id: string, data: Record) => Promise | null>; publishPost: (id: string) => Promise | null>; @@ -383,26 +384,24 @@ export class MCPServer { return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] }; } - // Filter-based query (optionally narrowed by text search) + // Build structural filter const filter: PostFilter = {}; 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; - let results = await this.deps.getPostEngine().getPostsFiltered(filter); - // Client-side text filter when query is combined with structured filters - if (args.query) { - const q = args.query.toLowerCase(); - results = results.filter((p: Record) => { - const title = String(p.title ?? '').toLowerCase(); - const content = String(p.content ?? '').toLowerCase(); - const excerpt = String(p.excerpt ?? '').toLowerCase(); - return title.includes(q) || content.includes(q) || excerpt.includes(q); - }); + if (args.query && hasFilters) { + // FTS + structural filters: single SQL JOIN query, ranked by FTS score + const results = await this.deps.getPostEngine().searchPostsFiltered( + args.query, filter, { offset, limit }, + ); + return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] }; } + // Filter-only query (no text search) + const results = await this.deps.getPostEngine().getPostsFiltered(filter); const paginated = results.slice(offset, offset + limit); return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] }; }); @@ -424,19 +423,26 @@ export class MCPServer { annotations: { readOnlyHint: false, destructiveHint: false }, _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 }) }], - }; + try { + 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 }) }], + }; + } catch (error) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ error: `Failed to create draft: ${error instanceof Error ? error.message : String(error)}` }) }], + isError: true, + }; + } }); // ── propose_script ── @@ -499,15 +505,28 @@ export class MCPServer { annotations: { readOnlyHint: false, destructiveHint: false }, _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 }) }], - }; + try { + const { mediaId, ...changes } = args; + const current = await this.deps.getMediaEngine().getMedia(mediaId); + if (!current) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ error: `Media item ${mediaId} not found.` }) }], + isError: true, + }; + } + const proposalId = this.proposalStore.create('proposeMediaMetadata', { + mediaId, + changes, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, current, proposed: changes }) }], + }; + } catch (error) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ error: `Failed to propose media metadata: ${error instanceof Error ? error.message : String(error)}` }) }], + isError: true, + }; + } }); // ── propose_post_metadata ── @@ -524,15 +543,28 @@ export class MCPServer { annotations: { readOnlyHint: false, destructiveHint: false }, _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 }) }], - }; + try { + const { postId, ...changes } = args; + const current = await this.deps.getPostEngine().getPost(postId); + if (!current) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ error: `Post ${postId} not found.` }) }], + isError: true, + }; + } + const proposalId = this.proposalStore.create('proposePostMetadata', { + postId, + changes, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, current, proposed: changes }) }], + }; + } catch (error) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ error: `Failed to propose post metadata: ${error instanceof Error ? error.message : String(error)}` }) }], + isError: true, + }; + } }); // ── Register ui:// resources for review Views ── diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index 124301d..a3987a4 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -1253,46 +1253,47 @@ export class OpenCodeManager { try { switch (name) { case 'search_posts': { - const searchResults = await this.postEngine.searchPosts(args.query as string); - const fullPosts = await Promise.all( - searchResults.map(sr => this.postEngine.getPost(sr.id)) - ); - let filteredPosts = fullPosts.filter(p => p !== null); + const filter: { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[]; year?: number; month?: number } = {}; + if (args.category) filter.categories = [args.category as string]; + if (args.tags && Array.isArray(args.tags) && (args.tags as string[]).length > 0) filter.tags = args.tags as string[]; + if (args.year !== undefined) filter.year = args.year as number; + if (args.month !== undefined && args.year !== undefined) filter.month = args.month as number; - if (args.category) { - filteredPosts = filteredPosts.filter(p => p!.categories.includes(args.category as string)); - } - if (args.tags && Array.isArray(args.tags) && (args.tags as string[]).length > 0) { - filteredPosts = filteredPosts.filter(p => - (args.tags as string[]).every(tag => p!.tags.includes(tag)) + const hasFilters = Object.keys(filter).length > 0; + const offset = (args.offset as number) || 0; + const limit = (args.limit as number) || 10; + + let filteredPosts; + if (hasFilters) { + // Combined FTS + structural filters in a single SQL query + filteredPosts = await this.postEngine.searchPostsFiltered( + args.query as string, filter, { offset, limit }, ); - } - if (args.year !== undefined) { - const year = args.year as number; - filteredPosts = filteredPosts.filter(p => p!.createdAt.getFullYear() === year); - } - if (args.month !== undefined && args.year !== undefined) { - const month = (args.month as number) - 1; // Convert 1-indexed to 0-indexed - filteredPosts = filteredPosts.filter(p => p!.createdAt.getMonth() === month); + } else { + // Pure FTS search + const searchResults = await this.postEngine.searchPosts(args.query as string); + // searchPosts returns sparse results; fetch full post data + const fullPosts = await Promise.all( + searchResults.map(sr => this.postEngine.getPost(sr.id)) + ); + const all = fullPosts.filter(p => p !== null) as PostData[]; + filteredPosts = all.slice(offset, offset + limit); } const totalMatches = filteredPosts.length; - const offset = (args.offset as number) || 0; - const limit = (args.limit as number) || 10; - filteredPosts = filteredPosts.slice(offset, offset + limit); return { success: true, count: filteredPosts.length, totalMatches, - hasMore: offset + limit < totalMatches, + hasMore: false, offset, limit, posts: filteredPosts.map(p => ({ - id: p!.id, title: p!.title, slug: p!.slug, - excerpt: p!.excerpt, status: p!.status, - categories: p!.categories, tags: p!.tags, - createdAt: p!.createdAt, updatedAt: p!.updatedAt, + id: p.id, title: p.title, slug: p.slug, + excerpt: p.excerpt, status: p.status, + categories: p.categories, tags: p.tags, + createdAt: p.createdAt, updatedAt: p.updatedAt, })), }; } diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index cf11f8f..948cc06 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -861,6 +861,107 @@ export class PostEngine extends EventEmitter { } } + /** + * Combined FTS search + structural filters in a single SQL query. + * Returns full PostData ordered by FTS rank, filtered by the given criteria. + * Both MCP and internal AI tools use this for query+filter combinations. + */ + async searchPostsFiltered( + query: string, + filter: PostFilter, + pagination?: PaginationOptions, + ): Promise { + if (!query.trim()) return []; + + const client = getDatabase().getLocalClient(); + if (!client) return []; + + try { + const stemmedQuery = stemQuery(query, this.searchLanguage); + + // Build WHERE clauses and args for the joined query + const conditions: string[] = [ + 'posts_fts.project_id = ?', + 'posts_fts.MATCH ?', + ]; + const args: (string | number | Date)[] = [this.currentProjectId, stemmedQuery]; + + if (filter.status) { + conditions.push('posts.status = ?'); + args.push(filter.status); + } + if (filter.year !== undefined) { + const startOfYear = new Date(filter.year, 0, 1); + const endOfYear = new Date(filter.year + 1, 0, 1); + conditions.push('posts.created_at >= ?'); + args.push(startOfYear); + conditions.push('posts.created_at <= ?'); + args.push(endOfYear); + } + if (filter.month !== undefined && filter.year !== undefined) { + const startOfMonth = new Date(filter.year, filter.month - 1, 1); + const endOfMonth = new Date(filter.year, filter.month, 1); + conditions.push('posts.created_at >= ?'); + args.push(startOfMonth); + conditions.push('posts.created_at <= ?'); + args.push(endOfMonth); + } + if (filter.categories && filter.categories.length > 0) { + const catClauses = filter.categories.map(() => + `EXISTS (SELECT 1 FROM json_each(posts.categories) AS c WHERE c.value = ?)` + ); + conditions.push(`(${catClauses.join(' OR ')})`); + args.push(...filter.categories); + } + if (filter.excludeCategories && filter.excludeCategories.length > 0) { + const exClauses = filter.excludeCategories.map(() => + `EXISTS (SELECT 1 FROM json_each(posts.categories) AS c WHERE c.value = ?)` + ); + conditions.push(`NOT (${exClauses.join(' OR ')})`); + args.push(...filter.excludeCategories); + } + if (filter.startDate) { + conditions.push('posts.created_at >= ?'); + args.push(filter.startDate); + } + if (filter.endDate) { + conditions.push('posts.created_at <= ?'); + args.push(filter.endDate); + } + + const whereClause = conditions.join(' AND '); + const sqlQuery = ` + SELECT posts.* + FROM posts_fts + JOIN posts ON posts_fts.id = posts.id + WHERE ${whereClause} + ORDER BY posts_fts.rank + LIMIT 500 + `; + + const result = await client.execute({ sql: sqlQuery, args }); + + let postDataList: PostData[] = result.rows.map((row) => + this.dbRowToPostData(row as unknown as Post, (row.content as string) || '') + ); + + // Tag filtering is done client-side (tags are stored as JSON arrays) + if (filter.tags && filter.tags.length > 0) { + postDataList = postDataList.filter((p) => + filter.tags!.every((tag) => p.tags.includes(tag)) + ); + } + + // Apply pagination + const offset = pagination?.offset ?? 0; + const limit = pagination?.limit ?? postDataList.length; + return postDataList.slice(offset, offset + limit); + } catch (error) { + console.error('Search with filters failed:', error); + return []; + } + } + async getAvailableTags(): Promise { const allPosts = await this.getAllPostsUnpaginated(); const tags = new Set(); diff --git a/tests/engine/MCPServer.integration.test.ts b/tests/engine/MCPServer.integration.test.ts index 86db727..aa31804 100644 --- a/tests/engine/MCPServer.integration.test.ts +++ b/tests/engine/MCPServer.integration.test.ts @@ -22,6 +22,7 @@ function createMockDeps(): MCPServerDependencies { getLinkedBy: vi.fn().mockResolvedValue([]), getLinksTo: vi.fn().mockResolvedValue([]), getPostsFiltered: vi.fn().mockResolvedValue([]), + searchPostsFiltered: vi.fn().mockResolvedValue([]), }), getMediaEngine: () => ({ getAllMedia: vi.fn().mockResolvedValue([]), diff --git a/tests/engine/MCPServer.test.ts b/tests/engine/MCPServer.test.ts index 9cf4070..909f8f6 100644 --- a/tests/engine/MCPServer.test.ts +++ b/tests/engine/MCPServer.test.ts @@ -29,6 +29,7 @@ function createMockPostEngine() { getAllPosts: vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 }), getPost: vi.fn().mockResolvedValue(null), searchPosts: vi.fn().mockResolvedValue([]), + searchPostsFiltered: 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(), @@ -618,20 +619,49 @@ describe('MCPServer', () => { expect(parsed[0].id).toBe('p3'); }); - it('search_posts with query + filters uses getPostsFiltered and client-side text filter', async () => { - const allFiltered = [ - { id: 'p1', title: 'TypeScript Guide', content: 'Learn TS', excerpt: '' }, - { id: 'p2', title: 'Python Guide', content: 'Learn Python', excerpt: '' }, + it('search_posts with query + filters calls searchPostsFiltered', async () => { + const combined = [ + { id: 'p1', title: 'TypeScript Guide', categories: ['tech'] }, ]; - mockPostEngine.getPostsFiltered.mockResolvedValue(allFiltered); + mockPostEngine.searchPostsFiltered.mockResolvedValue(combined); const mcpServer = server.createMcpServer(); const tool = getTool(mcpServer, 'search_posts'); const result = await tool.handler({ query: 'typescript', category: 'tech' }, {}) as { content: Array<{ text: string }> }; - expect(mockPostEngine.getPostsFiltered).toHaveBeenCalled(); + // Should call searchPostsFiltered, not searchPosts + getPostsFiltered separately + expect(mockPostEngine.searchPostsFiltered).toHaveBeenCalledWith( + 'typescript', + { categories: ['tech'] }, + { offset: 0, limit: 50 }, + ); expect(mockPostEngine.searchPosts).not.toHaveBeenCalled(); + expect(mockPostEngine.getPostsFiltered).not.toHaveBeenCalled(); const parsed = JSON.parse(result.content[0].text); expect(parsed).toHaveLength(1); - expect(parsed[0].title).toBe('TypeScript Guide'); + expect(parsed[0].id).toBe('p1'); + }); + + it('search_posts with query + filters passes pagination to searchPostsFiltered', async () => { + mockPostEngine.searchPostsFiltered.mockResolvedValue([{ id: 'p3', title: 'Result' }]); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'search_posts'); + await tool.handler({ query: 'keyword', status: 'published', offset: 5, limit: 10 }, {}); + expect(mockPostEngine.searchPostsFiltered).toHaveBeenCalledWith( + 'keyword', + { status: 'published' }, + { offset: 5, limit: 10 }, + ); + }); + + it('search_posts with query + multiple filters builds correct filter', async () => { + mockPostEngine.searchPostsFiltered.mockResolvedValue([]); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'search_posts'); + await tool.handler({ query: 'test', category: 'tech', tags: ['js'], year: 2025, status: 'published' }, {}); + expect(mockPostEngine.searchPostsFiltered).toHaveBeenCalledWith( + 'test', + { categories: ['tech'], tags: ['js'], year: 2025, status: 'published' }, + { offset: 0, limit: 50 }, + ); }); it('draft_post creates a draft and stores proposal', async () => { @@ -677,6 +707,18 @@ describe('MCPServer', () => { expect(proposal!.data.mediaId).toBe('img-1'); }); + it('propose_media_metadata returns error for non-existent media', async () => { + mockMediaEngine.getMedia.mockResolvedValue(null); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'propose_media_metadata'); + const result = await tool.handler({ mediaId: 'no-such', alt: 'New alt' }, {}) as { content: Array<{ text: string }>; isError?: boolean }; + expect(result.isError).toBe(true); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.error).toContain('not found'); + // No proposal should be created + expect(server.proposalStore.getAll()).toHaveLength(0); + }); + it('propose_post_metadata loads current post and stores proposal', async () => { const currentPost = { id: 'post-1', title: 'Old Title', excerpt: 'Old excerpt' }; mockPostEngine.getPost.mockResolvedValue(currentPost); @@ -689,6 +731,48 @@ describe('MCPServer', () => { const proposal = server.proposalStore.get(parsed.proposalId); expect(proposal!.type).toBe('proposePostMetadata'); }); + + it('propose_post_metadata returns error for non-existent post', async () => { + mockPostEngine.getPost.mockResolvedValue(null); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'propose_post_metadata'); + const result = await tool.handler({ postId: 'no-such', title: 'New Title' }, {}) as { content: Array<{ text: string }>; isError?: boolean }; + expect(result.isError).toBe(true); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.error).toContain('not found'); + expect(server.proposalStore.getAll()).toHaveLength(0); + }); + + it('draft_post returns error when createPost fails', async () => { + mockPostEngine.createPost.mockRejectedValue(new Error('No active project')); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'draft_post'); + const result = await tool.handler({ title: 'Test', content: '# Hello' }, {}) as { content: Array<{ text: string }>; isError?: boolean }; + expect(result.isError).toBe(true); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.error).toContain('No active project'); + expect(server.proposalStore.getAll()).toHaveLength(0); + }); + + it('propose_media_metadata returns error when getMedia throws', async () => { + mockMediaEngine.getMedia.mockRejectedValue(new Error('DB error')); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'propose_media_metadata'); + const result = await tool.handler({ mediaId: 'img-1', alt: 'New' }, {}) as { content: Array<{ text: string }>; isError?: boolean }; + expect(result.isError).toBe(true); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.error).toContain('DB error'); + }); + + it('propose_post_metadata returns error when getPost throws', async () => { + mockPostEngine.getPost.mockRejectedValue(new Error('DB error')); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'propose_post_metadata'); + const result = await tool.handler({ postId: 'p1', title: 'New' }, {}) as { content: Array<{ text: string }>; isError?: boolean }; + expect(result.isError).toBe(true); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.error).toContain('DB error'); + }); }); // ── Prompt handler behavior ──────────────────────────────────────── diff --git a/tests/engine/PostEngine.test.ts b/tests/engine/PostEngine.test.ts index aad10a6..3bc381b 100644 --- a/tests/engine/PostEngine.test.ts +++ b/tests/engine/PostEngine.test.ts @@ -2418,6 +2418,69 @@ Published snapshot content`); }); }); + describe('searchPostsFiltered', () => { + it('should return empty array for empty query', async () => { + const result = await postEngine.searchPostsFiltered('', {}); + expect(result).toEqual([]); + }); + + it('should use FTS JOIN with posts table to combine search and filters', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ + rows: [ + { id: 'p1', projectId: 'test-project', title: 'Found', slug: 'found', excerpt: 'Excerpt', content: 'Content', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '["js"]', categories: '["tech"]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null }, + ], + }); + + const result = await postEngine.searchPostsFiltered('search term', { status: 'published' }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('p1'); + + // Verify SQL includes both MATCH and status filter + const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined; + const sql = call?.sql?.toLowerCase() ?? ''; + expect(sql).toContain('match'); + expect(sql).toContain('status'); + expect(sql).toContain('order by'); + expect(sql).toContain('rank'); + }); + + it('should apply category filter in SQL', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); + + await postEngine.searchPostsFiltered('term', { categories: ['tech'] }); + + const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined; + const sql = call?.sql?.toLowerCase() ?? ''; + expect(sql).toContain('match'); + expect(sql).toContain('json_each'); + }); + + it('should apply tag filter client-side after SQL query', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ + rows: [ + { id: 'p1', projectId: 'test-project', title: 'Has Tag', slug: 'has-tag', excerpt: null, content: '', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '["js", "react"]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null }, + { id: 'p2', projectId: 'test-project', title: 'No Tag', slug: 'no-tag', excerpt: null, content: '', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '["python"]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null }, + ], + }); + + const result = await postEngine.searchPostsFiltered('term', { tags: ['js'] }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('p1'); + }); + + it('should apply pagination with offset and limit', async () => { + const rows = Array.from({ length: 5 }, (_, i) => ({ + id: `p${i}`, projectId: 'test-project', title: `Post ${i}`, slug: `post-${i}`, excerpt: null, content: '', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '[]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null, + })); + mockLocalClient.execute.mockResolvedValueOnce({ rows }); + + const result = await postEngine.searchPostsFiltered('term', {}, { offset: 1, limit: 2 }); + expect(result).toHaveLength(2); + expect(result[0].id).toBe('p1'); + expect(result[1].id).toBe('p2'); + }); + }); + describe('getTagsWithCounts', () => { it('should return empty array when no posts have tags', async () => { vi.mocked(mockLocalDb.select).mockImplementation(() => { From e71e47877687d5b6b4d562fa95196e4801d2bf13 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 28 Feb 2026 11:05:39 +0100 Subject: [PATCH 5/9] feat: more on mcp server --- src/main/engine/MCPServer.ts | 175 ++++++++++++++++++++------- src/main/engine/ScriptEngine.ts | 44 +++++++ src/main/main.ts | 14 +-- tests/engine/MCPServer.test.ts | 190 +++++++++++++++++++++++++++++- tests/engine/ScriptEngine.test.ts | 68 +++++++++++ 5 files changed, 437 insertions(+), 54 deletions(-) diff --git a/src/main/engine/MCPServer.ts b/src/main/engine/MCPServer.ts index c167e97..a5f09d2 100644 --- a/src/main/engine/MCPServer.ts +++ b/src/main/engine/MCPServer.ts @@ -14,60 +14,94 @@ import { reviewTemplateHtml, reviewMetadataHtml, } from './mcp-views'; +import type { + PostData, + PostFilter, + SearchResult, + PaginatedResult, + PaginationOptions, +} from './PostEngine'; +import type { MediaData } from './MediaEngine'; +import type { CreateScriptInput, ScriptData, ScriptValidationResult } from './ScriptEngine'; +import type { CreateTemplateInput, TemplateData, TemplateValidationResult } from './TemplateEngine'; +import type { ProjectMetadata } from './MetaEngine'; +import type { PostMediaLinkData } from './PostMediaEngine'; +import type { TagWithCount } from './TagEngine'; + +// ── Pagination helpers ───────────────────────────────────────────────── + +export const DEFAULT_PAGE_SIZE = 50; + +export function encodeCursor(offset: number): string { + return Buffer.from(String(offset)).toString('base64url'); +} + +export function decodeCursor(cursor: string): number { + try { + const offset = parseInt(Buffer.from(cursor, 'base64url').toString(), 10); + return Number.isNaN(offset) || offset < 0 ? 0 : offset; + } catch { + return 0; + } +} // ── Dependency contracts ────────────────────────────────────────────── -export interface PostFilter { - status?: 'draft' | 'published' | 'archived'; - tags?: string[]; - categories?: string[]; - year?: number; - month?: number; -} - interface PostEngineContract { - getAllPosts: (options?: { limit?: number; offset?: number }) => Promise<{ items: Array>; hasMore: boolean; total: number }>; - getPost: (id: string) => Promise | null>; - searchPosts: (query: string) => Promise>; - searchPostsFiltered: (query: string, filter: PostFilter, pagination?: { offset?: number; limit?: number }) => Promise>>; - createPost: (data: Record) => Promise>; - updatePost: (id: string, data: Record) => Promise | null>; - publishPost: (id: string) => Promise | null>; + getAllPosts: (options?: PaginationOptions) => Promise>; + getPost: (id: string) => Promise; + searchPosts: (query: string) => Promise; + searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise; + createPost: (data: Partial) => Promise; + updatePost: (id: string, data: Partial) => Promise; + publishPost: (id: string) => Promise; deletePost: (id: string) => Promise; getTagsWithCounts: () => Promise>; getCategoriesWithCounts: () => Promise>; - getBlogStats: () => Promise>; + getBlogStats: () => Promise<{ + totalPosts: number; + draftCount: number; + publishedCount: number; + archivedCount: number; + oldestPostDate: Date | null; + newestPostDate: Date | null; + postsPerYear: Record; + tagCount: number; + categoryCount: number; + }>; getLinkedBy: (postId: string) => Promise>; getLinksTo: (postId: string) => Promise>; - getPostsFiltered: (filter: PostFilter) => Promise>>; + getPostsFiltered: (filter: PostFilter) => Promise; } interface MediaEngineContract { - getAllMedia: () => Promise>>; - getMedia: (id: string) => Promise | null>; - updateMedia: (id: string, data: Record) => Promise | null>; + getAllMedia: () => Promise; + getMedia: (id: string) => Promise; + updateMedia: (id: string, data: Partial) => Promise; getThumbnailDataUrl: (mediaId: string, size: 'small' | 'medium' | 'large') => Promise; } interface ScriptEngineContract { - createScript: (input: Record) => Promise>; + createScript: (input: CreateScriptInput) => Promise; + validateScript: (content: string) => Promise; } interface TemplateEngineContract { - createTemplate: (input: Record) => Promise>; + createTemplate: (input: CreateTemplateInput) => Promise; + validateTemplate: (content: string) => Promise; } interface MetaEngineContract { - getProjectMetadata: () => Promise | null>; + getProjectMetadata: () => Promise; } interface PostMediaEngineContract { - getLinkedMediaDataForPost: (postId: string) => Promise>>; - getLinkedPostsForMedia: (mediaId: string) => Promise>>; + getLinkedMediaDataForPost: (postId: string) => Promise>; + getLinkedPostsForMedia: (mediaId: string) => Promise; } interface TagEngineContract { - getTagsWithCounts: () => Promise>>; + getTagsWithCounts: () => Promise; } export interface MCPServerDependencies { @@ -80,6 +114,23 @@ export interface MCPServerDependencies { getTagEngine: () => TagEngineContract; } +/** + * Maps each proposal type to the shape of its stored data. + * Used to recover type safety when reading from the generic ProposalStore. + */ +export interface ProposalDataMap { + draftPost: { postId: string }; + proposeScript: CreateScriptInput; + proposeTemplate: CreateTemplateInput; + proposeMediaMetadata: { mediaId: string; changes: Partial }; + proposePostMetadata: { postId: string; changes: Partial }; +} + +/** Type-safe accessor for proposal data (bridges the generic ProposalStore). */ +function proposalData(proposal: { data: Record }): ProposalDataMap[T] { + return proposal.data as unknown as ProposalDataMap[T]; +} + // ── MCPServer engine ────────────────────────────────────────────────── export class MCPServer { @@ -228,27 +279,25 @@ export class MCPServer { try { switch (proposal.type) { case 'draftPost': { - const postId = proposal.data.postId as string; + const { postId } = proposalData<'draftPost'>(proposal); await this.deps.getPostEngine().publishPost(postId); break; } case 'proposeScript': { - await this.deps.getScriptEngine().createScript(proposal.data); + await this.deps.getScriptEngine().createScript(proposalData<'proposeScript'>(proposal)); break; } case 'proposeTemplate': { - await this.deps.getTemplateEngine().createTemplate(proposal.data); + await this.deps.getTemplateEngine().createTemplate(proposalData<'proposeTemplate'>(proposal)); break; } case 'proposeMediaMetadata': { - const mediaId = proposal.data.mediaId as string; - const changes = proposal.data.changes as Record; + const { mediaId, changes } = proposalData<'proposeMediaMetadata'>(proposal); await this.deps.getMediaEngine().updateMedia(mediaId, changes); break; } case 'proposePostMetadata': { - const postId = proposal.data.postId as string; - const changes = proposal.data.changes as Record; + const { postId, changes } = proposalData<'proposePostMetadata'>(proposal); await this.deps.getPostEngine().updatePost(postId, changes); break; } @@ -268,7 +317,7 @@ export class MCPServer { try { if (proposal.type === 'draftPost') { - const postId = proposal.data.postId as string; + const { postId } = proposalData<'draftPost'>(proposal); await this.deps.getPostEngine().deletePost(postId); } this.proposalStore.remove(proposalId); @@ -281,14 +330,25 @@ export class MCPServer { // ── 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('posts', 'bds://posts', { description: 'All blog posts (first page)' }, async () => { + const result = await this.deps.getPostEngine().getAllPosts({ limit: DEFAULT_PAGE_SIZE }); + const response: Record = { ...result }; + if (result.hasMore) { + response.nextCursor = encodeCursor(DEFAULT_PAGE_SIZE); + } + return { contents: [{ uri: 'bds://posts', mimeType: 'application/json', text: JSON.stringify(response) }] }; }); - 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('media', 'bds://media', { description: 'All media files (first page)' }, async () => { + const allMedia = await this.deps.getMediaEngine().getAllMedia(); + const items = allMedia.slice(0, DEFAULT_PAGE_SIZE); + const total = allMedia.length; + const hasMore = DEFAULT_PAGE_SIZE < total; + const response: Record = { items, total, hasMore }; + if (hasMore) { + response.nextCursor = encodeCursor(DEFAULT_PAGE_SIZE); + } + return { contents: [{ uri: 'bds://media', mimeType: 'application/json', text: JSON.stringify(response) }] }; }); server.registerResource('tags', 'bds://tags', { description: 'Tags with post counts' }, async () => { @@ -308,6 +368,31 @@ export class MCPServer { } private registerResourceTemplates(server: McpServer): void { + // ── Pagination templates ── + server.registerResource('posts-page', new ResourceTemplate('bds://posts{?cursor}', { list: undefined }), { description: 'Paginated blog posts (use cursor from previous page)' }, async (uri, { cursor }) => { + const offset = decodeCursor(cursor as string); + const result = await this.deps.getPostEngine().getAllPosts({ limit: DEFAULT_PAGE_SIZE, offset }); + const response: Record = { ...result }; + if (result.hasMore) { + response.nextCursor = encodeCursor(offset + DEFAULT_PAGE_SIZE); + } + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(response) }] }; + }); + + server.registerResource('media-page', new ResourceTemplate('bds://media{?cursor}', { list: undefined }), { description: 'Paginated media files (use cursor from previous page)' }, async (uri, { cursor }) => { + const offset = decodeCursor(cursor as string); + const allMedia = await this.deps.getMediaEngine().getAllMedia(); + const items = allMedia.slice(offset, offset + DEFAULT_PAGE_SIZE); + const total = allMedia.length; + const hasMore = offset + DEFAULT_PAGE_SIZE < total; + const response: Record = { items, total, hasMore }; + if (hasMore) { + response.nextCursor = encodeCursor(offset + DEFAULT_PAGE_SIZE); + } + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(response) }] }; + }); + + // ── Entity templates ── 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) }] }; @@ -341,7 +426,7 @@ export class MCPServer { server.registerResource('media-image', new ResourceTemplate('bds://media/{id}/image', { list: undefined }), { description: 'Image thumbnail (medium size, base64 WebP) for visual context' }, async (uri, { id }) => { const mediaId = id as string; const media = await this.deps.getMediaEngine().getMedia(mediaId); - if (!media || !(media as Record).mimeType || !String((media as Record).mimeType).startsWith('image/')) { + if (!media || !media.mimeType.startsWith('image/')) { return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'Not an image or media not found' }] }; } const dataUrl = await this.deps.getMediaEngine().getThumbnailDataUrl(mediaId, 'medium'); @@ -433,7 +518,7 @@ export class MCPServer { author: args.author, status: 'draft', }); - const proposalId = this.proposalStore.create('draftPost', { postId: (post as Record).id }); + const proposalId = this.proposalStore.create('draftPost', { postId: post.id }); return { content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, post }) }], }; @@ -458,6 +543,7 @@ export class MCPServer { annotations: { readOnlyHint: false, destructiveHint: false }, _meta: { ui: { resourceUri: 'ui://bds/review-script' } }, }, async (args: { title: string; kind: 'macro' | 'utility' | 'transform'; content: string; entrypoint?: string }) => { + const validation = await this.deps.getScriptEngine().validateScript(args.content); const proposalId = this.proposalStore.create('proposeScript', { title: args.title, kind: args.kind, @@ -465,7 +551,7 @@ export class MCPServer { entrypoint: args.entrypoint, }); return { - content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length } }) }], + content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length, syntaxValid: validation.valid, syntaxErrors: validation.errors } }) }], }; }); @@ -481,13 +567,14 @@ export class MCPServer { annotations: { readOnlyHint: false, destructiveHint: false }, _meta: { ui: { resourceUri: 'ui://bds/review-template' } }, }, async (args: { title: string; kind: 'post' | 'list' | 'not-found' | 'partial'; content: string }) => { + const validation = await this.deps.getTemplateEngine().validateTemplate(args.content); 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 } }) }], + content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length, syntaxValid: validation.valid, syntaxErrors: validation.errors } }) }], }; }); diff --git a/src/main/engine/ScriptEngine.ts b/src/main/engine/ScriptEngine.ts index 2072513..87cfecd 100644 --- a/src/main/engine/ScriptEngine.ts +++ b/src/main/engine/ScriptEngine.ts @@ -57,6 +57,11 @@ export interface ScriptReconcileResult { processedFiles: number; } +export interface ScriptValidationResult { + valid: boolean; + errors: string[]; +} + interface ParsedScriptFile { metadata: { id?: string; @@ -86,6 +91,45 @@ export class ScriptEngine extends EventEmitter { return this.currentProjectId; } + async validateScript(content: string): Promise { + const script = [ + 'import ast, sys, json', + 'code = sys.stdin.read()', + 'try:', + ' ast.parse(code)', + ' json.dump({"valid": True, "errors": []}, sys.stdout)', + 'except SyntaxError as e:', + ' msg = e.msg or "invalid syntax"', + ' line = e.lineno or 1', + ' col = e.offset or 1', + ' json.dump({"valid": False, "errors": [f"{msg} (line {line}, col {col})"]}, sys.stdout)', + ].join('\n'); + + try { + const { execFile } = await import('node:child_process'); + return await new Promise((resolve) => { + const proc = execFile('python3', ['-c', script], { timeout: 5000 }, (error, stdout) => { + if (error && !stdout) { + // python3 not available or timed out — assume valid (can't check) + resolve({ valid: true, errors: [] }); + return; + } + try { + const result = JSON.parse(stdout); + resolve({ valid: !!result.valid, errors: Array.isArray(result.errors) ? result.errors : [] }); + } catch { + resolve({ valid: true, errors: [] }); + } + }); + proc.stdin?.write(content); + proc.stdin?.end(); + }); + } catch { + // Dynamic import failed — assume valid + return { valid: true, errors: [] }; + } + } + async createScript(input: CreateScriptInput): Promise { const now = new Date(); const allScripts = await this.getAllScriptRows(); diff --git a/src/main/main.ts b/src/main/main.ts index 28ec7a1..69c3a3c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -871,13 +871,13 @@ app.whenReady().then(async () => { } try { const mcpServer = getMCPServer({ - getPostEngine: () => getPostEngine() as never, - getMediaEngine: () => getMediaEngine() as never, - getScriptEngine: () => getScriptEngine() as never, - getTemplateEngine: () => getTemplateEngine() as never, - getMetaEngine: () => getMetaEngine() as never, - getPostMediaEngine: () => getPostMediaEngine() as never, - getTagEngine: () => getTagEngine() as never, + getPostEngine: () => getPostEngine(), + getMediaEngine: () => getMediaEngine(), + getScriptEngine: () => getScriptEngine(), + getTemplateEngine: () => getTemplateEngine(), + getMetaEngine: () => getMetaEngine(), + getPostMediaEngine: () => getPostMediaEngine(), + getTagEngine: () => getTagEngine(), }); await mcpServer.start(MCP_SERVER_PORT); } catch (error) { diff --git a/tests/engine/MCPServer.test.ts b/tests/engine/MCPServer.test.ts index 909f8f6..e0d92ab 100644 --- a/tests/engine/MCPServer.test.ts +++ b/tests/engine/MCPServer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { MCPServer, type MCPServerDependencies } from '../../src/main/engine/MCPServer'; +import { MCPServer, type MCPServerDependencies, DEFAULT_PAGE_SIZE, encodeCursor, decodeCursor } from '../../src/main/engine/MCPServer'; // Mock all engine singletons vi.mock('../../src/main/engine/PostEngine', () => ({ @@ -65,6 +65,7 @@ function createMockScriptEngine() { entrypoint: 'main.py', content: '', enabled: true, version: 1, filePath: '/test', createdAt: new Date(), updatedAt: new Date(), }), + validateScript: vi.fn().mockResolvedValue({ valid: true, errors: [] }), }; } @@ -75,6 +76,7 @@ function createMockTemplateEngine() { enabled: true, version: 1, filePath: '/test', content: '', createdAt: new Date(), updatedAt: new Date(), }), + validateTemplate: vi.fn().mockResolvedValue({ valid: true, errors: [] }), }; } @@ -270,6 +272,34 @@ describe('MCPServer', () => { const mcpServer = server.createMcpServer(); expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'media-image')).toBe(true); }); + + it('registers posts-page resource template for pagination', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'posts-page')).toBe(true); + }); + + it('registers media-page resource template for pagination', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredResourceTemplates', 'media-page')).toBe(true); + }); + }); + + describe('cursor encoding', () => { + it('round-trips offset through encode/decode', () => { + expect(decodeCursor(encodeCursor(0))).toBe(0); + expect(decodeCursor(encodeCursor(50))).toBe(50); + expect(decodeCursor(encodeCursor(999))).toBe(999); + }); + + it('returns 0 for invalid cursor', () => { + expect(decodeCursor('!!!invalid!!!')).toBe(0); + expect(decodeCursor('')).toBe(0); + }); + + it('returns 0 for negative offset', () => { + // Encoding a negative offset then decoding should clamp to 0 + expect(decodeCursor(encodeCursor(-5))).toBe(0); + }); }); describe('registered prompts', () => { @@ -490,16 +520,71 @@ describe('MCPServer', () => { return (mcpServer as Record Promise }>>)._registeredResourceTemplates[name]; } - it('bds://posts calls getAllPosts and returns JSON', async () => { + it('bds://posts calls getAllPosts with pagination limit and returns JSON', async () => { const postsData = { items: [{ id: 'p1', title: 'Hello' }], hasMore: false, total: 1 }; mockPostEngine.getAllPosts.mockResolvedValue(postsData); const mcpServer = server.createMcpServer(); const resource = getResource(mcpServer, 'bds://posts'); const result = await resource.readCallback(new URL('bds://posts'), {}) as { contents: Array<{ text: string }> }; - expect(mockPostEngine.getAllPosts).toHaveBeenCalled(); + expect(mockPostEngine.getAllPosts).toHaveBeenCalledWith({ limit: DEFAULT_PAGE_SIZE }); expect(JSON.parse(result.contents[0].text)).toEqual(postsData); }); + it('bds://posts includes nextCursor when hasMore is true', async () => { + const postsData = { + items: Array.from({ length: DEFAULT_PAGE_SIZE }, (_, i) => ({ id: `p${i}` })), + hasMore: true, + total: 120, + }; + mockPostEngine.getAllPosts.mockResolvedValue(postsData); + const mcpServer = server.createMcpServer(); + const resource = getResource(mcpServer, 'bds://posts'); + const result = await resource.readCallback(new URL('bds://posts'), {}) as { contents: Array<{ text: string }> }; + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.nextCursor).toBeTruthy(); + expect(parsed.hasMore).toBe(true); + expect(parsed.total).toBe(120); + // Cursor should decode to the next offset + expect(decodeCursor(parsed.nextCursor)).toBe(DEFAULT_PAGE_SIZE); + }); + + it('bds://posts omits nextCursor when hasMore is false', async () => { + const postsData = { items: [{ id: 'p1' }], hasMore: false, total: 1 }; + mockPostEngine.getAllPosts.mockResolvedValue(postsData); + const mcpServer = server.createMcpServer(); + const resource = getResource(mcpServer, 'bds://posts'); + const result = await resource.readCallback(new URL('bds://posts'), {}) as { contents: Array<{ text: string }> }; + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.nextCursor).toBeUndefined(); + }); + + it('bds://media returns paginated response with items, total, hasMore', async () => { + const allMedia = Array.from({ length: 3 }, (_, i) => ({ id: `m${i}` })); + mockMediaEngine.getAllMedia.mockResolvedValue(allMedia); + const mcpServer = server.createMcpServer(); + const resource = getResource(mcpServer, 'bds://media'); + const result = await resource.readCallback(new URL('bds://media'), {}) as { contents: Array<{ text: string }> }; + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.items).toHaveLength(3); + expect(parsed.total).toBe(3); + expect(parsed.hasMore).toBe(false); + expect(parsed.nextCursor).toBeUndefined(); + }); + + it('bds://media includes nextCursor when more items than page size', async () => { + const allMedia = Array.from({ length: DEFAULT_PAGE_SIZE + 10 }, (_, i) => ({ id: `m${i}` })); + mockMediaEngine.getAllMedia.mockResolvedValue(allMedia); + const mcpServer = server.createMcpServer(); + const resource = getResource(mcpServer, 'bds://media'); + const result = await resource.readCallback(new URL('bds://media'), {}) as { contents: Array<{ text: string }> }; + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.items).toHaveLength(DEFAULT_PAGE_SIZE); + expect(parsed.total).toBe(DEFAULT_PAGE_SIZE + 10); + expect(parsed.hasMore).toBe(true); + expect(parsed.nextCursor).toBeTruthy(); + expect(decodeCursor(parsed.nextCursor)).toBe(DEFAULT_PAGE_SIZE); + }); + it('bds://stats calls getBlogStats and returns JSON', async () => { const stats = { totalPosts: 42 }; mockPostEngine.getBlogStats.mockResolvedValue(stats); @@ -519,6 +604,61 @@ describe('MCPServer', () => { expect(JSON.parse(result.contents[0].text)).toEqual(post); }); + it('posts-page template decodes cursor and passes offset to getAllPosts', async () => { + const cursor = encodeCursor(50); + mockPostEngine.getAllPosts.mockResolvedValue({ items: [{ id: 'p50' }], hasMore: false, total: 60 }); + const mcpServer = server.createMcpServer(); + const tpl = getResourceTemplate(mcpServer, 'posts-page'); + const result = await tpl.readCallback(new URL(`bds://posts?cursor=${cursor}`), { cursor }, {}) as { contents: Array<{ text: string }> }; + expect(mockPostEngine.getAllPosts).toHaveBeenCalledWith({ limit: DEFAULT_PAGE_SIZE, offset: 50 }); + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.items).toEqual([{ id: 'p50' }]); + expect(parsed.nextCursor).toBeUndefined(); + }); + + it('posts-page template includes nextCursor for subsequent pages', async () => { + const cursor = encodeCursor(50); + mockPostEngine.getAllPosts.mockResolvedValue({ + items: Array.from({ length: DEFAULT_PAGE_SIZE }, (_, i) => ({ id: `p${50 + i}` })), + hasMore: true, + total: 200, + }); + const mcpServer = server.createMcpServer(); + const tpl = getResourceTemplate(mcpServer, 'posts-page'); + const result = await tpl.readCallback(new URL(`bds://posts?cursor=${cursor}`), { cursor }, {}) as { contents: Array<{ text: string }> }; + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.nextCursor).toBeTruthy(); + expect(decodeCursor(parsed.nextCursor)).toBe(100); + }); + + it('media-page template decodes cursor and returns correct slice', async () => { + const allMedia = Array.from({ length: 80 }, (_, i) => ({ id: `m${i}` })); + mockMediaEngine.getAllMedia.mockResolvedValue(allMedia); + const cursor = encodeCursor(50); + const mcpServer = server.createMcpServer(); + const tpl = getResourceTemplate(mcpServer, 'media-page'); + const result = await tpl.readCallback(new URL(`bds://media?cursor=${cursor}`), { cursor }, {}) as { contents: Array<{ text: string }> }; + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.items).toHaveLength(30); + expect(parsed.items[0].id).toBe('m50'); + expect(parsed.total).toBe(80); + expect(parsed.hasMore).toBe(false); + expect(parsed.nextCursor).toBeUndefined(); + }); + + it('media-page template includes nextCursor when more pages remain', async () => { + const allMedia = Array.from({ length: 120 }, (_, i) => ({ id: `m${i}` })); + mockMediaEngine.getAllMedia.mockResolvedValue(allMedia); + const cursor = encodeCursor(0); + const mcpServer = server.createMcpServer(); + const tpl = getResourceTemplate(mcpServer, 'media-page'); + const result = await tpl.readCallback(new URL(`bds://media?cursor=${cursor}`), { cursor }, {}) as { contents: Array<{ text: string }> }; + const parsed = JSON.parse(result.contents[0].text); + expect(parsed.items).toHaveLength(DEFAULT_PAGE_SIZE); + expect(parsed.hasMore).toBe(true); + expect(decodeCursor(parsed.nextCursor)).toBe(DEFAULT_PAGE_SIZE); + }); + it('bds://posts/{id}/media calls getLinkedMediaDataForPost', async () => { const linkedMedia = [{ id: 'm1', filename: 'photo.jpg' }]; mockPostMediaEngine.getLinkedMediaDataForPost.mockResolvedValue(linkedMedia); @@ -693,6 +833,50 @@ describe('MCPServer', () => { expect(proposal!.data.content).toBe('print("hi")'); }); + it('propose_script calls validateScript and includes validation result in preview', async () => { + mockScriptEngine.validateScript.mockResolvedValue({ valid: true, errors: [] }); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'propose_script'); + const result = await tool.handler({ title: 'Valid Script', kind: 'macro', content: 'print("hello")' }, {}) as { content: Array<{ text: string }> }; + expect(mockScriptEngine.validateScript).toHaveBeenCalledWith('print("hello")'); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.preview.syntaxValid).toBe(true); + expect(parsed.preview.syntaxErrors).toEqual([]); + }); + + it('propose_script includes syntax errors in preview when validation fails', async () => { + mockScriptEngine.validateScript.mockResolvedValue({ valid: false, errors: ['invalid syntax (line 1, col 6)'] }); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'propose_script'); + const result = await tool.handler({ title: 'Bad Script', kind: 'macro', content: 'def (' }, {}) as { content: Array<{ text: string }> }; + const parsed = JSON.parse(result.content[0].text); + expect(parsed.proposalId).toBeTruthy(); + expect(parsed.preview.syntaxValid).toBe(false); + expect(parsed.preview.syntaxErrors).toEqual(['invalid syntax (line 1, col 6)']); + }); + + it('propose_template calls validateTemplate and includes validation result in preview', async () => { + mockTemplateEngine.validateTemplate.mockResolvedValue({ valid: true, errors: [] }); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'propose_template'); + const result = await tool.handler({ title: 'Valid Template', kind: 'post', content: '

{{ title }}

' }, {}) as { content: Array<{ text: string }> }; + expect(mockTemplateEngine.validateTemplate).toHaveBeenCalledWith('

{{ title }}

'); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.preview.syntaxValid).toBe(true); + expect(parsed.preview.syntaxErrors).toEqual([]); + }); + + it('propose_template includes syntax errors in preview when validation fails', async () => { + mockTemplateEngine.validateTemplate.mockResolvedValue({ valid: false, errors: ['tag "{% invalid" not closed'] }); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'propose_template'); + const result = await tool.handler({ title: 'Bad Template', kind: 'post', content: '{% invalid' }, {}) as { content: Array<{ text: string }> }; + const parsed = JSON.parse(result.content[0].text); + expect(parsed.proposalId).toBeTruthy(); + expect(parsed.preview.syntaxValid).toBe(false); + expect(parsed.preview.syntaxErrors).toEqual(['tag "{% invalid" not closed']); + }); + it('propose_media_metadata loads current media and stores proposal', async () => { const currentMedia = { id: 'img-1', alt: 'Old alt', title: 'Old title' }; mockMediaEngine.getMedia.mockResolvedValue(currentMedia); diff --git a/tests/engine/ScriptEngine.test.ts b/tests/engine/ScriptEngine.test.ts index fa450cb..36d6f86 100644 --- a/tests/engine/ScriptEngine.test.ts +++ b/tests/engine/ScriptEngine.test.ts @@ -2,6 +2,15 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import * as fs from 'fs/promises'; import { ScriptEngine } from '../../src/main/engine/ScriptEngine'; +const { mockExecFile } = vi.hoisted(() => ({ + mockExecFile: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + execFile: mockExecFile, + default: { execFile: mockExecFile }, +})); + const mockScripts = new Map(); const mockFiles = new Map(); @@ -377,4 +386,63 @@ describe('ScriptEngine', () => { expect(found).toBeNull(); }); }); + + describe('validateScript', () => { + it('returns valid for correct Python syntax', async () => { + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: null, stdout: string) => void) => { + cb(null, JSON.stringify({ valid: true, errors: [] })); + return { stdin: { write: vi.fn(), end: vi.fn() } }; + }); + + const result = await scriptEngine.validateScript('print("hello")'); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('returns invalid with errors for bad Python syntax', async () => { + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: null, stdout: string) => void) => { + cb(null, JSON.stringify({ valid: false, errors: ['invalid syntax (line 1, col 5)'] })); + return { stdin: { write: vi.fn(), end: vi.fn() } }; + }); + + const result = await scriptEngine.validateScript('def ('); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(['invalid syntax (line 1, col 5)']); + }); + + it('returns valid when python3 is not available', async () => { + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error, stdout: string) => void) => { + cb(new Error('spawn python3 ENOENT'), ''); + return { stdin: { write: vi.fn(), end: vi.fn() } }; + }); + + const result = await scriptEngine.validateScript('print("hello")'); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('returns valid when python3 output is not parseable JSON', async () => { + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: null, stdout: string) => void) => { + cb(null, 'not json'); + return { stdin: { write: vi.fn(), end: vi.fn() } }; + }); + + const result = await scriptEngine.validateScript('print("hello")'); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('passes script content via stdin', async () => { + const writeFn = vi.fn(); + const endFn = vi.fn(); + mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: null, stdout: string) => void) => { + cb(null, JSON.stringify({ valid: true, errors: [] })); + return { stdin: { write: writeFn, end: endFn } }; + }); + + await scriptEngine.validateScript('x = 42'); + expect(writeFn).toHaveBeenCalledWith('x = 42'); + expect(endFn).toHaveBeenCalled(); + }); + }); }); From 6c22e698057c279aadcc9ae0ba64ec98782fd6a7 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 28 Feb 2026 12:36:13 +0100 Subject: [PATCH 6/9] feat: more work on mcp server integration --- .github/copilot-instructions.md => AGENTS.md | 17 +- CLAUDE.md | 150 +------- GEMINI.md | 149 -------- package.json | 4 + .../engine/GenerationRouteRendererFactory.ts | 2 +- src/main/engine/MCPAgentConfigEngine.ts | 156 ++++++++ src/main/engine/MediaEngine.ts | 8 +- src/main/engine/OpenCodeManager.ts | 24 +- src/main/engine/PostEngine.ts | 6 +- src/main/engine/SharedRouteRenderer.ts | 4 +- src/main/engine/SharedSnapshotService.ts | 2 +- src/main/engine/mcp-views.ts | 299 ++++----------- .../engine/mcp-views/review-metadata.html | 132 +++++++ src/main/engine/mcp-views/review-post.html | 124 +++++++ src/main/engine/mcp-views/review-script.html | 116 ++++++ .../engine/mcp-views/review-template.html | 116 ++++++ src/main/ipc/handlers.ts | 51 +++ src/main/preload.ts | 7 + src/main/shared/electronApi.ts | 6 + src/renderer/components/Editor/Editor.tsx | 2 +- .../components/SettingsView/SettingsView.tsx | 100 +++++- src/renderer/components/Sidebar/Sidebar.tsx | 13 +- src/renderer/i18n/locales/de.json | 18 +- src/renderer/i18n/locales/en.json | 18 +- src/renderer/i18n/locales/es.json | 18 +- src/renderer/i18n/locales/fr.json | 18 +- src/renderer/i18n/locales/it.json | 18 +- .../GenerationRouteRendererFactory.test.ts | 2 +- tests/engine/MCPConfigEngine.test.ts | 339 ++++++++++++++++++ tests/engine/MCPServer.integration.test.ts | 2 + tests/engine/MediaEngine.test.ts | 10 +- tests/engine/PostEngine.test.ts | 10 +- tests/engine/PreviewServer.test.ts | 2 +- tests/engine/SharedSnapshotService.test.ts | 6 +- tests/engine/mainStartup.test.ts | 30 +- tests/engine/mcp-views.test.ts | 76 ++-- 36 files changed, 1420 insertions(+), 635 deletions(-) rename .github/copilot-instructions.md => AGENTS.md (93%) delete mode 100644 GEMINI.md create mode 100644 src/main/engine/MCPAgentConfigEngine.ts create mode 100644 src/main/engine/mcp-views/review-metadata.html create mode 100644 src/main/engine/mcp-views/review-post.html create mode 100644 src/main/engine/mcp-views/review-script.html create mode 100644 src/main/engine/mcp-views/review-template.html create mode 100644 tests/engine/MCPConfigEngine.test.ts diff --git a/.github/copilot-instructions.md b/AGENTS.md similarity index 93% rename from .github/copilot-instructions.md rename to AGENTS.md index 916b356..a2c9b63 100644 --- a/.github/copilot-instructions.md +++ b/AGENTS.md @@ -1,16 +1,11 @@ -# GitHub Copilot Instructions for Blogging Desktop Server (bDS) +# Agents Instructions for Blogging Desktop Server (bDS) This document provides context and best practices for GitHub Copilot when working on this Electron + TypeScript + SQLite blogging application. -## Project Overview +## Plan Mode -**Blogging Desktop Server (bDS)** is a desktop blogging application built with: -- **Electron** v28+ for cross-platform desktop -- **TypeScript** for all code (strict mode) -- **React** for the renderer UI -- **Drizzle ORM** for type-safe database access -- **@libsql/client** for SQLite (local database) -- **Zustand** for React state management +- Make the plan extremely concise. Sacrifice grammar for the sake of concision. +- At the end of each plan, give me a list of unresolved questions to answer, if any. --- @@ -143,7 +138,3 @@ This document provides context and best practices for GitHub Copilot when workin - Store Dropbox auth tokens in secure storage, not in code - Sanitize user input before rendering (XSS prevention) -## Plan Mode - -- Make the plan extremely concise. Sacrifice grammar for the sake of concision. -- At the end of each plan, give me a list of unresolved questions to answer, if any. diff --git a/CLAUDE.md b/CLAUDE.md index 916b356..43c994c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,149 +1 @@ -# GitHub Copilot Instructions for Blogging Desktop Server (bDS) - -This document provides context and best practices for GitHub Copilot when working on this Electron + TypeScript + SQLite blogging application. - -## Project Overview - -**Blogging Desktop Server (bDS)** is a desktop blogging application built with: -- **Electron** v28+ for cross-platform desktop -- **TypeScript** for all code (strict mode) -- **React** for the renderer UI -- **Drizzle ORM** for type-safe database access -- **@libsql/client** for SQLite (local database) -- **Zustand** for React state management - ---- - -## ⚠️ MANDATORY: Test-First Development - -**STOP!** Before writing ANY implementation code, you MUST: - -1. **Write a failing test first** that describes the expected behavior -2. **Run the test** to confirm it fails (Red) -3. **Write minimal code** to make the test pass (Green) -4. **Refactor** while keeping tests green - -> **No code without tests. No exceptions.** -> -> Tests must import and exercise the REAL implementation classes, not inline helper functions. -> Mock only external dependencies (database, filesystem), never the class under test. - ---- - -## ⚠️ MANDATORY: Fix All Test Failures - -**You MUST investigate and fix ALL test failures before completing any task.** - -- Never leave tests failing, even if they appear unrelated to your changes -- If a test failure is pre-existing, fix it as part of your current work -- Run the full test suite (`npm test`) before considering any task complete -- If you cannot fix a test, explain why and propose a solution - -> **Zero failing tests. No exceptions.** - ---- - -## ⚠️ MANDATORY: Remove Unused Code - -**Never keep unused code around. Always delete it completely.** - -- When a feature is removed, delete ALL related code (implementation, tests, types, configs) -- Do NOT comment out code "for later" - use version control history -- Do NOT skip tests for removed functionality - delete them -- Do NOT leave dead code paths, unused imports, or orphaned functions -- When refactoring, actively look for and remove any code that becomes unused - -> **Delete unused code immediately. No exceptions.** - ---- - -## ⚠️ MANDATORY: Build Verification After Code Changes - -**You MUST run the full build after making code changes.** - -- Run `npm run build` after any code modifications -- Fix ALL build errors before considering the task complete -- Build errors indicate issues that may not be caught by `tsc --noEmit` alone (e.g., event forwarding, renderer build) -- The build must complete successfully before the task is complete - -> **Successful build required. No exceptions.** - ---- - -## ⚠️ MANDATORY: No External JS/CSS in Preview or Generated HTML - -**Do not reference external JavaScript or CSS libraries (CDNs/remote URLs) from the preview server output or generated HTML.** - -- Preview HTML must reference only local/package-bundled assets -- Generated HTML must not include CDN-hosted JS/CSS libraries -- If a library is needed (e.g., Pico CSS, Lightbox), include it as a local dependency and serve/reference it locally -- Avoid introducing any new ` - -`; +export function reviewPostHtml( + options?: Parameters[0], +): string { + return loadViewHtml('review-post.html', options); } -export function reviewScriptHtml(): string { - return ` - -Review Script - - - -
-

Waiting for script data...

-
- - - -`; +export function reviewScriptHtml( + options?: Parameters[0], +): string { + return loadViewHtml('review-script.html', options); } -export function reviewTemplateHtml(): string { - return ` - -Review Template - - - -
-

Waiting for template data...

-
- - - -`; +export function reviewTemplateHtml( + options?: Parameters[0], +): string { + return loadViewHtml('review-template.html', options); } -export function reviewMetadataHtml(): string { - return ` - -Review Metadata Changes - - - -
-

Waiting for metadata data...

-
- - - -`; +export function reviewMetadataHtml( + options?: Parameters[0], +): string { + return loadViewHtml('review-metadata.html', options); } diff --git a/src/main/engine/mcp-views/review-metadata.html b/src/main/engine/mcp-views/review-metadata.html new file mode 100644 index 0000000..701bd4d --- /dev/null +++ b/src/main/engine/mcp-views/review-metadata.html @@ -0,0 +1,132 @@ + + +Review Metadata + + + +
+

Waiting for metadata...

+
+ + + + diff --git a/src/main/engine/mcp-views/review-post.html b/src/main/engine/mcp-views/review-post.html new file mode 100644 index 0000000..d661b87 --- /dev/null +++ b/src/main/engine/mcp-views/review-post.html @@ -0,0 +1,124 @@ + + +Review Post + + + +
+

Waiting for post data...

+
+ + + + diff --git a/src/main/engine/mcp-views/review-script.html b/src/main/engine/mcp-views/review-script.html new file mode 100644 index 0000000..f352bc5 --- /dev/null +++ b/src/main/engine/mcp-views/review-script.html @@ -0,0 +1,116 @@ + + +Review Script + + + +
+

Waiting for script data...

+
+ + + + diff --git a/src/main/engine/mcp-views/review-template.html b/src/main/engine/mcp-views/review-template.html new file mode 100644 index 0000000..7aa7a0d --- /dev/null +++ b/src/main/engine/mcp-views/review-template.html @@ -0,0 +1,116 @@ + + +Review Template + + + +
+

Waiting for template data...

+
+ + + + diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 055a6b1..d703616 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -125,6 +125,16 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean { } } +function buildMcpUrl(): string { + try { + const { getMCPServer } = require('../engine/MCPServer'); + const port = getMCPServer().getPort() ?? 4124; + return `http://127.0.0.1:${port}/mcp`; + } catch { + return 'http://127.0.0.1:4124/mcp'; + } +} + export function registerIpcHandlers(): void { // ============ Git Handlers ============ @@ -1562,6 +1572,47 @@ export function registerIpcHandlers(): void { registerBlogHandlers(safeHandle); registerPublishHandlers(safeHandle); + // ============ MCP Config Handlers ============ + + safeHandle('mcp:getAgents', async () => { + const { MCPAgentConfigEngine } = await import('../engine/MCPAgentConfigEngine'); + const engine = new MCPAgentConfigEngine({ + homeDir: require('os').homedir(), + platform: process.platform, + mcpUrl: buildMcpUrl(), + }); + return engine.getAgents(); + }); + + safeHandle('mcp:addToAgentConfig', async (_event: unknown, agentId: string) => { + const { MCPAgentConfigEngine } = await import('../engine/MCPAgentConfigEngine'); + const engine = new MCPAgentConfigEngine({ + homeDir: require('os').homedir(), + platform: process.platform, + mcpUrl: buildMcpUrl(), + }); + return engine.addToConfig(agentId as import('../engine/MCPAgentConfigEngine').MCPAgentId); + }); + + safeHandle('mcp:isConfigured', async (_event: unknown, agentId: string) => { + const { MCPAgentConfigEngine } = await import('../engine/MCPAgentConfigEngine'); + const engine = new MCPAgentConfigEngine({ + homeDir: require('os').homedir(), + platform: process.platform, + mcpUrl: buildMcpUrl(), + }); + return engine.isConfigured(agentId as import('../engine/MCPAgentConfigEngine').MCPAgentId); + }); + + safeHandle('mcp:getPort', async () => { + try { + const { getMCPServer } = await import('../engine/MCPServer'); + return getMCPServer().getPort(); + } catch { + return null; + } + }); + // ============ Event Forwarding ============ // Forward engine events to renderer diff --git a/src/main/preload.ts b/src/main/preload.ts index dcad04f..fae8e23 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -382,6 +382,13 @@ export const electronAPI: ElectronAPI = { once: (channel: string, callback: (...args: unknown[]) => void) => { ipcRenderer.once(channel, (_event, ...args) => callback(...args)); }, + + mcp: { + getAgents: () => ipcRenderer.invoke('mcp:getAgents'), + addToAgentConfig: (agentId: string) => ipcRenderer.invoke('mcp:addToAgentConfig', agentId), + isConfigured: (agentId: string) => ipcRenderer.invoke('mcp:isConfigured', agentId), + getPort: () => ipcRenderer.invoke('mcp:getPort'), + }, }; contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 0ae58b6..36e5af0 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -836,5 +836,11 @@ export interface ElectronAPI { }; on: (channel: string, callback: (...args: unknown[]) => void) => () => void; once: (channel: string, callback: (...args: unknown[]) => void) => void; + mcp: { + getAgents: () => Promise>; + addToAgentConfig: (agentId: string) => Promise<{ success: boolean; configPath: string; error?: string }>; + isConfigured: (agentId: string) => Promise; + getPort: () => Promise; + }; } diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index ff9abae..54630bd 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -1674,7 +1674,7 @@ const Dashboard: React.FC = () => { {entry.count}
- {monthFormatter.format(new Date(entry.year, entry.month, 1))} + {monthFormatter.format(new Date(entry.year, entry.month - 1, 1))} {entry.year}
diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index 2e32cc0..a5a0183 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -10,7 +10,7 @@ import { import './SettingsView.css'; // Export category IDs for sidebar navigation -export type SettingsCategory = 'project' | 'editor' | 'content' | 'ai' | 'technology' | 'publishing' | 'data'; +export type SettingsCategory = 'project' | 'editor' | 'content' | 'ai' | 'technology' | 'publishing' | 'data' | 'mcp'; // Scroll to a settings section by category ID export const scrollToSettingsSection = (category: SettingsCategory) => { @@ -122,6 +122,62 @@ const SettingSection: React.FC<{ ); }; +/** Small component that shows the MCP server port or "Not running". */ +const MCPStatusBadge: React.FC = () => { + const { t } = useI18n(); + const [port, setPort] = React.useState(null); + + React.useEffect(() => { + window.electronAPI?.mcp?.getPort().then(setPort).catch(() => setPort(null)); + }, []); + + return ( + + {port ? t('settings.mcp.portRunning', { port: String(port) }) : t('settings.mcp.portStopped')} + + ); +}; + +/** Button to add bDS MCP server to an agent's config. Shows "Configured" if already present. */ +const MCPAgentButton: React.FC<{ agentId: string; agentLabel: string }> = ({ agentId, agentLabel }) => { + const { t } = useI18n(); + const [configured, setConfigured] = React.useState(false); + const [loading, setLoading] = React.useState(false); + + React.useEffect(() => { + window.electronAPI?.mcp?.isConfigured(agentId).then(setConfigured).catch(() => setConfigured(false)); + }, [agentId]); + + if (configured) { + return {t('settings.mcp.alreadyConfigured')}; + } + + return ( + + ); +}; + export const SettingsView: React.FC = () => { const { t } = useI18n(); const { @@ -417,6 +473,7 @@ export const SettingsView: React.FC = () => { const technologyKeywords = ['technology', 'python', 'runtime', 'worker', 'webworker', 'main thread', 'execution']; const publishingKeywords = ['publishing', 'ssh', 'deploy', 'server', 'host', 'upload', 'scp', 'rsync']; const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'scripts', 'links', 'folder', 'filesystem']; + const mcpKeywords = ['mcp', 'server', 'agent', 'claude', 'copilot', 'gemini', 'opencode', 'model context protocol', 'coding', 'configuration']; const renderProjectSettings = () => ( { ); + const renderMCPSettings = () => { + const agents = [ + { id: 'claude-code', label: 'Claude Code' }, + { id: 'github-copilot', label: 'GitHub Copilot' }, + { id: 'gemini-cli', label: 'Gemini CLI' }, + { id: 'opencode', label: 'OpenCode' }, + ]; + + return ( + + ); + }; + const renderDataSettings = () => ( <> { sectionHasMatches(aiKeywords) || sectionHasMatches(technologyKeywords) || sectionHasMatches(publishingKeywords) || - sectionHasMatches(dataKeywords); + sectionHasMatches(dataKeywords) || + sectionHasMatches(mcpKeywords); return (
@@ -1429,6 +1524,7 @@ export const SettingsView: React.FC = () => { {renderTechnologySettings()} {renderPublishingSettings()} {renderDataSettings()} + {renderMCPSettings()} ) : (
diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 8b3ed7f..cfab478 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -167,7 +167,7 @@ const CalendarView: React.FC = ({ onDateSelect, selectedYear, onDateSelect(year, month); }} > - {MONTH_NAMES[month]} + {MONTH_NAMES[month - 1]} {count}
))} @@ -374,7 +374,7 @@ const MediaCalendarView: React.FC = ({ onDateSelect, sel onDateSelect(year, month); }} > - {MONTH_NAMES[month]} + {MONTH_NAMES[month - 1]} {count}
))} @@ -1261,7 +1261,7 @@ const SettingsNav: React.FC = () => { const { tabs, activeTabId, openTab } = useAppStore(); const [activeSection, setActiveSection] = useState(() => { const persisted = getPersistedSidebarSection('settings'); - if (persisted === 'project' || persisted === 'editor' || persisted === 'content' || persisted === 'ai' || persisted === 'technology' || persisted === 'publishing' || persisted === 'data') { + if (persisted === 'project' || persisted === 'editor' || persisted === 'content' || persisted === 'ai' || persisted === 'technology' || persisted === 'publishing' || persisted === 'data' || persisted === 'mcp') { return persisted; } return null; @@ -1343,6 +1343,13 @@ const SettingsNav: React.FC = () => { 🗄️ {t('sidebar.nav.data')} + + + + \`;`, + }); } -/** - * Load an MCP view HTML file by name, searching candidate directories. - * Throws if the file cannot be found in any candidate directory. - */ -export function loadViewHtml( - filename: string, - options?: Parameters[0], -): string { - const dirs = resolveMcpViewsDirs(options); - for (const dir of dirs) { - try { - return readFileSync(path.join(dir, filename), 'utf-8'); - } catch { - // try next directory - } - } - throw new Error( - `MCP view "${filename}" not found in any of: ${dirs.join(', ')}`, - ); +/* ── Review Script ──────────────────────────────────────────────────── */ + +export function reviewScriptHtml(): string { + return buildMcpView({ + title: 'Review Script', + waitingMessage: 'Waiting for script data...', + acceptLabel: 'Create Script', + discardLabel: 'Discard', + renderBody: `\ + const p = data.preview || data; + document.getElementById("review").innerHTML = \` +

\${esc(p.title || "Untitled Script")}

+

\${esc(p.kind || "script")}

+

Python Code

+
\${esc(p.content || "(code not included in preview)")}
+
+ + +
+ \`;`, + }); } -export function reviewPostHtml( - options?: Parameters[0], -): string { - return loadViewHtml('review-post.html', options); +/* ── Review Template ────────────────────────────────────────────────── */ + +export function reviewTemplateHtml(): string { + return buildMcpView({ + title: 'Review Template', + waitingMessage: 'Waiting for template data...', + acceptLabel: 'Create Template', + discardLabel: 'Discard', + renderBody: `\ + const p = data.preview || data; + document.getElementById("review").innerHTML = \` +

\${esc(p.title || "Untitled Template")}

+

\${esc(p.kind || "template")}

+

Liquid Template

+
\${esc(p.content || "(template not included in preview)")}
+
+ + +
+ \`;`, + }); } -export function reviewScriptHtml( - options?: Parameters[0], -): string { - return loadViewHtml('review-script.html', options); -} +/* ── Review Metadata ────────────────────────────────────────────────── */ -export function reviewTemplateHtml( - options?: Parameters[0], -): string { - return loadViewHtml('review-template.html', options); -} - -export function reviewMetadataHtml( - options?: Parameters[0], -): string { - return loadViewHtml('review-metadata.html', options); +export function reviewMetadataHtml(): string { + return buildMcpView({ + title: 'Review Metadata', + waitingMessage: 'Waiting for metadata...', + acceptLabel: 'Apply Changes', + discardLabel: 'Discard', + extraCss: `\ + .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; }`, + extraJsHelpers: `function fmt(v) { if (v == null) return "(empty)"; if (Array.isArray(v)) return v.join(", "); return String(v); }`, + renderBody: `\ + const current = data.current || {}; + const proposed = data.proposed || {}; + const fields = Object.keys(proposed); + let rows = fields.map(f => \` + + \${esc(f)} + \${esc(fmt(current[f]))} + \${esc(fmt(proposed[f]))} + + \`).join(""); + document.getElementById("review").innerHTML = \` +

Metadata Changes

+ + + \${rows} +
FieldCurrentProposed
+
+ + +
+ \`;`, + }); } diff --git a/src/main/engine/mcp-views/review-post.html b/src/main/engine/mcp-views/review-post.html deleted file mode 100644 index d661b87..0000000 --- a/src/main/engine/mcp-views/review-post.html +++ /dev/null @@ -1,124 +0,0 @@ - - -Review Post - - - -
-

Waiting for post data...

-
- - - - diff --git a/src/main/engine/mcp-views/review-script.html b/src/main/engine/mcp-views/review-script.html deleted file mode 100644 index f352bc5..0000000 --- a/src/main/engine/mcp-views/review-script.html +++ /dev/null @@ -1,116 +0,0 @@ - - -Review Script - - - -
-

Waiting for script data...

-
- - - - diff --git a/src/main/engine/mcp-views/review-template.html b/src/main/engine/mcp-views/review-template.html deleted file mode 100644 index 7aa7a0d..0000000 --- a/src/main/engine/mcp-views/review-template.html +++ /dev/null @@ -1,116 +0,0 @@ - - -Review Template - - - -
-

Waiting for template data...

-
- - - - diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index d703616..8c5cc0e 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -1612,10 +1612,15 @@ export function registerIpcHandlers(): void { return null; } }); +} - // ============ Event Forwarding ============ - - // Forward engine events to renderer +/** + * Register event forwarding from engine EventEmitters to the renderer via IPC. + * Must be called after the database is initialized (engines require DB access). + * Separated from registerIpcHandlers() so that handler registration can happen + * synchronously before any async work, eliminating startup race conditions. + */ +export function registerEventForwarding(): void { const postEngine = getPostEngine(); const mediaEngine = getMediaEngine(); const projectEngine = getProjectEngine(); diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 1fc06f9..63d5753 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -1,2 +1,2 @@ -export { registerIpcHandlers } from './handlers'; +export { registerIpcHandlers, registerEventForwarding } from './handlers'; export { registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './chatHandlers'; diff --git a/src/main/main.ts b/src/main/main.ts index 69c3a3c..2ef5730 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol import * as path from 'path'; import * as fs from 'fs'; import { getDatabase } from './database'; -import { registerIpcHandlers, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc'; +import { registerIpcHandlers, registerEventForwarding, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc'; import { media } from './database/schema'; import { eq } from 'drizzle-orm'; import { getMediaEngine } from './engine/MediaEngine'; @@ -714,10 +714,19 @@ function createApplicationMenu(): Menu { } async function initialize(): Promise { + // Register IPC handlers immediately (synchronous) so they are available + // before any async work. This eliminates race conditions where the renderer + // calls handlers before the database is ready. + registerIpcHandlers(); + // Initialize database const db = getDatabase(); await db.initializeLocal(); + // Now that the database is ready, register event forwarding from engines + // to the renderer (engines need DB access at registration time). + registerEventForwarding(); + // Register custom protocol for serving media files // URLs like bds-media://media-id will be resolved to the actual file protocol.handle('bds-media', async (request) => { @@ -816,9 +825,6 @@ async function initialize(): Promise { } }); - // Register IPC handlers - registerIpcHandlers(); - ipcMain.handle('app:setPreviewPostTarget', async (_, postId: string | null) => { activePreviewPostId = typeof postId === 'string' && postId.length > 0 ? postId : null; setPreviewPostMenuEnabled(Boolean(activePreviewPostId)); diff --git a/tests/engine/mainStartup.test.ts b/tests/engine/mainStartup.test.ts index 80e56f5..8bb4e62 100644 --- a/tests/engine/mainStartup.test.ts +++ b/tests/engine/mainStartup.test.ts @@ -91,6 +91,7 @@ describe('main bootstrap preview behavior', () => { vi.doMock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn(), + registerEventForwarding: vi.fn(), registerChatHandlers: vi.fn(), initializeChatHandlers: vi.fn(), cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), @@ -209,6 +210,7 @@ describe('main bootstrap preview behavior', () => { vi.doMock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn(), + registerEventForwarding: vi.fn(), registerChatHandlers: vi.fn(), initializeChatHandlers: vi.fn(), cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), @@ -356,6 +358,7 @@ describe('main bootstrap preview behavior', () => { vi.doMock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn(), + registerEventForwarding: vi.fn(), registerChatHandlers: vi.fn(), initializeChatHandlers: vi.fn(), cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), @@ -498,6 +501,7 @@ describe('main bootstrap preview behavior', () => { vi.doMock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn(), + registerEventForwarding: vi.fn(), registerChatHandlers: vi.fn(), initializeChatHandlers: vi.fn(), cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), @@ -628,6 +632,7 @@ describe('main bootstrap preview behavior', () => { vi.doMock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn(), + registerEventForwarding: vi.fn(), registerChatHandlers: vi.fn(), initializeChatHandlers: vi.fn(), cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), @@ -780,6 +785,7 @@ describe('main bootstrap preview behavior', () => { vi.doMock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn(), + registerEventForwarding: vi.fn(), registerChatHandlers: vi.fn(), initializeChatHandlers: vi.fn(), cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), @@ -961,6 +967,7 @@ describe('main bootstrap preview behavior', () => { vi.doMock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn(), + registerEventForwarding: vi.fn(), registerChatHandlers: vi.fn(), initializeChatHandlers: vi.fn(), cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), @@ -1145,6 +1152,7 @@ describe('main bootstrap preview behavior', () => { vi.doMock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn(), + registerEventForwarding: vi.fn(), registerChatHandlers: vi.fn(), initializeChatHandlers: vi.fn(), cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), diff --git a/tests/engine/mcp-view-builder.test.ts b/tests/engine/mcp-view-builder.test.ts new file mode 100644 index 0000000..be52622 --- /dev/null +++ b/tests/engine/mcp-view-builder.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { buildMcpView, type McpViewConfig } from '../../src/main/engine/mcp-view-builder'; + +describe('mcp-view-builder', () => { + describe('buildMcpView', () => { + const minimalConfig: McpViewConfig = { + title: 'Test View', + waitingMessage: 'Waiting for data...', + renderBody: ` + document.getElementById("review").innerHTML = "

Hello

"; + `, + acceptLabel: 'Accept', + discardLabel: 'Discard', + }; + + it('returns a valid HTML document', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('sets the page title', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('Test View'); + }); + + it('contains the waiting message', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('Waiting for data...'); + }); + + it('contains the App import from ext-apps', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); + expect(html).toContain('new App('); + }); + + it('contains accept and discard proposal handlers', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('window.acceptProposal'); + expect(html).toContain('window.discardProposal'); + }); + + it('uses custom button labels in renderBody', () => { + const config: McpViewConfig = { + ...minimalConfig, + renderBody: ` + document.getElementById("review").innerHTML = \` +
+ + +
+ \`;`, + }; + const html = buildMcpView(config); + expect(html).toContain('onclick="acceptProposal()"'); + expect(html).toContain('onclick="discardProposal()"'); + }); + + it('calls accept_proposal and discard_proposal tools via app bridge', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('app.callServerTool'); + expect(html).toContain('"accept_proposal"'); + expect(html).toContain('"discard_proposal"'); + }); + + it('contains the custom renderBody code', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('document.getElementById("review").innerHTML = "

Hello

"'); + }); + + it('uses module script type', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('type="module"'); + }); + + it('connects the App on load', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('app.connect()'); + }); + + it('has a status display element', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('id="status"'); + expect(html).toContain('showStatus'); + }); + + it('disables buttons during action', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('setButtonsDisabled(true)'); + }); + + it('uses XSS-safe escaping function', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('function esc('); + expect(html).toContain('document.createElement("div")'); + }); + + it('renders tool result data via ontoolresult handler', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('app.ontoolresult'); + expect(html).toContain('renderReview'); + }); + + it('includes extra CSS when provided', () => { + const config: McpViewConfig = { + ...minimalConfig, + extraCss: '.custom-class { color: red; }', + }; + const html = buildMcpView(config); + expect(html).toContain('.custom-class { color: red; }'); + }); + + it('includes extra JS helpers when provided', () => { + const config: McpViewConfig = { + ...minimalConfig, + extraJsHelpers: 'function fmt(v) { return String(v); }', + }; + const html = buildMcpView(config); + expect(html).toContain('function fmt(v) { return String(v); }'); + }); + + it('does not include extra CSS/JS when not provided', () => { + const html = buildMcpView(minimalConfig); + // The shared CSS is always present; just ensure no extra markers + expect(html).not.toContain('function fmt('); + }); + }); +}); diff --git a/tests/engine/mcp-views.test.ts b/tests/engine/mcp-views.test.ts index 3fb95af..bfdc9c4 100644 --- a/tests/engine/mcp-views.test.ts +++ b/tests/engine/mcp-views.test.ts @@ -1,68 +1,40 @@ import { describe, it, expect } from 'vitest'; -import path from 'path'; import { reviewPostHtml, reviewScriptHtml, reviewTemplateHtml, reviewMetadataHtml, - resolveMcpViewsDirs, - loadViewHtml, } from '../../src/main/engine/mcp-views'; -const viewOpts = { - moduleDir: path.resolve(__dirname, '../../src/main/engine'), -}; - describe('mcp-views', () => { - describe('resolveMcpViewsDirs', () => { - it('returns candidate directories', () => { - const dirs = resolveMcpViewsDirs(viewOpts); - expect(dirs.length).toBeGreaterThanOrEqual(2); - expect(dirs.some(d => d.includes('mcp-views'))).toBe(true); - }); - }); - - describe('loadViewHtml', () => { - it('loads an existing view file', () => { - const html = loadViewHtml('review-post.html', viewOpts); - expect(html).toContain(''); - }); - - it('throws for a non-existent view', () => { - expect(() => loadViewHtml('does-not-exist.html', viewOpts)).toThrow( - /not found/, - ); - }); - }); - describe('reviewPostHtml', () => { it('returns valid HTML document', () => { - const html = reviewPostHtml(viewOpts); + const html = reviewPostHtml(); expect(html).toContain(''); expect(html).toContain(''); }); it('contains App import from ext-apps', () => { - const html = reviewPostHtml(viewOpts); + const html = reviewPostHtml(); expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); expect(html).toContain('new App('); }); it('contains accept and discard buttons', () => { - const html = reviewPostHtml(viewOpts); + const html = reviewPostHtml(); expect(html).toContain('acceptProposal()'); expect(html).toContain('discardProposal()'); }); it('calls accept_proposal and discard_proposal tools via app bridge', () => { - const html = reviewPostHtml(viewOpts); + const html = reviewPostHtml(); expect(html).toContain('app.callServerTool'); expect(html).toContain('"accept_proposal"'); expect(html).toContain('"discard_proposal"'); }); it('contains post-specific UI elements', () => { - const html = reviewPostHtml(viewOpts); + const html = reviewPostHtml(); expect(html).toContain('Review Post'); expect(html).toContain('Publish'); expect(html).toContain('badge-draft'); @@ -70,13 +42,13 @@ describe('mcp-views', () => { }); it('renders tool result data via ontoolresult handler', () => { - const html = reviewPostHtml(viewOpts); + const html = reviewPostHtml(); expect(html).toContain('app.ontoolresult'); expect(html).toContain('renderReview'); }); it('uses XSS-safe escaping function', () => { - const html = reviewPostHtml(viewOpts); + const html = reviewPostHtml(); expect(html).toContain('function esc('); expect(html).toContain('document.createElement("div")'); }); @@ -84,24 +56,24 @@ describe('mcp-views', () => { describe('reviewScriptHtml', () => { it('returns valid HTML document', () => { - const html = reviewScriptHtml(viewOpts); + const html = reviewScriptHtml(); expect(html).toContain(''); expect(html).toContain(''); }); it('contains App import from ext-apps', () => { - const html = reviewScriptHtml(viewOpts); + const html = reviewScriptHtml(); expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); }); it('contains accept and discard buttons', () => { - const html = reviewScriptHtml(viewOpts); + const html = reviewScriptHtml(); expect(html).toContain('acceptProposal()'); expect(html).toContain('discardProposal()'); }); it('contains script-specific UI elements', () => { - const html = reviewScriptHtml(viewOpts); + const html = reviewScriptHtml(); expect(html).toContain('Review Script'); expect(html).toContain('Create Script'); expect(html).toContain('Python Code'); @@ -110,24 +82,24 @@ describe('mcp-views', () => { describe('reviewTemplateHtml', () => { it('returns valid HTML document', () => { - const html = reviewTemplateHtml(viewOpts); + const html = reviewTemplateHtml(); expect(html).toContain(''); expect(html).toContain(''); }); it('contains App import from ext-apps', () => { - const html = reviewTemplateHtml(viewOpts); + const html = reviewTemplateHtml(); expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); }); it('contains accept and discard buttons', () => { - const html = reviewTemplateHtml(viewOpts); + const html = reviewTemplateHtml(); expect(html).toContain('acceptProposal()'); expect(html).toContain('discardProposal()'); }); it('contains template-specific UI elements', () => { - const html = reviewTemplateHtml(viewOpts); + const html = reviewTemplateHtml(); expect(html).toContain('Review Template'); expect(html).toContain('Create Template'); expect(html).toContain('Liquid Template'); @@ -136,24 +108,24 @@ describe('mcp-views', () => { describe('reviewMetadataHtml', () => { it('returns valid HTML document', () => { - const html = reviewMetadataHtml(viewOpts); + const html = reviewMetadataHtml(); expect(html).toContain(''); expect(html).toContain(''); }); it('contains App import from ext-apps', () => { - const html = reviewMetadataHtml(viewOpts); + const html = reviewMetadataHtml(); expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); }); it('contains accept and discard buttons', () => { - const html = reviewMetadataHtml(viewOpts); + const html = reviewMetadataHtml(); expect(html).toContain('acceptProposal()'); expect(html).toContain('discardProposal()'); }); it('contains metadata-diff UI elements', () => { - const html = reviewMetadataHtml(viewOpts); + const html = reviewMetadataHtml(); expect(html).toContain('Metadata Changes'); expect(html).toContain('Apply Changes'); expect(html).toContain('diff-table'); @@ -162,7 +134,7 @@ describe('mcp-views', () => { }); it('contains diff formatting function', () => { - const html = reviewMetadataHtml(viewOpts); + const html = reviewMetadataHtml(); expect(html).toContain('function fmt('); expect(html).toContain('diff-old'); expect(html).toContain('diff-new'); @@ -171,10 +143,10 @@ describe('mcp-views', () => { describe('shared behavior', () => { const allViews = [ - { name: 'reviewPostHtml', fn: () => reviewPostHtml(viewOpts) }, - { name: 'reviewScriptHtml', fn: () => reviewScriptHtml(viewOpts) }, - { name: 'reviewTemplateHtml', fn: () => reviewTemplateHtml(viewOpts) }, - { name: 'reviewMetadataHtml', fn: () => reviewMetadataHtml(viewOpts) }, + { name: 'reviewPostHtml', fn: () => reviewPostHtml() }, + { name: 'reviewScriptHtml', fn: () => reviewScriptHtml() }, + { name: 'reviewTemplateHtml', fn: () => reviewTemplateHtml() }, + { name: 'reviewMetadataHtml', fn: () => reviewMetadataHtml() }, ]; it.each(allViews)('$name connects the App on load', ({ fn }) => { From 554c02e1d340d808747075a028ab3debdb0c8a17 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 28 Feb 2026 14:26:56 +0100 Subject: [PATCH 8/9] fix: MCP apps rendering now --- src/main/engine/mcp-view-builder.ts | 61 +++++++++++++++++++++++++---- tests/engine/mcp-views.test.ts | 21 +++++----- 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/src/main/engine/mcp-view-builder.ts b/src/main/engine/mcp-view-builder.ts index 5461109..655f52f 100644 --- a/src/main/engine/mcp-view-builder.ts +++ b/src/main/engine/mcp-view-builder.ts @@ -3,14 +3,15 @@ * from shared boilerplate + per-view configuration. * * Each generated page uses the `App` class from - * `@modelcontextprotocol/ext-apps` (loaded via the `app-with-deps` bundle) + * `@modelcontextprotocol/ext-apps` (inlined as a self-contained script) * and is served as a `ui://` resource for MCP hosts. * - * This replaces the previous approach of 4 separate HTML files that - * duplicated ~80% of their content (CSS, JS boilerplate, accept/discard - * handlers, status display, XSS escaping, app connection). + * The bundle is loaded from disk once and cached; it cannot use bare + * specifiers in the sandboxed iframe, so it is inlined directly. */ +import * as fs from 'fs'; + /** Configuration for a single MCP review view. */ export interface McpViewConfig { /** Page . */ @@ -57,10 +58,42 @@ const SHARED_CSS = `\ .status-error { background: #f8d7da; color: #721c24; } .word-count { color: #888; font-size: 0.8rem; }`; +/* ── Inline bundle loader ───────────────────────────────────────────── */ + +let _appBundle: string | null = null; + +/** + * Read the `app-with-deps` ESM bundle from node_modules, strip its + * `export{...}` block, and add `globalThis.__bdsExtApp = App_internal_name;` + * so the App class is accessible as a global from a plain `<script>` tag. + * Result is cached after the first call. + */ +function getAppBundle(): string { + if (_appBundle !== null) return _appBundle; + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const bundlePath: string = require.resolve('@modelcontextprotocol/ext-apps/app-with-deps'); + let source = fs.readFileSync(bundlePath, 'utf-8'); + + // The bundle ends with export{...,X as App,...}. + // Extract the internal variable name for `App`. + const match = source.match(/export\{[^}]*\b(\w+)\s+as\s+App\b[^}]*\}/); + if (!match) throw new Error('Could not find App export in app-with-deps bundle'); + const internalName = match[1]; + + // Strip ESM export block and expose App class on globalThis. + source = source.replace(/export\{[^}]+\}/, ''); + source += `\nglobalThis.__bdsExtApp=${internalName};`; + + _appBundle = source; + return _appBundle; +} + /* ── Shared JS ──────────────────────────────────────────────────────── */ const SHARED_JS = `\ - import { App } from "@modelcontextprotocol/ext-apps/app-with-deps"; + const App = globalThis.__bdsExtApp; + if (!App) { document.getElementById("status").textContent = "Error: App bundle not loaded"; document.getElementById("status").className = "status status-error"; document.getElementById("status").style.display = "block"; throw new Error("App bundle not loaded"); } const app = new App({ name: "bDS Review", version: "1.0.0" }); @@ -72,6 +105,8 @@ const SHARED_JS = `\ if (textContent?.text) { currentData = JSON.parse(textContent.text); renderReview(currentData); + } else { + showStatus("Tool result received but no text content found. Raw: " + JSON.stringify(result).slice(0, 200), "error"); } } catch (e) { showStatus("Failed to parse tool result: " + e.message, "error"); @@ -124,7 +159,18 @@ const SHARED_JS = `\ } window.showStatus = showStatus; - function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }`; + function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; } + + window.__connectApp = () => { + app.connect() + .then(() => { + showStatus("Connected — waiting for tool result…", "success"); + }) + .catch(e => { + showStatus("Failed to connect to host: " + e.message, "error"); + console.error("App connect failed:", e); + }); + };`; /* ── Builder ────────────────────────────────────────────────────────── */ @@ -150,6 +196,7 @@ ${SHARED_CSS}${extraCss} <p class="meta">${config.waitingMessage}</p> </div> <div id="status" class="status" style="display:none"></div> + <script>${getAppBundle()}</script> <script type="module"> ${SHARED_JS}${extraJs} @@ -157,7 +204,7 @@ ${SHARED_JS}${extraJs} ${config.renderBody} }; - app.connect().catch(e => console.error("App connect failed:", e)); + window.__connectApp(); </script> </body> </html> diff --git a/tests/engine/mcp-views.test.ts b/tests/engine/mcp-views.test.ts index bfdc9c4..59b363c 100644 --- a/tests/engine/mcp-views.test.ts +++ b/tests/engine/mcp-views.test.ts @@ -14,10 +14,10 @@ describe('mcp-views', () => { expect(html).toContain('</html>'); }); - it('contains App import from ext-apps', () => { + it('inlines ext-apps bundle and uses App class', () => { const html = reviewPostHtml(); - expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); - expect(html).toContain('new App('); + expect(html).toContain('__bdsExtApp'); // inlined bundle sets global + expect(html).toContain('new App('); // SHARED_JS uses the global }); it('contains accept and discard buttons', () => { @@ -61,9 +61,10 @@ describe('mcp-views', () => { expect(html).toContain('</html>'); }); - it('contains App import from ext-apps', () => { + it('inlines ext-apps bundle and uses App class', () => { const html = reviewScriptHtml(); - expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); + expect(html).toContain('__bdsExtApp'); + expect(html).toContain('new App('); }); it('contains accept and discard buttons', () => { @@ -87,9 +88,10 @@ describe('mcp-views', () => { expect(html).toContain('</html>'); }); - it('contains App import from ext-apps', () => { + it('inlines ext-apps bundle and uses App class', () => { const html = reviewTemplateHtml(); - expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); + expect(html).toContain('__bdsExtApp'); + expect(html).toContain('new App('); }); it('contains accept and discard buttons', () => { @@ -113,9 +115,10 @@ describe('mcp-views', () => { expect(html).toContain('</html>'); }); - it('contains App import from ext-apps', () => { + it('inlines ext-apps bundle and uses App class', () => { const html = reviewMetadataHtml(); - expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); + expect(html).toContain('__bdsExtApp'); + expect(html).toContain('new App('); }); it('contains accept and discard buttons', () => { From 8af1063351b4ecabf4dabd7ba39a1b22a2294cc1 Mon Sep 17 00:00:00 2001 From: hugo <hugoms@me.com> Date: Sat, 28 Feb 2026 14:54:56 +0100 Subject: [PATCH 9/9] fix: just a fixed test --- tests/engine/mcp-view-builder.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/engine/mcp-view-builder.test.ts b/tests/engine/mcp-view-builder.test.ts index be52622..b5ba7f1 100644 --- a/tests/engine/mcp-view-builder.test.ts +++ b/tests/engine/mcp-view-builder.test.ts @@ -29,10 +29,16 @@ describe('mcp-view-builder', () => { expect(html).toContain('Waiting for data...'); }); - it('contains the App import from ext-apps', () => { + it('inlines the ext-apps bundle and exposes App globally', () => { const html = buildMcpView(minimalConfig); - expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); + // The bundle is inlined (no external import URLs) — the loader strips the + // ESM export block and exposes App on globalThis.__bdsExtApp so the page + // script can instantiate it without a module specifier. + expect(html).toContain('__bdsExtApp'); expect(html).toContain('new App('); + // Must NOT reference an external CDN for the bundle (distribution isolation). + expect(html).not.toMatch(/<script[^>]+src=["']https?:/i); + expect(html).not.toMatch(/<link[^>]+href=["']https?:/i); }); it('contains accept and discard proposal handlers', () => {