feat: first cut at mcp and mcp apps

This commit is contained in:
2026-02-28 09:11:59 +01:00
parent 1f7045e0b3
commit 690b90abcf
12 changed files with 3276 additions and 64 deletions

View File

@@ -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
View 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
View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View 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;
}

View 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;
}
}
}

View 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>`;
}

View File

@@ -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();
}); });

View 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);
});
});
});

View 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);
});
});
});

View File

@@ -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,