feat: first cut at mcp and mcp apps
This commit is contained in:
@@ -15,7 +15,8 @@
|
|||||||
"Bash(npm test)",
|
"Bash(npm test)",
|
||||||
"Bash(ls -la /Users/gb/Projects/bDS/*.md)",
|
"Bash(ls -la /Users/gb/Projects/bDS/*.md)",
|
||||||
"Bash(grep -n \"templateSlug\\\\|postTemplateSlug\" /Users/gb/Projects/bDS/src/main/engine/*.ts)",
|
"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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
160
MCP_PLAN.md
Normal file
160
MCP_PLAN.md
Normal file
@@ -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<string, Proposal>` 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.
|
||||||
512
TODO.md
512
TODO.md
@@ -256,13 +256,20 @@ In `PageRenderer` and `BlogGenerationEngine`:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. MCP Server
|
## 3. MCP Server — Agent-Assisted Content Creation
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
|
|
||||||
Host an MCP (Model Context Protocol) server inside the application so external
|
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
|
AI agents (Claude Code, Cursor, etc.) can connect and work with bDS. The core
|
||||||
and manage blog content.
|
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
|
### Current State
|
||||||
|
|
||||||
@@ -270,14 +277,93 @@ and manage blog content.
|
|||||||
tools with full implementations (`getToolDefinitions()`, `executeTool()`).
|
tools with full implementations (`getToolDefinitions()`, `executeTool()`).
|
||||||
- `PreviewServer` provides the architectural pattern for an in-process HTTP
|
- `PreviewServer` provides the architectural pattern for an in-process HTTP
|
||||||
server with lifecycle management.
|
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
|
### Implementation Plan
|
||||||
|
|
||||||
#### 3.1 Dependencies
|
#### 3.1 Dependencies
|
||||||
|
|
||||||
Add `@modelcontextprotocol/sdk` to `package.json`. This provides the standard
|
- `@modelcontextprotocol/sdk` — standard MCP server with transport handling.
|
||||||
MCP server implementation with transport handling.
|
- `@modelcontextprotocol/ext-apps` — MCP Apps extension for serving
|
||||||
|
interactive UI resources.
|
||||||
|
|
||||||
#### 3.2 Engine Class — `MCPServer`
|
#### 3.2 Engine Class — `MCPServer`
|
||||||
|
|
||||||
@@ -288,72 +374,416 @@ src/main/engine/MCPServer.ts
|
|||||||
```
|
```
|
||||||
|
|
||||||
- Constructor accepts dependency injection (engines via getters).
|
- Constructor accepts dependency injection (engines via getters).
|
||||||
- `start(port)` — create HTTP server implementing MCP protocol, or use stdio
|
- `start(port)` — create standalone HTTP server on port 4124 using
|
||||||
transport for local agent integration.
|
`StreamableHTTPServerTransport` in stateless mode.
|
||||||
- `stop()` — clean shutdown.
|
- `stop()` — clean shutdown.
|
||||||
- `getToolDefinitions()` — convert OpenCodeManager's Anthropic-format tool
|
- Manages an in-memory `ProposalStore` for pending proposals (scripts,
|
||||||
definitions to MCP schema format.
|
templates, metadata changes). Posts use the existing draft mechanism instead.
|
||||||
- `executeTool(name, args)` — delegate to OpenCodeManager's `executeTool()`.
|
- 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
|
Expose blog data as MCP Resources using URI templates. Resources are
|
||||||
nearly identical between Anthropic tool_use format and MCP — both use JSON
|
application-controlled — the host fetches them for context, they are not
|
||||||
Schema for input definitions. The mapping is mechanical:
|
actions. Each resource is registered via `resources/list` and read via
|
||||||
|
`resources/read`.
|
||||||
|
|
||||||
| OpenCodeManager Tool | MCP Tool Name |
|
| Resource URI | Source | Description |
|
||||||
|------------------------|------------------------|
|
|----------------------------------|----------------------------------|------------------------------------|
|
||||||
| search_posts | search_posts |
|
| `bds://posts/{id}` | OpenCodeManager.read_post | Full post content + metadata |
|
||||||
| read_post | read_post |
|
| `bds://posts` | OpenCodeManager.list_posts | Paginated post list |
|
||||||
| list_posts | list_posts |
|
| `bds://media/{id}` | OpenCodeManager.get_media | Media item metadata |
|
||||||
| get_media | get_media |
|
| `bds://media` | OpenCodeManager.list_media | Paginated media list |
|
||||||
| list_media | list_media |
|
| `bds://tags` | OpenCodeManager.list_tags | All tags with counts |
|
||||||
| update_post_metadata | update_post_metadata |
|
| `bds://categories` | OpenCodeManager.list_categories | All categories |
|
||||||
| update_media_metadata | update_media_metadata |
|
| `bds://stats` | OpenCodeManager.get_blog_stats | Blog-wide statistics |
|
||||||
| list_tags | list_tags |
|
| `bds://posts/{id}/backlinks` | OpenCodeManager.get_post_backlinks | Posts linking to this post |
|
||||||
| list_categories | list_categories |
|
| `bds://posts/{id}/outlinks` | OpenCodeManager.get_post_outlinks | Posts this post links to |
|
||||||
| get_blog_stats | get_blog_stats |
|
| `bds://posts/{id}/media` | OpenCodeManager.get_post_media | Media attached to a post |
|
||||||
| view_image | view_image |
|
| `bds://media/{id}/posts` | OpenCodeManager.get_media_posts | Posts using a media item |
|
||||||
| get_post_backlinks | get_post_backlinks |
|
| `bds://media/{id}/image` | OpenCodeManager.view_image | Image binary (for visual context) |
|
||||||
| get_post_outlinks | get_post_outlinks |
|
|
||||||
| get_post_media | get_post_media |
|
|
||||||
| get_media_posts | get_media_posts |
|
|
||||||
|
|
||||||
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).
|
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<string, unknown>; // type-specific payload
|
||||||
|
createdAt: number;
|
||||||
|
ttlMs: number; // default 30 minutes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Simple `Map<string, Proposal>` 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:
|
Support two transports:
|
||||||
|
|
||||||
- **stdio** — for local integration (agent runs `bds --mcp` or connects via
|
- **stdio** — for local integration (agent runs `bds --mcp` or connects via
|
||||||
named pipe). This is the standard for MCP in coding agents.
|
named pipe). This is the standard for MCP in coding agents. Credentials
|
||||||
- **HTTP/SSE** — for network access, running alongside PreviewServer on a
|
come from the environment.
|
||||||
different port (e.g., 5174).
|
- **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.
|
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`:
|
In `main.ts`:
|
||||||
|
|
||||||
- Initialize `MCPServer` in `initialize()`.
|
- Initialize `MCPServer` in `initialize()`.
|
||||||
- Start alongside `PreviewServer` in `app.whenReady()`.
|
- 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).
|
- Respect active project context (tools operate on the active project).
|
||||||
|
|
||||||
#### 3.6 Configuration
|
#### 3.11 Configuration
|
||||||
|
|
||||||
In `SettingsView`, add an "MCP Server" section:
|
In `SettingsView`, add an "MCP Server" section:
|
||||||
|
|
||||||
- Enable/disable toggle.
|
- Enable/disable toggle.
|
||||||
- Port number (for HTTP transport).
|
- Port number (for HTTP transport).
|
||||||
- Show connection instructions (stdio command or URL).
|
- 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 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: 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.
|
- Follow existing engine test patterns with mocked dependencies.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
1091
package-lock.json
generated
1091
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -83,6 +83,8 @@
|
|||||||
"@milkdown/plugin-trailing": "^7.18.0",
|
"@milkdown/plugin-trailing": "^7.18.0",
|
||||||
"@milkdown/react": "^7.18.0",
|
"@milkdown/react": "^7.18.0",
|
||||||
"@milkdown/theme-nord": "^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",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@picocss/pico": "^2.1.1",
|
"@picocss/pico": "^2.1.1",
|
||||||
"@xmldom/xmldom": "^0.8.11",
|
"@xmldom/xmldom": "^0.8.11",
|
||||||
|
|||||||
690
src/main/engine/MCPServer.ts
Normal file
690
src/main/engine/MCPServer.ts
Normal file
@@ -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<Record<string, unknown>>; hasMore: boolean; total: number }>;
|
||||||
|
getPost: (id: string) => Promise<Record<string, unknown> | null>;
|
||||||
|
searchPosts: (query: string) => Promise<Array<{ id: string; title: string; slug: string; excerpt?: string }>>;
|
||||||
|
createPost: (data: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||||
|
updatePost: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown> | null>;
|
||||||
|
publishPost: (id: string) => Promise<Record<string, unknown> | null>;
|
||||||
|
deletePost: (id: string) => Promise<boolean>;
|
||||||
|
getTagsWithCounts: () => Promise<Array<{ tag: string; count: number }>>;
|
||||||
|
getCategoriesWithCounts: () => Promise<Array<{ category: string; count: number }>>;
|
||||||
|
getBlogStats: () => Promise<Record<string, unknown>>;
|
||||||
|
getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
||||||
|
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
||||||
|
getPostsFiltered: (filter: Record<string, unknown>) => Promise<Array<Record<string, unknown>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaEngineContract {
|
||||||
|
getAllMedia: () => Promise<Array<Record<string, unknown>>>;
|
||||||
|
getMedia: (id: string) => Promise<Record<string, unknown> | null>;
|
||||||
|
updateMedia: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown> | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScriptEngineContract {
|
||||||
|
createScript: (input: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TemplateEngineContract {
|
||||||
|
createTemplate: (input: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetaEngineContract {
|
||||||
|
getProjectMetadata: () => Promise<Record<string, unknown> | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PostMediaEngineContract {
|
||||||
|
getLinkedMediaDataForPost: (postId: string) => Promise<Array<Record<string, unknown>>>;
|
||||||
|
getLinkedPostsForMedia: (mediaId: string) => Promise<Array<Record<string, unknown>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagEngineContract {
|
||||||
|
getTagsWithCounts: () => Promise<Array<Record<string, unknown>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<number> {
|
||||||
|
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<void>((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<void> {
|
||||||
|
if (!this.httpServer) {
|
||||||
|
this.port = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((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<void> {
|
||||||
|
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<string, unknown>;
|
||||||
|
await this.deps.getMediaEngine().updateMedia(mediaId, changes);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'proposePostMetadata': {
|
||||||
|
const postId = proposal.data.postId as string;
|
||||||
|
const changes = proposal.data.changes as Record<string, unknown>;
|
||||||
|
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<string, unknown> = {};
|
||||||
|
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<string, unknown>).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<unknown> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
68
src/main/engine/ProposalStore.ts
Normal file
68
src/main/engine/ProposalStore.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
|
|
||||||
|
export class ProposalStore {
|
||||||
|
private readonly proposals = new Map<string, Proposal>();
|
||||||
|
private readonly ttlMs: number;
|
||||||
|
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor(ttlMs: number = DEFAULT_TTL_MS) {
|
||||||
|
this.ttlMs = ttlMs;
|
||||||
|
this.cleanupInterval = setInterval(() => this.cleanup(), this.ttlMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(type: ProposalType, data: Record<string, unknown>): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
258
src/main/engine/mcp-views.ts
Normal file
258
src/main/engine/mcp-views.ts
Normal file
@@ -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 `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8"><title>Review Post</title>
|
||||||
|
<style>${baseStyles()}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="review">
|
||||||
|
<p class="meta">Waiting for post data...</p>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status" style="display:none"></div>
|
||||||
|
<script type="module">
|
||||||
|
${appScript()}
|
||||||
|
window.renderReview = (data) => {
|
||||||
|
const post = data.post || {};
|
||||||
|
const wc = (post.content || "").split(/\\s+/).filter(Boolean).length;
|
||||||
|
document.getElementById("review").innerHTML = \`
|
||||||
|
<h1>\${esc(post.title || "Untitled")}</h1>
|
||||||
|
<p class="meta">
|
||||||
|
<span class="badge badge-draft">Draft</span>
|
||||||
|
<span class="word-count">\${wc} words</span>
|
||||||
|
</p>
|
||||||
|
\${post.categories?.length ? '<p class="meta">Categories: ' + post.categories.map(c => esc(c)).join(", ") + '</p>' : ''}
|
||||||
|
\${post.tags?.length ? '<p class="meta">Tags: ' + post.tags.map(t => esc(t)).join(", ") + '</p>' : ''}
|
||||||
|
\${post.excerpt ? '<h2>Excerpt</h2><p>' + esc(post.excerpt) + '</p>' : ''}
|
||||||
|
<h2>Content</h2>
|
||||||
|
<div class="content-preview">\${esc(post.content || "")}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-accept" onclick="acceptProposal()">Publish</button>
|
||||||
|
<button class="btn btn-discard" onclick="discardProposal()">Discard Draft</button>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
};
|
||||||
|
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reviewScriptHtml(): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8"><title>Review Script</title>
|
||||||
|
<style>${baseStyles()}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="review">
|
||||||
|
<p class="meta">Waiting for script data...</p>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status" style="display:none"></div>
|
||||||
|
<script type="module">
|
||||||
|
${appScript()}
|
||||||
|
window.renderReview = (data) => {
|
||||||
|
const p = data.preview || data;
|
||||||
|
document.getElementById("review").innerHTML = \`
|
||||||
|
<h1>\${esc(p.title || "Untitled Script")}</h1>
|
||||||
|
<p class="meta"><span class="badge badge-kind">\${esc(p.kind || "script")}</span></p>
|
||||||
|
<h2>Python Code</h2>
|
||||||
|
<div class="content-preview">\${esc(p.content || "(code not included in preview)")}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-accept" onclick="acceptProposal()">Create Script</button>
|
||||||
|
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
};
|
||||||
|
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reviewTemplateHtml(): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8"><title>Review Template</title>
|
||||||
|
<style>${baseStyles()}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="review">
|
||||||
|
<p class="meta">Waiting for template data...</p>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status" style="display:none"></div>
|
||||||
|
<script type="module">
|
||||||
|
${appScript()}
|
||||||
|
window.renderReview = (data) => {
|
||||||
|
const p = data.preview || data;
|
||||||
|
document.getElementById("review").innerHTML = \`
|
||||||
|
<h1>\${esc(p.title || "Untitled Template")}</h1>
|
||||||
|
<p class="meta"><span class="badge badge-kind">\${esc(p.kind || "template")}</span></p>
|
||||||
|
<h2>Liquid Template</h2>
|
||||||
|
<div class="content-preview">\${esc(p.content || "(template not included in preview)")}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-accept" onclick="acceptProposal()">Create Template</button>
|
||||||
|
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
};
|
||||||
|
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reviewMetadataHtml(): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8"><title>Review Metadata Changes</title>
|
||||||
|
<style>${baseStyles()}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="review">
|
||||||
|
<p class="meta">Waiting for metadata data...</p>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status" style="display:none"></div>
|
||||||
|
<script type="module">
|
||||||
|
${appScript()}
|
||||||
|
window.renderReview = (data) => {
|
||||||
|
const current = data.current || {};
|
||||||
|
const proposed = data.proposed || {};
|
||||||
|
const fields = Object.keys(proposed);
|
||||||
|
let rows = fields.map(f => \`
|
||||||
|
<tr>
|
||||||
|
<td><strong>\${esc(f)}</strong></td>
|
||||||
|
<td class="diff-old">\${esc(fmt(current[f]))}</td>
|
||||||
|
<td class="diff-new">\${esc(fmt(proposed[f]))}</td>
|
||||||
|
</tr>
|
||||||
|
\`).join("");
|
||||||
|
document.getElementById("review").innerHTML = \`
|
||||||
|
<h1>Metadata Changes</h1>
|
||||||
|
<table class="diff-table">
|
||||||
|
<thead><tr><th>Field</th><th>Current</th><th>Proposed</th></tr></thead>
|
||||||
|
<tbody>\${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-accept" onclick="acceptProposal()">Apply Changes</button>
|
||||||
|
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
};
|
||||||
|
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
|
||||||
|
function fmt(v) { if (v == null) return "(empty)"; if (Array.isArray(v)) return v.join(", "); return String(v); }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { getMetaEngine } from './engine/MetaEngine';
|
|||||||
import { getTemplateEngine } from './engine/TemplateEngine';
|
import { getTemplateEngine } from './engine/TemplateEngine';
|
||||||
import { getBlogmarkTransformService } from './engine/BlogmarkTransformService';
|
import { getBlogmarkTransformService } from './engine/BlogmarkTransformService';
|
||||||
import { PreviewServer } from './engine/PreviewServer';
|
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 { 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 { resolveUiLanguageFromSystemLocale, translateMenu } from './shared/i18n';
|
||||||
import { buildBlogmarkMarkdownLink, extractBlogmarkPayloadFromDeepLink, normalizeBlogmarkCategory } from './shared/blogmark';
|
import { buildBlogmarkMarkdownLink, extractBlogmarkPayloadFromDeepLink, normalizeBlogmarkCategory } from './shared/blogmark';
|
||||||
@@ -24,6 +25,7 @@ let blogmarkQueueProcessing = false;
|
|||||||
let pendingBlogmarkCreatedEvents: unknown[] = [];
|
let pendingBlogmarkCreatedEvents: unknown[] = [];
|
||||||
let rendererReady = false;
|
let rendererReady = false;
|
||||||
const PREVIEW_SERVER_PORT = 4123;
|
const PREVIEW_SERVER_PORT = 4123;
|
||||||
|
const MCP_SERVER_PORT = 4124;
|
||||||
const BLOG_PREVIEW_POST_MENU_ID = APP_MENU_ITEM_IDS.previewPost;
|
const BLOG_PREVIEW_POST_MENU_ID = APP_MENU_ITEM_IDS.previewPost;
|
||||||
const BLOGMARK_PROTOCOL = 'bds';
|
const BLOGMARK_PROTOCOL = 'bds';
|
||||||
const BLOGMARK_NEW_POST_PREFIX = `${BLOGMARK_PROTOCOL}://new-post`;
|
const BLOGMARK_NEW_POST_PREFIX = `${BLOGMARK_PROTOCOL}://new-post`;
|
||||||
@@ -864,6 +866,12 @@ app.whenReady().then(async () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start preview server on app startup:', 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();
|
createWindow();
|
||||||
|
|
||||||
await activeProjectContextReady;
|
await activeProjectContextReady;
|
||||||
@@ -897,6 +905,13 @@ app.on('before-quit', async () => {
|
|||||||
previewServer = null;
|
previewServer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mcpServer = getMCPServer();
|
||||||
|
await mcpServer.cleanup();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to cleanup MCP server:', error);
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|||||||
390
tests/engine/MCPServer.test.ts
Normal file
390
tests/engine/MCPServer.test.ts
Normal file
@@ -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<string, Record<string, unknown>>)[registry];
|
||||||
|
return obj != null && name in obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MCPServer', () => {
|
||||||
|
let server: MCPServer;
|
||||||
|
let deps: MCPServerDependencies;
|
||||||
|
let mockPostEngine: ReturnType<typeof createMockPostEngine>;
|
||||||
|
let mockMediaEngine: ReturnType<typeof createMockMediaEngine>;
|
||||||
|
let mockScriptEngine: ReturnType<typeof createMockScriptEngine>;
|
||||||
|
let mockTemplateEngine: ReturnType<typeof createMockTemplateEngine>;
|
||||||
|
|
||||||
|
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: '<h1>{{ title }}</h1>',
|
||||||
|
});
|
||||||
|
const result = await server.acceptProposal(proposalId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockTemplateEngine.createTemplate).toHaveBeenCalledWith({
|
||||||
|
title: 'My Template', kind: 'post', content: '<h1>{{ title }}</h1>',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
148
tests/engine/ProposalStore.test.ts
Normal file
148
tests/engine/ProposalStore.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
|
"paths": {
|
||||||
|
"@modelcontextprotocol/ext-apps/server": ["./node_modules/@modelcontextprotocol/ext-apps/dist/src/server/index.d.ts"]
|
||||||
|
},
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user