Merge pull request #24 from rfc1437/feat/mcp-server

Feat/mcp server
This commit is contained in:
Georg Bauer
2026-02-28 15:27:51 +01:00
committed by GitHub
47 changed files with 5808 additions and 464 deletions

View File

@@ -15,7 +15,8 @@
"Bash(npm test)",
"Bash(ls -la /Users/gb/Projects/bDS/*.md)",
"Bash(grep -n \"templateSlug\\\\|postTemplateSlug\" /Users/gb/Projects/bDS/src/main/engine/*.ts)",
"Bash(npm test -- tests/renderer/i18nLocaleCompleteness.test.ts)"
"Bash(npm test -- tests/renderer/i18nLocaleCompleteness.test.ts)",
"WebFetch(domain:ricmac.org)"
]
}
}

View File

@@ -1,16 +1,11 @@
# GitHub Copilot Instructions for Blogging Desktop Server (bDS)
# Agents Instructions for Blogging Desktop Server (bDS)
This document provides context and best practices for GitHub Copilot when working on this Electron + TypeScript + SQLite blogging application.
## Project Overview
## Plan Mode
**Blogging Desktop Server (bDS)** is a desktop blogging application built with:
- **Electron** v28+ for cross-platform desktop
- **TypeScript** for all code (strict mode)
- **React** for the renderer UI
- **Drizzle ORM** for type-safe database access
- **@libsql/client** for SQLite (local database)
- **Zustand** for React state management
- Make the plan extremely concise. Sacrifice grammar for the sake of concision.
- At the end of each plan, give me a list of unresolved questions to answer, if any.
---
@@ -143,7 +138,3 @@ This document provides context and best practices for GitHub Copilot when workin
- Store Dropbox auth tokens in secure storage, not in code
- Sanitize user input before rendering (XSS prevention)
## Plan Mode
- Make the plan extremely concise. Sacrifice grammar for the sake of concision.
- At the end of each plan, give me a list of unresolved questions to answer, if any.

150
CLAUDE.md
View File

@@ -1,149 +1 @@
# GitHub Copilot Instructions for Blogging Desktop Server (bDS)
This document provides context and best practices for GitHub Copilot when working on this Electron + TypeScript + SQLite blogging application.
## Project Overview
**Blogging Desktop Server (bDS)** is a desktop blogging application built with:
- **Electron** v28+ for cross-platform desktop
- **TypeScript** for all code (strict mode)
- **React** for the renderer UI
- **Drizzle ORM** for type-safe database access
- **@libsql/client** for SQLite (local database)
- **Zustand** for React state management
---
## ⚠️ MANDATORY: Test-First Development
**STOP!** Before writing ANY implementation code, you MUST:
1. **Write a failing test first** that describes the expected behavior
2. **Run the test** to confirm it fails (Red)
3. **Write minimal code** to make the test pass (Green)
4. **Refactor** while keeping tests green
> **No code without tests. No exceptions.**
>
> Tests must import and exercise the REAL implementation classes, not inline helper functions.
> Mock only external dependencies (database, filesystem), never the class under test.
---
## ⚠️ MANDATORY: Fix All Test Failures
**You MUST investigate and fix ALL test failures before completing any task.**
- Never leave tests failing, even if they appear unrelated to your changes
- If a test failure is pre-existing, fix it as part of your current work
- Run the full test suite (`npm test`) before considering any task complete
- If you cannot fix a test, explain why and propose a solution
> **Zero failing tests. No exceptions.**
---
## ⚠️ MANDATORY: Remove Unused Code
**Never keep unused code around. Always delete it completely.**
- When a feature is removed, delete ALL related code (implementation, tests, types, configs)
- Do NOT comment out code "for later" - use version control history
- Do NOT skip tests for removed functionality - delete them
- Do NOT leave dead code paths, unused imports, or orphaned functions
- When refactoring, actively look for and remove any code that becomes unused
> **Delete unused code immediately. No exceptions.**
---
## ⚠️ MANDATORY: Build Verification After Code Changes
**You MUST run the full build after making code changes.**
- Run `npm run build` after any code modifications
- Fix ALL build errors before considering the task complete
- Build errors indicate issues that may not be caught by `tsc --noEmit` alone (e.g., event forwarding, renderer build)
- The build must complete successfully before the task is complete
> **Successful build required. No exceptions.**
---
## ⚠️ MANDATORY: No External JS/CSS in Preview or Generated HTML
**Do not reference external JavaScript or CSS libraries (CDNs/remote URLs) from the preview server output or generated HTML.**
- Preview HTML must reference only local/package-bundled assets
- Generated HTML must not include CDN-hosted JS/CSS libraries
- If a library is needed (e.g., Pico CSS, Lightbox), include it as a local dependency and serve/reference it locally
- Avoid introducing any new `<script src="https://...">` or `<link href="https://...">` for library assets in preview/generated output
> **Preview and generated HTML must be self-contained with local assets. No exceptions.**
---
## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text
**All user-facing text MUST follow proper i18n patterns.**
- Do not hardcode UI strings directly in React components, menu templates, dialogs, or toasts
- Store UI copy in language resources and resolve text through i18n helpers/hooks
- UI language MUST come from the operating system locale
- Rendering/preview/generated-content language MUST come from project preferences (`mainLanguage`), not UI locale
- Keep i18n usage consistent in both renderer UI and render/preview output
- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback
- English fallback is allowed only when the requested locale is unsupported by available locale files
- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras
> **No hardcoded user-facing text. No exceptions.**
---
## ⚠️ MANDATORY: Keep Python API Bindings and API Docs in Sync
**Whenever any app API is added, removed, or changed, you MUST update the Python API bridge and API documentation in the same change set.**
- Update the Python API contract/bindings used by embedded Pyodide (`bds_api`)
- Regenerate and commit `API.md`
- Ensure every API entry documents:
- Parameter names, types, and required/optional status
- Return type/response specification
- At least one sample Python call
- Maintain a shared **Data Structures** section in `API.md` for canonical objects (for example `PostData`, `MediaData`) so users can see expected attributes in one place
- Keep docs sync tests passing (documentation and generator output must match)
> **No API contract drift between app APIs, Python bindings, and API.md. No exceptions.**
---
## Architecture Principles
### Separation of Concerns
1. **Engine Classes** (`src/main/engine/`): All business logic lives here
- No UI code, no IPC code, no direct database or filesystem access
- Methods should be pure functions where possible, with side effects (fs/db/events) abstracted behind interfaces
2. **IPC Layer** (`src/main/ipc/`): Bridge between main and renderer
- Handles all IPC communication, input validation, and error handling
- Calls engine methods and forwards results/events to renderer
- No business logic or UI code here
3. **UI Components** (`src/renderer/components/`): Presentation only
- Components should be stateless where possible
- Use Zustand store for shared state
- Never call IPC directly from deeply nested components
## Security Reminders
- Never log sensitive data (auth tokens, passwords)
- Validate all IPC inputs before processing
- Use `contextIsolation: true` and `sandbox: false` only when necessary
- Store Dropbox auth tokens in secure storage, not in code
- Sanitize user input before rendering (XSS prevention)
## Plan Mode
- Make the plan extremely concise. Sacrifice grammar for the sake of concision.
- At the end of each plan, give me a list of unresolved questions to answer, if any.
@AGENTS.md

149
GEMINI.md
View File

@@ -1,149 +0,0 @@
# GitHub Copilot Instructions for Blogging Desktop Server (bDS)
This document provides context and best practices for GitHub Copilot when working on this Electron + TypeScript + SQLite blogging application.
## Project Overview
**Blogging Desktop Server (bDS)** is a desktop blogging application built with:
- **Electron** v28+ for cross-platform desktop
- **TypeScript** for all code (strict mode)
- **React** for the renderer UI
- **Drizzle ORM** for type-safe database access
- **@libsql/client** for SQLite (local database)
- **Zustand** for React state management
---
## ⚠️ MANDATORY: Test-First Development
**STOP!** Before writing ANY implementation code, you MUST:
1. **Write a failing test first** that describes the expected behavior
2. **Run the test** to confirm it fails (Red)
3. **Write minimal code** to make the test pass (Green)
4. **Refactor** while keeping tests green
> **No code without tests. No exceptions.**
>
> Tests must import and exercise the REAL implementation classes, not inline helper functions.
> Mock only external dependencies (database, filesystem), never the class under test.
---
## ⚠️ MANDATORY: Fix All Test Failures
**You MUST investigate and fix ALL test failures before completing any task.**
- Never leave tests failing, even if they appear unrelated to your changes
- If a test failure is pre-existing, fix it as part of your current work
- Run the full test suite (`npm test`) before considering any task complete
- If you cannot fix a test, explain why and propose a solution
> **Zero failing tests. No exceptions.**
---
## ⚠️ MANDATORY: Remove Unused Code
**Never keep unused code around. Always delete it completely.**
- When a feature is removed, delete ALL related code (implementation, tests, types, configs)
- Do NOT comment out code "for later" - use version control history
- Do NOT skip tests for removed functionality - delete them
- Do NOT leave dead code paths, unused imports, or orphaned functions
- When refactoring, actively look for and remove any code that becomes unused
> **Delete unused code immediately. No exceptions.**
---
## ⚠️ MANDATORY: Build Verification After Code Changes
**You MUST run the full build after making code changes.**
- Run `npm run build` after any code modifications
- Fix ALL build errors before considering the task complete
- Build errors indicate issues that may not be caught by `tsc --noEmit` alone (e.g., event forwarding, renderer build)
- The build must complete successfully before the task is complete
> **Successful build required. No exceptions.**
---
## ⚠️ MANDATORY: No External JS/CSS in Preview or Generated HTML
**Do not reference external JavaScript or CSS libraries (CDNs/remote URLs) from the preview server output or generated HTML.**
- Preview HTML must reference only local/package-bundled assets
- Generated HTML must not include CDN-hosted JS/CSS libraries
- If a library is needed (e.g., Pico CSS, Lightbox), include it as a local dependency and serve/reference it locally
- Avoid introducing any new `<script src="https://...">` or `<link href="https://...">` for library assets in preview/generated output
> **Preview and generated HTML must be self-contained with local assets. No exceptions.**
---
## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text
**All user-facing text MUST follow proper i18n patterns.**
- Do not hardcode UI strings directly in React components, menu templates, dialogs, or toasts
- Store UI copy in language resources and resolve text through i18n helpers/hooks
- UI language MUST come from the operating system locale
- Rendering/preview/generated-content language MUST come from project preferences (`mainLanguage`), not UI locale
- Keep i18n usage consistent in both renderer UI and render/preview output
- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback
- English fallback is allowed only when the requested locale is unsupported by available locale files
- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras
> **No hardcoded user-facing text. No exceptions.**
---
## ⚠️ MANDATORY: Keep Python API Bindings and API Docs in Sync
**Whenever any app API is added, removed, or changed, you MUST update the Python API bridge and API documentation in the same change set.**
- Update the Python API contract/bindings used by embedded Pyodide (`bds_api`)
- Regenerate and commit `API.md`
- Ensure every API entry documents:
- Parameter names, types, and required/optional status
- Return type/response specification
- At least one sample Python call
- Maintain a shared **Data Structures** section in `API.md` for canonical objects (for example `PostData`, `MediaData`) so users can see expected attributes in one place
- Keep docs sync tests passing (documentation and generator output must match)
> **No API contract drift between app APIs, Python bindings, and API.md. No exceptions.**
---
## Architecture Principles
### Separation of Concerns
1. **Engine Classes** (`src/main/engine/`): All business logic lives here
- No UI code, no IPC code, no direct database or filesystem access
- Methods should be pure functions where possible, with side effects (fs/db/events) abstracted behind interfaces
2. **IPC Layer** (`src/main/ipc/`): Bridge between main and renderer
- Handles all IPC communication, input validation, and error handling
- Calls engine methods and forwards results/events to renderer
- No business logic or UI code here
3. **UI Components** (`src/renderer/components/`): Presentation only
- Components should be stateless where possible
- Use Zustand store for shared state
- Never call IPC directly from deeply nested components
## Security Reminders
- Never log sensitive data (auth tokens, passwords)
- Validate all IPC inputs before processing
- Use `contextIsolation: true` and `sandbox: false` only when necessary
- Store Dropbox auth tokens in secure storage, not in code
- Sanitize user input before rendering (XSS prevention)
## Plan Mode
- Make the plan extremely concise. Sacrifice grammar for the sake of concision.
- At the end of each plan, give me a list of unresolved questions to answer, if any.

158
MCP_PLAN.md Normal file
View File

@@ -0,0 +1,158 @@
# 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 `StreamableHTTPServerTransport` for HTTP handling. We use Node's `http.createServer` directly with stateless mode (new `McpServer` per request) to avoid session management complexity and the Express dependency.
`MCPServer` is a new engine class that owns the `McpServer` factory, tool/resource/prompt registration, and the `ProposalStore`. It runs independently of PreviewServer.
## 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 `http.createServer` + `StreamableHTTPServerTransport` in stateless mode, listens on `127.0.0.1:port`, validates Origin header
- `stop()` → close HTTP server
- `cleanup()` → discard proposals, stop intervals, stop server
- Singleton pattern with `getMCPServer()` getter
### 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) |
| `bds://media/{id}/image` | MediaEngine.getThumbnailDataUrl(id, 'medium') |
### 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
- Registered via `registerAppTool` with `visibility: ["app"]` (app-only, hidden from agent LLM)
- 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: Review Views are inline HTML template strings in `mcp-views.ts` — no separate build step needed.
### Step 10: Lifecycle in main.ts
- MCPServer starts independently alongside PreviewServer during app init
- 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/engine/mcp-views.ts` | NEW — inline review View HTML templates |
| `tests/engine/mcp-views.test.ts` | NEW |
| `src/main/main.ts` | MODIFY — start/cleanup MCPServer |
| `tests/engine/ProposalStore.test.ts` | NEW |
| `tests/engine/MCPServer.test.ts` | NEW |
| `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
Host an MCP (Model Context Protocol) server inside the application so external
AI agents (Claude Code, Cursor, etc.) can connect and use bDS tools to query
and manage blog content.
AI agents (Claude Code, Cursor, etc.) can connect and work with bDS. The core
principle: **read access is open, write access goes through user-reviewable
drafts**. External agents never silently mutate data — they propose changes
that the user accepts or discards inside the agent's UI.
This enables powerful workflows: an agent can read existing posts, draft a new
one with pre-filled content, propose a Python script or Liquid template, or
suggest image metadata improvements — and the user always has final say before
anything enters the system.
### Current State
@@ -270,14 +277,96 @@ and manage blog content.
tools with full implementations (`getToolDefinitions()`, `executeTool()`).
- `PreviewServer` provides the architectural pattern for an in-process HTTP
server with lifecycle management.
- No MCP SDK dependency exists.
- Posts already have a `draft` status (DB-only, no disk writes until publish)
— the exact pattern needed for MCP draft posts.
- Scripts and templates are file-first (immediately persisted) — MCP proposals
need an in-memory staging layer before acceptance.
- **`@modelcontextprotocol/sdk` v1.27.1 and `@modelcontextprotocol/ext-apps`
v1.1.2 are installed.**
- **`ProposalStore` engine is implemented and tested (18 tests).**
- **`MCPServer` engine is implemented and tested (70 tests).** Standalone HTTP
server on port 4124, stateless mode (new `McpServer` per request), registers
5 static resources, 7 resource templates, 8 tools (6 model-facing +
2 app-only), 3 prompts, and 4 `ui://` review-app resources.
- **`mcp-views.ts` provides review HTML** for posts, scripts, templates, and
metadata diffs via `@modelcontextprotocol/ext-apps` App class.
- **Lifecycle integrated in `main.ts`** — MCP server starts on app ready and
cleans up on before-quit.
- **Origin validation** — rejects requests from non-localhost origins to
prevent DNS rebinding attacks.
- **`accept_proposal` / `discard_proposal` use app-only visibility** via
`registerAppTool` with `visibility: ["app"]` — hidden from the agent LLM.
### Design Principles
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** — all tool inputs are validated at the MCP
boundary (via Zod schemas in `inputSchema`) before forwarding to
engine methods. Do not rely solely on downstream engine validation.
### Implementation Plan
#### 3.1 Dependencies
Add `@modelcontextprotocol/sdk` to `package.json`. This provides the standard
MCP server implementation with transport handling.
- `@modelcontextprotocol/sdk` — standard MCP server with transport handling.
- `@modelcontextprotocol/ext-apps` — MCP Apps extension for serving
interactive UI resources.
#### 3.2 Engine Class — `MCPServer`
@@ -288,72 +377,407 @@ src/main/engine/MCPServer.ts
```
- Constructor accepts dependency injection (engines via getters).
- `start(port)` — create HTTP server implementing MCP protocol, or use stdio
transport for local agent integration.
- `start(port)` — create standalone HTTP server on port 4124 using
`StreamableHTTPServerTransport` in stateless mode.
- `stop()` — clean shutdown.
- `getToolDefinitions()` — convert OpenCodeManager's Anthropic-format tool
definitions to MCP schema format.
- `executeTool(name, args)` — delegate to OpenCodeManager's `executeTool()`.
- Manages an in-memory `ProposalStore` for pending proposals (scripts,
templates, metadata changes). Posts use the existing draft mechanism instead.
- Serves `ui://` resources for the MCP App review UIs.
- Exposes three MCP primitive types:
- **Resources** — read-only blog data (posts, media, tags, categories,
stats) accessible via URI-based `resources/read`.
- **Tools** — parameterized actions (search, draft, propose, accept,
discard). Agent-facing tools are visible to the LLM; app-internal
tools (`accept_proposal`, `discard_proposal`) are called only by the
MCP App via the App Bridge.
- **Prompts** — user-triggered workflow templates surfaced as slash
commands in the host.
#### 3.3 Tool Mapping
#### 3.3 Resources (Read-Only Data)
Map the existing OpenCodeManager tools to MCP tools. The tool signatures are
nearly identical between Anthropic tool_use format and MCP — both use JSON
Schema for input definitions. The mapping is mechanical:
Expose blog data as MCP Resources using URI templates. Resources are
application-controlled — the host fetches them for context, they are not
actions. Each resource is registered via `resources/list` and read via
`resources/read`.
| OpenCodeManager Tool | MCP Tool Name |
|------------------------|------------------------|
| search_posts | search_posts |
| read_post | read_post |
| list_posts | list_posts |
| get_media | get_media |
| list_media | list_media |
| update_post_metadata | update_post_metadata |
| update_media_metadata | update_media_metadata |
| list_tags | list_tags |
| list_categories | list_categories |
| get_blog_stats | get_blog_stats |
| view_image | view_image |
| get_post_backlinks | get_post_backlinks |
| get_post_outlinks | get_post_outlinks |
| get_post_media | get_post_media |
| get_media_posts | get_media_posts |
| Resource URI | Source | Description |
|----------------------------------|----------------------------------|------------------------------------|
| `bds://posts/{id}` | OpenCodeManager.read_post | Full post content + metadata |
| `bds://posts` | OpenCodeManager.list_posts | Paginated post list |
| `bds://media/{id}` | OpenCodeManager.get_media | Media item metadata |
| `bds://media` | OpenCodeManager.list_media | Paginated media list |
| `bds://tags` | OpenCodeManager.list_tags | All tags with counts |
| `bds://categories` | OpenCodeManager.list_categories | All categories |
| `bds://stats` | OpenCodeManager.get_blog_stats | Blog-wide statistics |
| `bds://posts/{id}/backlinks` | OpenCodeManager.get_post_backlinks | Posts linking to this post |
| `bds://posts/{id}/outlinks` | OpenCodeManager.get_post_outlinks | Posts this post links to |
| `bds://posts/{id}/media` | OpenCodeManager.get_post_media | Media attached to a post |
| `bds://media/{id}/posts` | OpenCodeManager.get_media_posts | Posts using a media item |
| `bds://media/{id}/image` | OpenCodeManager.view_image | Image binary (for visual context) |
Exclude A2UI render tools (they are UI-specific and not useful for external
Use `bds://` as the custom URI scheme. Parameterized URIs use MCP resource
templates (`resources/templates/list`).
Note: `notifications/resources/list_changed` is not emitted because the
server runs in stateless mode (new `McpServer` per request, no persistent
connection). If the server moves to session-based mode in the future,
change notifications should be added.
List resources (`bds://posts`, `bds://media`) support cursor-based
pagination following the MCP pagination spec. The initial response
includes a `nextCursor` if more results exist; the host passes it back
on the next `resources/read` call.
#### 3.4 Read Tools (Parameterized Queries)
Not all read operations fit the Resources model. Parameterized queries
that accept complex search criteria are better modeled as tools with
`readOnlyHint: true`.
| MCP Tool Name | Source | Annotations |
|------------------------|----------------------------------|------------------------------------------|
| search_posts | OpenCodeManager.search_posts | `readOnlyHint: true, openWorldHint: false` |
Each tool includes a `title`, a `description` explaining when it should
be used, and a JSON Schema `inputSchema`. `search_posts` accepts query
text, filters (status, category, tag, date range), and pagination
parameters (cursor, limit).
Exclude `update_post_metadata`, `update_media_metadata` (writes go through
proposals), and A2UI render tools (UI-specific, not useful for external
agents).
#### 3.4 Transport
#### 3.5 Proposal Tools (Draft-Based Writes)
Support two transports:
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 }`.
- **stdio** — for local integration (agent runs `bds --mcp` or connects via
named pipe). This is the standard for MCP in coding agents.
- **HTTP/SSE** — for network access, running alongside PreviewServer on a
different port (e.g., 5174).
##### 3.5.1 `draft_post`
Start with stdio since that is what Claude Code and Cursor use.
Creates a draft post using the existing PostEngine draft workflow.
#### 3.5 Lifecycle Integration
**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. They are registered with
`registerAppTool` from `@modelcontextprotocol/ext-apps` using
`visibility: ["app"]`, which signals to compliant hosts that these tools
should not be shown to or invoked by the model.
##### `accept_proposal`
**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
**Streamable HTTP** — standalone HTTP server on port 4124 using
`StreamableHTTPServerTransport` in stateless mode (new `McpServer` per
request). A single HTTP endpoint at `/mcp` accepts JSON-RPC POST
requests and responds with `application/json` or `text/event-stream`
(SSE).
**Security:**
- Bind to `127.0.0.1` (localhost only).
- Validate the `Origin` header on all requests — reject non-localhost
origins to prevent DNS rebinding attacks.
- Requests without an `Origin` header are allowed (CLI tools like curl
and local MCP clients typically do not send one).
#### 3.10 Lifecycle Integration
In `main.ts`:
- Initialize `MCPServer` in `initialize()`.
- Start alongside `PreviewServer` in `app.whenReady()`.
- Stop in `before-quit` handler.
- Stop in `before-quit` handler (discard all pending proposals).
- Respect active project context (tools operate on the active project).
#### 3.6 Configuration
#### 3.11 Configuration
In `SettingsView`, add an "MCP Server" section:
- Enable/disable toggle.
- Port number (for HTTP transport).
- Show connection instructions (stdio command or URL).
- Proposal TTL setting (default 30 min).
#### 3.7 Testing
#### 3.12 MCP Prompts (Workflow Templates)
Expose MCP Prompts for common agent workflows. Prompts are user-controlled —
they surface as slash commands or command palette entries in the host and
produce pre-structured messages that guide the LLM.
| Prompt Name | Arguments | Description |
|------------------------|----------------------------|--------------------------------------|
| `draft-blog-post` | `topic?`, `category?` | Guides agent through reading existing posts, understanding the blog's style, and drafting a new post on the given topic |
| `improve-media-metadata` | `scope` (`all`\|`missing`) | Guides agent through reviewing media items and proposing alt text, captions, and titles |
| `content-audit` | `category?` | Guides agent through reviewing posts for quality, broken links, missing metadata, and suggesting fixes |
Each prompt returns a structured message array (system + user messages) that
sets up the LLM with context about the blog and clear instructions for the
workflow. The host renders the prompt as a one-click action.
This is a lower-priority addition — prompts enhance discoverability but the
core read/write flow works without them.
#### 3.13 Example Workflows
**Agent creates a blog post:**
1. Agent LLM reads `bds://categories` and `bds://tags` resources to
understand the blog.
2. Agent LLM calls `draft_post({ title: "...", content: "...", tags: [...] })`.
3. MCP server creates draft post (DB only), returns preview data.
4. Host sees `_meta.ui.resourceUri`, fetches `ui://bds/review-post`, renders
sandboxed iframe inline in conversation.
5. Review app shows the full post preview with accept/discard buttons.
6. User clicks accept → app calls `accept_proposal` via App Bridge →
server publishes post → app shows "Published" → agent is informed.
**Agent writes a Python script:**
1. Agent LLM reads `bds://posts` resource to understand the content model.
2. Agent LLM calls `propose_script({ title: "Tag Cleanup", content: "..." })`.
3. MCP server validates syntax, stages in ProposalStore, returns preview.
4. Host renders `ui://bds/review-script` — shows syntax-highlighted code
with validation results.
5. User reviews code, clicks accept → app calls `accept_proposal` →
server creates script via ScriptEngine.
**Agent suggests image metadata:**
1. Agent LLM reads `bds://media/{id}/image` resource to see the image.
2. Agent LLM calls `propose_media_metadata({ mediaId, alt: "...",
caption: "..." })`.
3. MCP server computes diff, stages in ProposalStore, returns diff preview.
4. Host renders `ui://bds/review-metadata` — shows current vs proposed diff.
5. User reviews diff, clicks accept → metadata is updated.
#### 3.14 MCP Apps Client Support
MCP Apps are currently supported by Claude (claude.ai), Claude Desktop,
VS Code GitHub Copilot, Goose, Postman, and MCPJam. For hosts that don't
support MCP Apps, the proposal tools still return structured text content
that the agent can present as formatted text — the user would then need
to instruct the agent to accept or discard via conversation, falling back
to a simpler flow.
For hosts without Apps support, the MCP **Elicitation** primitive
(`elicitation/request`) can serve as a lighter-weight confirmation
mechanism — the server asks the user to confirm or reject a proposal via
a simple dialog rather than a full review UI. This requires the host to
support the `elicitation` capability. When neither Apps nor Elicitation is
available, fall back to text-based accept/discard in the conversation.
#### 3.15 Testing
- Unit tests for tool definition mapping (Anthropic → MCP format).
- Unit tests for resource URI resolution and resource template listing.
- Unit tests for `ProposalStore` (create, accept, discard, TTL expiry).
- Unit tests for accept/discard tool handlers.
- Unit tests for tool annotations (verify correct hints on each tool).
- Unit tests for prompt templates (verify message structure and arguments).
- Integration tests: draft_post → app accept flow, propose_script → app
discard flow.
- Integration tests: start MCP server, send tool calls, verify responses.
- Integration tests: capability negotiation (with/without Apps extension).
- Integration tests: resource read, resource list with pagination, resource
change notifications.
- MCP App UI tests: render review apps, simulate user actions, verify
tool calls through App Bridge.
- Security tests: Origin header validation, session management, rate
limiting (for Streamable HTTP transport).
- Follow existing engine test patterns with mocked dependencies.
---

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/react": "^7.18.0",
"@milkdown/theme-nord": "^7.18.0",
"@modelcontextprotocol/ext-apps": "^1.1.2",
"@modelcontextprotocol/sdk": "^1.27.1",
"@monaco-editor/react": "^4.7.0",
"@picocss/pico": "^2.1.1",
"@xmldom/xmldom": "^0.8.11",

View File

@@ -169,7 +169,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
const match = candidates.find((candidate) => {
const createdAt = candidate.createdAt;
return createdAt.getFullYear() === dateFilter.year
&& createdAt.getMonth() === dateFilter.month;
&& createdAt.getMonth() === dateFilter.month - 1;
});
return match ?? null;

View File

@@ -0,0 +1,156 @@
/**
* MCPAgentConfigEngine adds the bDS MCP server entry to coding-agent config files.
*
* Supports: Claude Code, GitHub Copilot (VS Code), Gemini CLI, OpenCode.
* Each agent has its own config file format; this engine reads, merges, and writes
* the appropriate JSON structure without overwriting existing entries.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import path from 'path';
// ── Public types ─────────────────────────────────────────────────────
export type MCPAgentId = 'claude-code' | 'github-copilot' | 'gemini-cli' | 'opencode';
export interface AgentDefinition {
id: MCPAgentId;
label: string;
}
export interface AgentConfigResult {
success: boolean;
configPath: string;
error?: string;
}
export interface MCPAgentConfigOptions {
homeDir: string;
platform: NodeJS.Platform;
mcpUrl: string;
}
// ── Agent definitions ────────────────────────────────────────────────
const AGENTS: AgentDefinition[] = [
{ id: 'claude-code', label: 'Claude Code' },
{ id: 'github-copilot', label: 'GitHub Copilot' },
{ id: 'gemini-cli', label: 'Gemini CLI' },
{ id: 'opencode', label: 'OpenCode' },
];
const SERVER_NAME = 'bDS';
// ── Engine ───────────────────────────────────────────────────────────
export class MCPAgentConfigEngine {
private readonly homeDir: string;
private readonly platform: NodeJS.Platform;
private readonly mcpUrl: string;
constructor(opts: MCPAgentConfigOptions) {
this.homeDir = opts.homeDir;
this.platform = opts.platform;
this.mcpUrl = opts.mcpUrl;
}
/** Return the list of supported agent definitions. */
getAgents(): AgentDefinition[] {
return [...AGENTS];
}
/** Resolve the absolute path to the config file for the given agent. */
getConfigPath(agentId: MCPAgentId): string {
switch (agentId) {
case 'claude-code':
return path.join(this.homeDir, '.claude.json');
case 'github-copilot':
return this.vsCodeMcpPath();
case 'gemini-cli':
return path.join(this.homeDir, '.gemini', 'settings.json');
case 'opencode':
return path.join(this.homeDir, '.opencode.json');
}
}
/** Read-merge-write the bDS MCP server entry into the agent's config file. */
addToConfig(agentId: MCPAgentId): AgentConfigResult {
const configPath = this.getConfigPath(agentId);
try {
const existing = this.readExisting(configPath);
const merged = this.merge(agentId, existing);
this.ensureDir(configPath);
writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
return { success: true, configPath };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, configPath, error: message };
}
}
/** Check whether the bDS entry already exists in the agent's config. */
isConfigured(agentId: MCPAgentId): boolean {
const configPath = this.getConfigPath(agentId);
if (!existsSync(configPath)) return false;
try {
const data = JSON.parse(readFileSync(configPath, 'utf-8'));
const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers';
return !!data?.[serversKey]?.[SERVER_NAME];
} catch {
return false;
}
}
// ── Private helpers ──────────────────────────────────────────────
private vsCodeMcpPath(): string {
if (this.platform === 'darwin') {
return path.join(this.homeDir, 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
}
if (this.platform === 'win32') {
return path.join(this.homeDir, 'AppData', 'Roaming', 'Code', 'User', 'mcp.json');
}
// linux and others
return path.join(this.homeDir, '.config', 'Code', 'User', 'mcp.json');
}
private readExisting(configPath: string): Record<string, unknown> {
if (!existsSync(configPath)) return {};
const raw = readFileSync(configPath, 'utf-8');
return JSON.parse(raw) as Record<string, unknown>;
}
private merge(agentId: MCPAgentId, existing: Record<string, unknown>): Record<string, unknown> {
const entry = this.buildEntry(agentId);
const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers';
const currentServers = (existing[serversKey] as Record<string, unknown> | undefined) ?? {};
return {
...existing,
[serversKey]: {
...currentServers,
[SERVER_NAME]: entry,
},
};
}
private buildEntry(agentId: MCPAgentId): Record<string, unknown> {
switch (agentId) {
case 'claude-code':
return { type: 'http', url: this.mcpUrl };
case 'github-copilot':
return { type: 'http', url: this.mcpUrl };
case 'gemini-cli':
return { httpUrl: this.mcpUrl };
case 'opencode':
return { type: 'sse', url: this.mcpUrl };
}
}
private ensureDir(filePath: string): void {
const dir = path.dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
}

View File

@@ -0,0 +1,869 @@
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';
import type {
PostData,
PostFilter,
SearchResult,
PaginatedResult,
PaginationOptions,
} from './PostEngine';
import type { MediaData } from './MediaEngine';
import type { CreateScriptInput, ScriptData, ScriptValidationResult } from './ScriptEngine';
import type { CreateTemplateInput, TemplateData, TemplateValidationResult } from './TemplateEngine';
import type { ProjectMetadata } from './MetaEngine';
import type { PostMediaLinkData } from './PostMediaEngine';
import type { TagWithCount } from './TagEngine';
// ── Pagination helpers ─────────────────────────────────────────────────
export const DEFAULT_PAGE_SIZE = 50;
export function encodeCursor(offset: number): string {
return Buffer.from(String(offset)).toString('base64url');
}
export function decodeCursor(cursor: string): number {
try {
const offset = parseInt(Buffer.from(cursor, 'base64url').toString(), 10);
return Number.isNaN(offset) || offset < 0 ? 0 : offset;
} catch {
return 0;
}
}
// ── Dependency contracts ──────────────────────────────────────────────
interface PostEngineContract {
getAllPosts: (options?: PaginationOptions) => Promise<PaginatedResult<PostData>>;
getPost: (id: string) => Promise<PostData | null>;
searchPosts: (query: string) => Promise<SearchResult[]>;
searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise<PostData[]>;
createPost: (data: Partial<PostData>) => Promise<PostData>;
updatePost: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
publishPost: (id: string) => Promise<PostData | null>;
deletePost: (id: string) => Promise<boolean>;
getTagsWithCounts: () => Promise<Array<{ tag: string; count: number }>>;
getCategoriesWithCounts: () => Promise<Array<{ category: string; count: number }>>;
getBlogStats: () => Promise<{
totalPosts: number;
draftCount: number;
publishedCount: number;
archivedCount: number;
oldestPostDate: Date | null;
newestPostDate: Date | null;
postsPerYear: Record<number, number>;
tagCount: number;
categoryCount: number;
}>;
getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
}
interface MediaEngineContract {
getAllMedia: () => Promise<MediaData[]>;
getMedia: (id: string) => Promise<MediaData | null>;
updateMedia: (id: string, data: Partial<MediaData>) => Promise<MediaData | null>;
getThumbnailDataUrl: (mediaId: string, size: 'small' | 'medium' | 'large') => Promise<string | null>;
}
interface ScriptEngineContract {
createScript: (input: CreateScriptInput) => Promise<ScriptData>;
validateScript: (content: string) => Promise<ScriptValidationResult>;
}
interface TemplateEngineContract {
createTemplate: (input: CreateTemplateInput) => Promise<TemplateData>;
validateTemplate: (content: string) => Promise<TemplateValidationResult>;
}
interface MetaEngineContract {
getProjectMetadata: () => Promise<ProjectMetadata | null>;
}
interface PostMediaEngineContract {
getLinkedMediaDataForPost: (postId: string) => Promise<Array<PostMediaLinkData & { media: MediaData }>>;
getLinkedPostsForMedia: (mediaId: string) => Promise<PostMediaLinkData[]>;
}
interface TagEngineContract {
getTagsWithCounts: () => Promise<TagWithCount[]>;
}
export interface MCPServerDependencies {
getPostEngine: () => PostEngineContract;
getMediaEngine: () => MediaEngineContract;
getScriptEngine: () => ScriptEngineContract;
getTemplateEngine: () => TemplateEngineContract;
getMetaEngine: () => MetaEngineContract;
getPostMediaEngine: () => PostMediaEngineContract;
getTagEngine: () => TagEngineContract;
}
/**
* Maps each proposal type to the shape of its stored data.
* Used to recover type safety when reading from the generic ProposalStore.
*/
export interface ProposalDataMap {
draftPost: { postId: string };
proposeScript: CreateScriptInput;
proposeTemplate: CreateTemplateInput;
proposeMediaMetadata: { mediaId: string; changes: Partial<MediaData> };
proposePostMetadata: { postId: string; changes: Partial<PostData> };
}
/** Type-safe accessor for proposal data (bridges the generic ProposalStore). */
function proposalData<T extends keyof ProposalDataMap>(proposal: { data: Record<string, unknown> }): ProposalDataMap[T] {
return proposal.data as unknown as ProposalDataMap[T];
}
// ── 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) => {
// Origin validation — reject requests from non-localhost origins
// to prevent DNS rebinding attacks
const origin = req.headers['origin'];
if (origin) {
try {
const originUrl = new URL(origin);
if (originUrl.hostname !== '127.0.0.1' && originUrl.hostname !== 'localhost') {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Forbidden: non-local origin' }));
return;
}
} catch {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Forbidden: invalid origin' }));
return;
}
}
// CORS headers
res.setHeader('Access-Control-Allow-Origin', origin ?? 'http://127.0.0.1');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id');
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
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 } = proposalData<'draftPost'>(proposal);
await this.deps.getPostEngine().publishPost(postId);
break;
}
case 'proposeScript': {
await this.deps.getScriptEngine().createScript(proposalData<'proposeScript'>(proposal));
break;
}
case 'proposeTemplate': {
await this.deps.getTemplateEngine().createTemplate(proposalData<'proposeTemplate'>(proposal));
break;
}
case 'proposeMediaMetadata': {
const { mediaId, changes } = proposalData<'proposeMediaMetadata'>(proposal);
await this.deps.getMediaEngine().updateMedia(mediaId, changes);
break;
}
case 'proposePostMetadata': {
const { postId, changes } = proposalData<'proposePostMetadata'>(proposal);
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 } = proposalData<'draftPost'>(proposal);
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 (first page)' }, async () => {
const result = await this.deps.getPostEngine().getAllPosts({ limit: DEFAULT_PAGE_SIZE });
const response: Record<string, unknown> = { ...result };
if (result.hasMore) {
response.nextCursor = encodeCursor(DEFAULT_PAGE_SIZE);
}
return { contents: [{ uri: 'bds://posts', mimeType: 'application/json', text: JSON.stringify(response) }] };
});
server.registerResource('media', 'bds://media', { description: 'All media files (first page)' }, async () => {
const allMedia = await this.deps.getMediaEngine().getAllMedia();
const items = allMedia.slice(0, DEFAULT_PAGE_SIZE);
const total = allMedia.length;
const hasMore = DEFAULT_PAGE_SIZE < total;
const response: Record<string, unknown> = { items, total, hasMore };
if (hasMore) {
response.nextCursor = encodeCursor(DEFAULT_PAGE_SIZE);
}
return { contents: [{ uri: 'bds://media', mimeType: 'application/json', text: JSON.stringify(response) }] };
});
server.registerResource('tags', 'bds://tags', { description: 'Tags with post counts' }, async () => {
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 {
// ── Pagination templates ──
server.registerResource('posts-page', new ResourceTemplate('bds://posts{?cursor}', { list: undefined }), { description: 'Paginated blog posts (use cursor from previous page)' }, async (uri, { cursor }) => {
const offset = decodeCursor(cursor as string);
const result = await this.deps.getPostEngine().getAllPosts({ limit: DEFAULT_PAGE_SIZE, offset });
const response: Record<string, unknown> = { ...result };
if (result.hasMore) {
response.nextCursor = encodeCursor(offset + DEFAULT_PAGE_SIZE);
}
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(response) }] };
});
server.registerResource('media-page', new ResourceTemplate('bds://media{?cursor}', { list: undefined }), { description: 'Paginated media files (use cursor from previous page)' }, async (uri, { cursor }) => {
const offset = decodeCursor(cursor as string);
const allMedia = await this.deps.getMediaEngine().getAllMedia();
const items = allMedia.slice(offset, offset + DEFAULT_PAGE_SIZE);
const total = allMedia.length;
const hasMore = offset + DEFAULT_PAGE_SIZE < total;
const response: Record<string, unknown> = { items, total, hasMore };
if (hasMore) {
response.nextCursor = encodeCursor(offset + DEFAULT_PAGE_SIZE);
}
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(response) }] };
});
// ── Entity templates ──
server.registerResource('post', new ResourceTemplate('bds://posts/{id}', { list: undefined }), { description: 'A single post by ID' }, async (uri, { id }) => {
const result = await this.deps.getPostEngine().getPost(id as string);
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] };
});
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) }] };
});
server.registerResource('media-image', new ResourceTemplate('bds://media/{id}/image', { list: undefined }), { description: 'Image thumbnail (medium size, base64 WebP) for visual context' }, async (uri, { id }) => {
const mediaId = id as string;
const media = await this.deps.getMediaEngine().getMedia(mediaId);
if (!media || !media.mimeType.startsWith('image/')) {
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'Not an image or media not found' }] };
}
const dataUrl = await this.deps.getMediaEngine().getThumbnailDataUrl(mediaId, 'medium');
if (!dataUrl) {
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'Thumbnail not available' }] };
}
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
return {
contents: [{ uri: uri.href, mimeType: 'image/webp', blob: base64Data }],
};
});
}
// ── Tool registration ──────────────────────────────────────────────
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) => {
const hasFilters = args.category || args.tags || args.year || args.month || args.status;
const offset = args.offset ?? 0;
const limit = args.limit ?? 50;
if (args.query && !hasFilters) {
// Pure text search — use FTS
const results = await this.deps.getPostEngine().searchPosts(args.query);
const paginated = results.slice(offset, offset + limit);
return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] };
}
// Build structural filter
const filter: PostFilter = {};
if (args.category) filter.categories = [args.category];
if (args.tags) filter.tags = args.tags;
if (args.year) filter.year = args.year;
if (args.month) filter.month = args.month;
if (args.status) filter.status = args.status;
if (args.query && hasFilters) {
// FTS + structural filters: single SQL JOIN query, ranked by FTS score
const results = await this.deps.getPostEngine().searchPostsFiltered(
args.query, filter, { offset, limit },
);
return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] };
}
// Filter-only query (no text search)
const results = await this.deps.getPostEngine().getPostsFiltered(filter);
const paginated = results.slice(offset, offset + limit);
return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] };
});
}
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'),
},
annotations: { readOnlyHint: false, destructiveHint: false },
_meta: { ui: { resourceUri: 'ui://bds/review-post' } },
}, async (args: { title: string; content: string; excerpt?: string; tags?: string[]; categories?: string[]; author?: string }) => {
try {
const post = await this.deps.getPostEngine().createPost({
title: args.title,
content: args.content,
excerpt: args.excerpt,
tags: args.tags ?? [],
categories: args.categories ?? [],
author: args.author,
status: 'draft',
});
const proposalId = this.proposalStore.create('draftPost', { postId: post.id });
return {
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, post }) }],
};
} catch (error) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Failed to create draft: ${error instanceof Error ? error.message : String(error)}` }) }],
isError: true,
};
}
});
// ── propose_script ──
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'),
},
annotations: { readOnlyHint: false, destructiveHint: false },
_meta: { ui: { resourceUri: 'ui://bds/review-script' } },
}, async (args: { title: string; kind: 'macro' | 'utility' | 'transform'; content: string; entrypoint?: string }) => {
const validation = await this.deps.getScriptEngine().validateScript(args.content);
const proposalId = this.proposalStore.create('proposeScript', {
title: args.title,
kind: args.kind,
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, syntaxValid: validation.valid, syntaxErrors: validation.errors } }) }],
};
});
// ── 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'),
},
annotations: { readOnlyHint: false, destructiveHint: false },
_meta: { ui: { resourceUri: 'ui://bds/review-template' } },
}, async (args: { title: string; kind: 'post' | 'list' | 'not-found' | 'partial'; content: string }) => {
const validation = await this.deps.getTemplateEngine().validateTemplate(args.content);
const proposalId = this.proposalStore.create('proposeTemplate', {
title: args.title,
kind: args.kind,
content: args.content,
});
return {
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, preview: { title: args.title, kind: args.kind, contentLength: args.content.length, syntaxValid: validation.valid, syntaxErrors: validation.errors } }) }],
};
});
// ── 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'),
},
annotations: { readOnlyHint: false, destructiveHint: false },
_meta: { ui: { resourceUri: 'ui://bds/review-metadata' } },
}, async (args: { mediaId: string; alt?: string; caption?: string; title?: string; tags?: string[] }) => {
try {
const { mediaId, ...changes } = args;
const current = await this.deps.getMediaEngine().getMedia(mediaId);
if (!current) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Media item ${mediaId} not found.` }) }],
isError: true,
};
}
const proposalId = this.proposalStore.create('proposeMediaMetadata', {
mediaId,
changes,
});
return {
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, current, proposed: changes }) }],
};
} catch (error) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Failed to propose media metadata: ${error instanceof Error ? error.message : String(error)}` }) }],
isError: true,
};
}
});
// ── propose_post_metadata ──
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'),
},
annotations: { readOnlyHint: false, destructiveHint: false },
_meta: { ui: { resourceUri: 'ui://bds/review-metadata' } },
}, async (args: { postId: string; title?: string; excerpt?: string; tags?: string[]; categories?: string[] }) => {
try {
const { postId, ...changes } = args;
const current = await this.deps.getPostEngine().getPost(postId);
if (!current) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Post ${postId} not found.` }) }],
isError: true,
};
}
const proposalId = this.proposalStore.create('proposePostMetadata', {
postId,
changes,
});
return {
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, current, proposed: changes }) }],
};
} catch (error) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Failed to propose post metadata: ${error instanceof Error ? error.message : String(error)}` }) }],
isError: true,
};
}
});
// ── Register ui:// resources for review Views ──
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 {
registerAppTool(server, 'accept_proposal', {
title: 'Accept Proposal',
description: 'Accept a pending proposal, committing the proposed change.',
inputSchema: {
proposalId: z.string().describe('ID of the proposal to accept'),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
_meta: { ui: { visibility: ['app'] } },
}, async (args: { proposalId: string }) => {
const result = await this.acceptProposal(args.proposalId);
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
});
registerAppTool(server, 'discard_proposal', {
title: 'Discard Proposal',
description: 'Discard a pending proposal, rolling back any draft changes.',
inputSchema: {
proposalId: z.string().describe('ID of the proposal to discard'),
},
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
_meta: { ui: { visibility: ['app'] } },
}, async (args: { proposalId: string }) => {
const result = await this.discardProposal(args.proposalId);
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
});
}
// ── 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) {
throw new Error('MCPServer dependencies must be provided on first call to getMCPServer()');
}
mcpServerInstance = new MCPServer(deps);
}
return mcpServerInstance;
}
export function resetMCPServer(): void {
mcpServerInstance = null;
}

View File

@@ -818,9 +818,9 @@ export class MediaEngine extends EventEmitter {
}
if (filter.month !== undefined && filter.year !== undefined) {
// Use UTC dates to avoid timezone issues
const startOfMonth = new Date(Date.UTC(filter.year, filter.month, 1));
const endOfMonth = new Date(Date.UTC(filter.year, filter.month + 1, 1));
// Use UTC dates to avoid timezone issues (filter.month is 1-indexed)
const startOfMonth = new Date(Date.UTC(filter.year, filter.month - 1, 1));
const endOfMonth = new Date(Date.UTC(filter.year, filter.month, 1));
console.log(`[MediaEngine] Month filter: ${startOfMonth.toISOString()} to ${endOfMonth.toISOString()}`);
conditions.push(gte(media.createdAt, startOfMonth));
conditions.push(lt(media.createdAt, endOfMonth));
@@ -912,7 +912,7 @@ export class MediaEngine extends EventEmitter {
for (const item of allMedia) {
const year = item.createdAt.getFullYear();
const month = item.createdAt.getMonth();
const month = item.createdAt.getMonth() + 1; // 1-indexed
const key = `${year}-${month}`;
const current = counts.get(key) || { year, month, count: 0 };
current.count++;

View File

@@ -1253,46 +1253,37 @@ export class OpenCodeManager {
try {
switch (name) {
case 'search_posts': {
const searchResults = await this.postEngine.searchPosts(args.query as string);
const fullPosts = await Promise.all(
searchResults.map(sr => this.postEngine.getPost(sr.id))
);
let filteredPosts = fullPosts.filter(p => p !== null);
const filter: { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[]; year?: number; month?: number } = {};
if (args.category) filter.categories = [args.category as string];
if (args.tags && Array.isArray(args.tags) && (args.tags as string[]).length > 0) filter.tags = args.tags as string[];
if (args.year !== undefined) filter.year = args.year as number;
if (args.month !== undefined && args.year !== undefined) filter.month = args.month as number;
if (args.category) {
filteredPosts = filteredPosts.filter(p => p!.categories.includes(args.category as string));
}
if (args.tags && Array.isArray(args.tags) && (args.tags as string[]).length > 0) {
filteredPosts = filteredPosts.filter(p =>
(args.tags as string[]).every(tag => p!.tags.includes(tag))
);
}
if (args.year !== undefined) {
const year = args.year as number;
filteredPosts = filteredPosts.filter(p => p!.createdAt.getFullYear() === year);
}
if (args.month !== undefined && args.year !== undefined) {
const month = (args.month as number) - 1; // Convert 1-indexed to 0-indexed
filteredPosts = filteredPosts.filter(p => p!.createdAt.getMonth() === month);
}
const totalMatches = filteredPosts.length;
const hasFilters = Object.keys(filter).length > 0;
const offset = (args.offset as number) || 0;
const limit = (args.limit as number) || 10;
filteredPosts = filteredPosts.slice(offset, offset + limit);
let filteredPosts;
// Use searchPostsFiltered for all paths — it handles FTS + structural
// filters in a single SQL JOIN and returns full PostData[]
filteredPosts = await this.postEngine.searchPostsFiltered(
args.query as string, filter, { offset, limit },
);
const totalMatches = filteredPosts.length;
return {
success: true,
count: filteredPosts.length,
totalMatches,
hasMore: offset + limit < totalMatches,
hasMore: false,
offset,
limit,
posts: filteredPosts.map(p => ({
id: p!.id, title: p!.title, slug: p!.slug,
excerpt: p!.excerpt, status: p!.status,
categories: p!.categories, tags: p!.tags,
createdAt: p!.createdAt, updatedAt: p!.updatedAt,
id: p.id, title: p.title, slug: p.slug,
excerpt: p.excerpt, status: p.status,
categories: p.categories, tags: p.tags,
createdAt: p.createdAt, updatedAt: p.updatedAt,
})),
};
}
@@ -1319,7 +1310,7 @@ export class OpenCodeManager {
if (args.tags) filter.tags = args.tags as string[];
if (args.category) filter.categories = [args.category as string];
if (args.year !== undefined) filter.year = args.year as number;
if (args.month !== undefined && args.year !== undefined) filter.month = (args.month as number) - 1; // Convert 1-indexed to 0-indexed
if (args.month !== undefined && args.year !== undefined) filter.month = args.month as number;
const offset = (args.offset as number) || 0;
const limit = (args.limit as number) || 20;
@@ -1379,7 +1370,7 @@ export class OpenCodeManager {
if (hasMediaFilter) {
const mediaFilter: { year?: number; month?: number; tags?: string[] } = {};
if (args.year !== undefined) mediaFilter.year = args.year as number;
if (args.month !== undefined && args.year !== undefined) mediaFilter.month = (args.month as number) - 1; // Convert 1-indexed to 0-indexed
if (args.month !== undefined && args.year !== undefined) mediaFilter.month = args.month as number;
if (args.tags) mediaFilter.tags = args.tags as string[];
mediaList = await this.mediaEngine.getMediaFiltered(mediaFilter);
} else {

View File

@@ -770,8 +770,8 @@ export class PostEngine extends EventEmitter {
}
if (filter.month !== undefined && filter.year !== undefined) {
const startOfMonth = new Date(filter.year, filter.month, 1);
const endOfMonth = new Date(filter.year, filter.month + 1, 1);
const startOfMonth = new Date(filter.year, filter.month - 1, 1);
const endOfMonth = new Date(filter.year, filter.month, 1);
conditions.push(gte(posts.createdAt, startOfMonth));
conditions.push(lte(posts.createdAt, endOfMonth));
}
@@ -861,6 +861,107 @@ export class PostEngine extends EventEmitter {
}
}
/**
* Combined FTS search + structural filters in a single SQL query.
* Returns full PostData ordered by FTS rank, filtered by the given criteria.
* Both MCP and internal AI tools use this for query+filter combinations.
*/
async searchPostsFiltered(
query: string,
filter: PostFilter,
pagination?: PaginationOptions,
): Promise<PostData[]> {
if (!query.trim()) return [];
const client = getDatabase().getLocalClient();
if (!client) return [];
try {
const stemmedQuery = stemQuery(query, this.searchLanguage);
// Build WHERE clauses and args for the joined query
const conditions: string[] = [
'posts_fts.project_id = ?',
'posts_fts.MATCH ?',
];
const args: (string | number | Date)[] = [this.currentProjectId, stemmedQuery];
if (filter.status) {
conditions.push('posts.status = ?');
args.push(filter.status);
}
if (filter.year !== undefined) {
const startOfYear = new Date(filter.year, 0, 1);
const endOfYear = new Date(filter.year + 1, 0, 1);
conditions.push('posts.created_at >= ?');
args.push(startOfYear);
conditions.push('posts.created_at <= ?');
args.push(endOfYear);
}
if (filter.month !== undefined && filter.year !== undefined) {
const startOfMonth = new Date(filter.year, filter.month - 1, 1);
const endOfMonth = new Date(filter.year, filter.month, 1);
conditions.push('posts.created_at >= ?');
args.push(startOfMonth);
conditions.push('posts.created_at <= ?');
args.push(endOfMonth);
}
if (filter.categories && filter.categories.length > 0) {
const catClauses = filter.categories.map(() =>
`EXISTS (SELECT 1 FROM json_each(posts.categories) AS c WHERE c.value = ?)`
);
conditions.push(`(${catClauses.join(' OR ')})`);
args.push(...filter.categories);
}
if (filter.excludeCategories && filter.excludeCategories.length > 0) {
const exClauses = filter.excludeCategories.map(() =>
`EXISTS (SELECT 1 FROM json_each(posts.categories) AS c WHERE c.value = ?)`
);
conditions.push(`NOT (${exClauses.join(' OR ')})`);
args.push(...filter.excludeCategories);
}
if (filter.startDate) {
conditions.push('posts.created_at >= ?');
args.push(filter.startDate);
}
if (filter.endDate) {
conditions.push('posts.created_at <= ?');
args.push(filter.endDate);
}
const whereClause = conditions.join(' AND ');
const sqlQuery = `
SELECT posts.*
FROM posts_fts
JOIN posts ON posts_fts.id = posts.id
WHERE ${whereClause}
ORDER BY posts_fts.rank
LIMIT 500
`;
const result = await client.execute({ sql: sqlQuery, args });
let postDataList: PostData[] = result.rows.map((row) =>
this.dbRowToPostData(row as unknown as Post, (row.content as string) || '')
);
// Tag filtering is done client-side (tags are stored as JSON arrays)
if (filter.tags && filter.tags.length > 0) {
postDataList = postDataList.filter((p) =>
filter.tags!.every((tag) => p.tags.includes(tag))
);
}
// Apply pagination
const offset = pagination?.offset ?? 0;
const limit = pagination?.limit ?? postDataList.length;
return postDataList.slice(offset, offset + limit);
} catch (error) {
console.error('Search with filters failed:', error);
return [];
}
}
async getAvailableTags(): Promise<string[]> {
const allPosts = await this.getAllPostsUnpaginated();
const tags = new Set<string>();
@@ -1025,7 +1126,7 @@ export class PostEngine extends EventEmitter {
for (const post of allPosts) {
const year = post.createdAt.getFullYear();
const month = post.createdAt.getMonth();
const month = post.createdAt.getMonth() + 1; // 1-indexed
const key = `${year}-${month}`;
const current = counts.get(key) || { year, month, count: 0 };
current.count++;

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

@@ -57,6 +57,11 @@ export interface ScriptReconcileResult {
processedFiles: number;
}
export interface ScriptValidationResult {
valid: boolean;
errors: string[];
}
interface ParsedScriptFile {
metadata: {
id?: string;
@@ -86,6 +91,45 @@ export class ScriptEngine extends EventEmitter {
return this.currentProjectId;
}
async validateScript(content: string): Promise<ScriptValidationResult> {
const script = [
'import ast, sys, json',
'code = sys.stdin.read()',
'try:',
' ast.parse(code)',
' json.dump({"valid": True, "errors": []}, sys.stdout)',
'except SyntaxError as e:',
' msg = e.msg or "invalid syntax"',
' line = e.lineno or 1',
' col = e.offset or 1',
' json.dump({"valid": False, "errors": [f"{msg} (line {line}, col {col})"]}, sys.stdout)',
].join('\n');
try {
const { execFile } = await import('node:child_process');
return await new Promise<ScriptValidationResult>((resolve) => {
const proc = execFile('python3', ['-c', script], { timeout: 5000 }, (error, stdout) => {
if (error && !stdout) {
// python3 not available or timed out — assume valid (can't check)
resolve({ valid: true, errors: [] });
return;
}
try {
const result = JSON.parse(stdout);
resolve({ valid: !!result.valid, errors: Array.isArray(result.errors) ? result.errors : [] });
} catch {
resolve({ valid: true, errors: [] });
}
});
proc.stdin?.write(content);
proc.stdin?.end();
});
} catch {
// Dynamic import failed — assume valid
return { valid: true, errors: [] };
}
}
async createScript(input: CreateScriptInput): Promise<ScriptData> {
const now = new Date();
const allScripts = await this.getAllScriptRows();

View File

@@ -180,7 +180,7 @@ async function resolveRouteWithSharedServices(
const month = Number(daySlugMatch[2]);
const day = Number(daySlugMatch[3]);
const slug = daySlugMatch[4];
const post = await services.findSinglePostBySlug(slug, singlePostOptions, { year, month: month - 1, day });
const post = await services.findSinglePostBySlug(slug, singlePostOptions, { year, month, day });
if (!post) return null;
return services.pageRenderer.renderSinglePost(post, rewriteContext, {
page_title: pageContext.pageTitle,
@@ -224,7 +224,7 @@ async function resolveRouteWithSharedServices(
const year = Number(monthMatch[1]);
const month = Number(monthMatch[2]);
if (month < 1 || month > 12) return null;
const result = await services.loadPublishedSnapshotsPage({ status: 'published', year, month: month - 1, excludeCategories: listExcludedCategories }, pageOptions);
const result = await services.loadPublishedSnapshotsPage({ status: 'published', year, month, excludeCategories: listExcludedCategories }, pageOptions);
return services.pageRenderer.renderPostList(result.posts, rewriteContext, {
archiveGrouping: true,
routeKind: 'date',

View File

@@ -177,7 +177,7 @@ export async function findSinglePostBySlug(
}
const sameYear = draftCandidate.createdAt.getFullYear() === dateFilter.year;
const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month;
const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month - 1;
const sameDay = dateFilter.day === undefined || draftCandidate.createdAt.getDate() === dateFilter.day;
if (sameYear && sameMonth && sameDay) {
return draftCandidate;

View File

@@ -0,0 +1,212 @@
/**
* MCP App Review View Builder — generates self-contained HTML pages
* from shared boilerplate + per-view configuration.
*
* Each generated page uses the `App` class from
* `@modelcontextprotocol/ext-apps` (inlined as a self-contained script)
* and is served as a `ui://` resource for MCP hosts.
*
* The bundle is loaded from disk once and cached; it cannot use bare
* specifiers in the sandboxed iframe, so it is inlined directly.
*/
import * as fs from 'fs';
/** Configuration for a single MCP review view. */
export interface McpViewConfig {
/** Page <title>. */
title: string;
/** Text shown in the review area before data arrives. */
waitingMessage: string;
/**
* The body of the `window.renderReview = (data) => { ... }` function.
* Has access to `data`, `esc()`, `document`, and any helpers defined
* in `extraJsHelpers`. Must set `document.getElementById("review").innerHTML`.
*/
renderBody: string;
/** Label for the accept/confirm button (e.g. "Publish", "Create Template"). */
acceptLabel: string;
/** Label for the discard/cancel button (e.g. "Discard", "Discard Draft"). */
discardLabel: string;
/** Additional CSS rules appended after the shared stylesheet. */
extraCss?: string;
/** Additional JS helper functions placed before `renderReview`. */
extraJsHelpers?: string;
}
/* ── Shared CSS ─────────────────────────────────────────────────────── */
const SHARED_CSS = `\
* { 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; }
.word-count { color: #888; font-size: 0.8rem; }`;
/* ── Inline bundle loader ───────────────────────────────────────────── */
let _appBundle: string | null = null;
/**
* Read the `app-with-deps` ESM bundle from node_modules, strip its
* `export{...}` block, and add `globalThis.__bdsExtApp = App_internal_name;`
* so the App class is accessible as a global from a plain `<script>` tag.
* Result is cached after the first call.
*/
function getAppBundle(): string {
if (_appBundle !== null) return _appBundle;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const bundlePath: string = require.resolve('@modelcontextprotocol/ext-apps/app-with-deps');
let source = fs.readFileSync(bundlePath, 'utf-8');
// The bundle ends with export{...,X as App,...}.
// Extract the internal variable name for `App`.
const match = source.match(/export\{[^}]*\b(\w+)\s+as\s+App\b[^}]*\}/);
if (!match) throw new Error('Could not find App export in app-with-deps bundle');
const internalName = match[1];
// Strip ESM export block and expose App class on globalThis.
source = source.replace(/export\{[^}]+\}/, '');
source += `\nglobalThis.__bdsExtApp=${internalName};`;
_appBundle = source;
return _appBundle;
}
/* ── Shared JS ──────────────────────────────────────────────────────── */
const SHARED_JS = `\
const App = globalThis.__bdsExtApp;
if (!App) { document.getElementById("status").textContent = "Error: App bundle not loaded"; document.getElementById("status").className = "status status-error"; document.getElementById("status").style.display = "block"; throw new Error("App bundle not loaded"); }
const app = new App({ name: "bDS Review", version: "1.0.0" });
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);
} else {
showStatus("Tool result received but no text content found. Raw: " + JSON.stringify(result).slice(0, 200), "error");
}
} catch (e) {
showStatus("Failed to parse tool result: " + e.message, "error");
}
};
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;
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
window.__connectApp = () => {
app.connect()
.then(() => {
showStatus("Connected — waiting for tool result…", "success");
})
.catch(e => {
showStatus("Failed to connect to host: " + e.message, "error");
console.error("App connect failed:", e);
});
};`;
/* ── Builder ────────────────────────────────────────────────────────── */
/**
* Build a self-contained HTML page for an MCP review view.
*
* The output is a complete `<!DOCTYPE html>` document that can be served
* directly as a `ui://` resource.
*/
export function buildMcpView(config: McpViewConfig): string {
const extraCss = config.extraCss ? `\n${config.extraCss}` : '';
const extraJs = config.extraJsHelpers ? `\n ${config.extraJsHelpers}` : '';
return `<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>${config.title}</title>
<style>
${SHARED_CSS}${extraCss}
</style>
</head>
<body>
<div id="review">
<p class="meta">${config.waitingMessage}</p>
</div>
<div id="status" class="status" style="display:none"></div>
<script>${getAppBundle()}</script>
<script type="module">
${SHARED_JS}${extraJs}
window.renderReview = (data) => {
${config.renderBody}
};
window.__connectApp();
</script>
</body>
</html>
`;
}

View File

@@ -0,0 +1,129 @@
/**
* MCP App Review Views — generated at runtime from shared boilerplate
* + per-view configuration via `buildMcpView()`.
*
* 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.).
*/
import { buildMcpView } from './mcp-view-builder';
/* ── Review Post ────────────────────────────────────────────────────── */
export function reviewPostHtml(): string {
return buildMcpView({
title: 'Review Post',
waitingMessage: 'Waiting for post data...',
acceptLabel: 'Publish',
discardLabel: 'Discard Draft',
renderBody: `\
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>
\`;`,
});
}
/* ── Review Script ──────────────────────────────────────────────────── */
export function reviewScriptHtml(): string {
return buildMcpView({
title: 'Review Script',
waitingMessage: 'Waiting for script data...',
acceptLabel: 'Create Script',
discardLabel: 'Discard',
renderBody: `\
const p = data.preview || data;
document.getElementById("review").innerHTML = \`
<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>
\`;`,
});
}
/* ── Review Template ────────────────────────────────────────────────── */
export function reviewTemplateHtml(): string {
return buildMcpView({
title: 'Review Template',
waitingMessage: 'Waiting for template data...',
acceptLabel: 'Create Template',
discardLabel: 'Discard',
renderBody: `\
const p = data.preview || data;
document.getElementById("review").innerHTML = \`
<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>
\`;`,
});
}
/* ── Review Metadata ────────────────────────────────────────────────── */
export function reviewMetadataHtml(): string {
return buildMcpView({
title: 'Review Metadata',
waitingMessage: 'Waiting for metadata...',
acceptLabel: 'Apply Changes',
discardLabel: 'Discard',
extraCss: `\
.diff-table { width: 100%; border-collapse: collapse; margin: 8px 0; }
.diff-table th, .diff-table td { padding: 6px 10px; border: 1px solid #dee2e6; text-align: left; font-size: 0.85rem; }
.diff-table th { background: #f1f3f5; font-weight: 600; }
.diff-old { background: #ffeef0; }
.diff-new { background: #e6ffed; }`,
extraJsHelpers: `function fmt(v) { if (v == null) return "(empty)"; if (Array.isArray(v)) return v.join(", "); return String(v); }`,
renderBody: `\
const current = data.current || {};
const proposed = data.proposed || {};
const fields = Object.keys(proposed);
let rows = fields.map(f => \`
<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>
\`;`,
});
}

View File

@@ -125,6 +125,16 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean {
}
}
function buildMcpUrl(): string {
try {
const { getMCPServer } = require('../engine/MCPServer');
const port = getMCPServer().getPort() ?? 4124;
return `http://127.0.0.1:${port}/mcp`;
} catch {
return 'http://127.0.0.1:4124/mcp';
}
}
export function registerIpcHandlers(): void {
// ============ Git Handlers ============
@@ -1562,9 +1572,55 @@ export function registerIpcHandlers(): void {
registerBlogHandlers(safeHandle);
registerPublishHandlers(safeHandle);
// ============ Event Forwarding ============
// Forward engine events to renderer
// ============ MCP Config Handlers ============
safeHandle('mcp:getAgents', async () => {
const { MCPAgentConfigEngine } = await import('../engine/MCPAgentConfigEngine');
const engine = new MCPAgentConfigEngine({
homeDir: require('os').homedir(),
platform: process.platform,
mcpUrl: buildMcpUrl(),
});
return engine.getAgents();
});
safeHandle('mcp:addToAgentConfig', async (_event: unknown, agentId: string) => {
const { MCPAgentConfigEngine } = await import('../engine/MCPAgentConfigEngine');
const engine = new MCPAgentConfigEngine({
homeDir: require('os').homedir(),
platform: process.platform,
mcpUrl: buildMcpUrl(),
});
return engine.addToConfig(agentId as import('../engine/MCPAgentConfigEngine').MCPAgentId);
});
safeHandle('mcp:isConfigured', async (_event: unknown, agentId: string) => {
const { MCPAgentConfigEngine } = await import('../engine/MCPAgentConfigEngine');
const engine = new MCPAgentConfigEngine({
homeDir: require('os').homedir(),
platform: process.platform,
mcpUrl: buildMcpUrl(),
});
return engine.isConfigured(agentId as import('../engine/MCPAgentConfigEngine').MCPAgentId);
});
safeHandle('mcp:getPort', async () => {
try {
const { getMCPServer } = await import('../engine/MCPServer');
return getMCPServer().getPort();
} catch {
return null;
}
});
}
/**
* Register event forwarding from engine EventEmitters to the renderer via IPC.
* Must be called after the database is initialized (engines require DB access).
* Separated from registerIpcHandlers() so that handler registration can happen
* synchronously before any async work, eliminating startup race conditions.
*/
export function registerEventForwarding(): void {
const postEngine = getPostEngine();
const mediaEngine = getMediaEngine();
const projectEngine = getProjectEngine();

View File

@@ -1,2 +1,2 @@
export { registerIpcHandlers } from './handlers';
export { registerIpcHandlers, registerEventForwarding } from './handlers';
export { registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './chatHandlers';

View File

@@ -2,15 +2,19 @@ import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol
import * as path from 'path';
import * as fs from 'fs';
import { getDatabase } from './database';
import { registerIpcHandlers, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc';
import { registerIpcHandlers, registerEventForwarding, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc';
import { media } from './database/schema';
import { eq } from 'drizzle-orm';
import { getMediaEngine } from './engine/MediaEngine';
import { getPostEngine } from './engine/PostEngine';
import { getMetaEngine } from './engine/MetaEngine';
import { getTemplateEngine } from './engine/TemplateEngine';
import { getScriptEngine } from './engine/ScriptEngine';
import { getPostMediaEngine } from './engine/PostMediaEngine';
import { getTagEngine } from './engine/TagEngine';
import { getBlogmarkTransformService } from './engine/BlogmarkTransformService';
import { PreviewServer } from './engine/PreviewServer';
import { getMCPServer } from './engine/MCPServer';
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_GROUPS, APP_MENU_ITEM_IDS, type AppMenuAction, type AppMenuItemDefinition } from './shared/menuCommands';
import { resolveUiLanguageFromSystemLocale, translateMenu } from './shared/i18n';
import { buildBlogmarkMarkdownLink, extractBlogmarkPayloadFromDeepLink, normalizeBlogmarkCategory } from './shared/blogmark';
@@ -24,6 +28,7 @@ let blogmarkQueueProcessing = false;
let pendingBlogmarkCreatedEvents: unknown[] = [];
let rendererReady = false;
const PREVIEW_SERVER_PORT = 4123;
const MCP_SERVER_PORT = 4124;
const BLOG_PREVIEW_POST_MENU_ID = APP_MENU_ITEM_IDS.previewPost;
const BLOGMARK_PROTOCOL = 'bds';
const BLOGMARK_NEW_POST_PREFIX = `${BLOGMARK_PROTOCOL}://new-post`;
@@ -709,10 +714,19 @@ function createApplicationMenu(): Menu {
}
async function initialize(): Promise<void> {
// Register IPC handlers immediately (synchronous) so they are available
// before any async work. This eliminates race conditions where the renderer
// calls handlers before the database is ready.
registerIpcHandlers();
// Initialize database
const db = getDatabase();
await db.initializeLocal();
// Now that the database is ready, register event forwarding from engines
// to the renderer (engines need DB access at registration time).
registerEventForwarding();
// Register custom protocol for serving media files
// URLs like bds-media://media-id will be resolved to the actual file
protocol.handle('bds-media', async (request) => {
@@ -811,9 +825,6 @@ async function initialize(): Promise<void> {
}
});
// Register IPC handlers
registerIpcHandlers();
ipcMain.handle('app:setPreviewPostTarget', async (_, postId: string | null) => {
activePreviewPostId = typeof postId === 'string' && postId.length > 0 ? postId : null;
setPreviewPostMenuEnabled(Boolean(activePreviewPostId));
@@ -864,6 +875,20 @@ app.whenReady().then(async () => {
} catch (error) {
console.error('Failed to start preview server on app startup:', error);
}
try {
const mcpServer = getMCPServer({
getPostEngine: () => getPostEngine(),
getMediaEngine: () => getMediaEngine(),
getScriptEngine: () => getScriptEngine(),
getTemplateEngine: () => getTemplateEngine(),
getMetaEngine: () => getMetaEngine(),
getPostMediaEngine: () => getPostMediaEngine(),
getTagEngine: () => getTagEngine(),
});
await mcpServer.start(MCP_SERVER_PORT);
} catch (error) {
console.error('Failed to start MCP server on app startup:', error);
}
createWindow();
await activeProjectContextReady;
@@ -897,6 +922,13 @@ app.on('before-quit', async () => {
previewServer = null;
}
try {
const mcpServer = getMCPServer();
await mcpServer.cleanup();
} catch (error) {
console.error('Failed to cleanup MCP server:', error);
}
const db = getDatabase();
await db.close();
});

View File

@@ -382,6 +382,13 @@ export const electronAPI: ElectronAPI = {
once: (channel: string, callback: (...args: unknown[]) => void) => {
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
},
mcp: {
getAgents: () => ipcRenderer.invoke('mcp:getAgents'),
addToAgentConfig: (agentId: string) => ipcRenderer.invoke('mcp:addToAgentConfig', agentId),
isConfigured: (agentId: string) => ipcRenderer.invoke('mcp:isConfigured', agentId),
getPort: () => ipcRenderer.invoke('mcp:getPort'),
},
};
contextBridge.exposeInMainWorld('electronAPI', electronAPI);

View File

@@ -836,5 +836,11 @@ export interface ElectronAPI {
};
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void;
mcp: {
getAgents: () => Promise<Array<{ id: string; label: string }>>;
addToAgentConfig: (agentId: string) => Promise<{ success: boolean; configPath: string; error?: string }>;
isConfigured: (agentId: string) => Promise<boolean>;
getPort: () => Promise<number | null>;
};
}

View File

@@ -1674,7 +1674,7 @@ const Dashboard: React.FC = () => {
<span className="timeline-bar-count">{entry.count}</span>
</div>
<div className="timeline-bar-label">
<span className="timeline-bar-label-month">{monthFormatter.format(new Date(entry.year, entry.month, 1))}</span>
<span className="timeline-bar-label-month">{monthFormatter.format(new Date(entry.year, entry.month - 1, 1))}</span>
<span className="timeline-bar-label-year">{entry.year}</span>
</div>
</div>

View File

@@ -10,7 +10,7 @@ import {
import './SettingsView.css';
// Export category IDs for sidebar navigation
export type SettingsCategory = 'project' | 'editor' | 'content' | 'ai' | 'technology' | 'publishing' | 'data';
export type SettingsCategory = 'project' | 'editor' | 'content' | 'ai' | 'technology' | 'publishing' | 'data' | 'mcp';
// Scroll to a settings section by category ID
export const scrollToSettingsSection = (category: SettingsCategory) => {
@@ -122,6 +122,62 @@ const SettingSection: React.FC<{
);
};
/** Small component that shows the MCP server port or "Not running". */
const MCPStatusBadge: React.FC = () => {
const { t } = useI18n();
const [port, setPort] = React.useState<number | null>(null);
React.useEffect(() => {
window.electronAPI?.mcp?.getPort().then(setPort).catch(() => setPort(null));
}, []);
return (
<span className={`badge ${port ? 'badge-success' : 'badge-secondary'}`}>
{port ? t('settings.mcp.portRunning', { port: String(port) }) : t('settings.mcp.portStopped')}
</span>
);
};
/** Button to add bDS MCP server to an agent's config. Shows "Configured" if already present. */
const MCPAgentButton: React.FC<{ agentId: string; agentLabel: string }> = ({ agentId, agentLabel }) => {
const { t } = useI18n();
const [configured, setConfigured] = React.useState(false);
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
window.electronAPI?.mcp?.isConfigured(agentId).then(setConfigured).catch(() => setConfigured(false));
}, [agentId]);
if (configured) {
return <span className="badge badge-success">{t('settings.mcp.alreadyConfigured')}</span>;
}
return (
<button
className="secondary"
disabled={loading}
onClick={async () => {
setLoading(true);
try {
const result = await window.electronAPI?.mcp?.addToAgentConfig(agentId);
if (result?.success) {
showToast.success(t('settings.toast.mcpConfigSuccess', { agent: agentLabel }));
setConfigured(true);
} else {
showToast.error(t('settings.toast.mcpConfigFailed', { agent: agentLabel, error: result?.error ?? 'Unknown error' }));
}
} catch {
showToast.error(t('settings.toast.mcpConfigFailed', { agent: agentLabel, error: 'Unexpected error' }));
} finally {
setLoading(false);
}
}}
>
{t('settings.mcp.addToAgent', { agent: agentLabel })}
</button>
);
};
export const SettingsView: React.FC = () => {
const { t } = useI18n();
const {
@@ -417,6 +473,7 @@ export const SettingsView: React.FC = () => {
const technologyKeywords = ['technology', 'python', 'runtime', 'worker', 'webworker', 'main thread', 'execution'];
const publishingKeywords = ['publishing', 'ssh', 'deploy', 'server', 'host', 'upload', 'scp', 'rsync'];
const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'scripts', 'links', 'folder', 'filesystem'];
const mcpKeywords = ['mcp', 'server', 'agent', 'claude', 'copilot', 'gemini', 'opencode', 'model context protocol', 'coding', 'configuration'];
const renderProjectSettings = () => (
<SettingSection
@@ -1197,6 +1254,43 @@ export const SettingsView: React.FC = () => {
</SettingSection>
);
const renderMCPSettings = () => {
const agents = [
{ id: 'claude-code', label: 'Claude Code' },
{ id: 'github-copilot', label: 'GitHub Copilot' },
{ id: 'gemini-cli', label: 'Gemini CLI' },
{ id: 'opencode', label: 'OpenCode' },
];
return (
<SettingSection
id="settings-section-mcp"
title={t('settings.mcp.title')}
description={t('settings.mcp.description')}
hidden={!sectionHasMatches(mcpKeywords)}
>
<SettingRow
id="mcp-status"
label={t('settings.mcp.statusLabel')}
description={t('settings.mcp.statusDescription')}
>
<MCPStatusBadge />
</SettingRow>
{agents.map((agent) => (
<SettingRow
key={agent.id}
id={`mcp-agent-${agent.id}`}
label={t('settings.mcp.addToAgent', { agent: agent.label })}
description=""
>
<MCPAgentButton agentId={agent.id} agentLabel={agent.label} />
</SettingRow>
))}
</SettingSection>
);
};
const renderDataSettings = () => (
<>
<SettingSection
@@ -1392,7 +1486,8 @@ export const SettingsView: React.FC = () => {
sectionHasMatches(aiKeywords) ||
sectionHasMatches(technologyKeywords) ||
sectionHasMatches(publishingKeywords) ||
sectionHasMatches(dataKeywords);
sectionHasMatches(dataKeywords) ||
sectionHasMatches(mcpKeywords);
return (
<div className="settings-view">
@@ -1429,6 +1524,7 @@ export const SettingsView: React.FC = () => {
{renderTechnologySettings()}
{renderPublishingSettings()}
{renderDataSettings()}
{renderMCPSettings()}
</>
) : (
<div className="settings-no-results">

View File

@@ -167,7 +167,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({ onDateSelect, selectedYear,
onDateSelect(year, month);
}}
>
<span className="month-label">{MONTH_NAMES[month]}</span>
<span className="month-label">{MONTH_NAMES[month - 1]}</span>
<span className="month-count">{count}</span>
</div>
))}
@@ -374,7 +374,7 @@ const MediaCalendarView: React.FC<MediaCalendarViewProps> = ({ onDateSelect, sel
onDateSelect(year, month);
}}
>
<span className="month-label">{MONTH_NAMES[month]}</span>
<span className="month-label">{MONTH_NAMES[month - 1]}</span>
<span className="month-count">{count}</span>
</div>
))}
@@ -1261,7 +1261,7 @@ const SettingsNav: React.FC = () => {
const { tabs, activeTabId, openTab } = useAppStore();
const [activeSection, setActiveSection] = useState<SettingsCategory | null>(() => {
const persisted = getPersistedSidebarSection('settings');
if (persisted === 'project' || persisted === 'editor' || persisted === 'content' || persisted === 'ai' || persisted === 'technology' || persisted === 'publishing' || persisted === 'data') {
if (persisted === 'project' || persisted === 'editor' || persisted === 'content' || persisted === 'ai' || persisted === 'technology' || persisted === 'publishing' || persisted === 'data' || persisted === 'mcp') {
return persisted;
}
return null;
@@ -1343,6 +1343,13 @@ const SettingsNav: React.FC = () => {
<span className="settings-nav-entry-icon">🗄</span>
<span>{t('sidebar.nav.data')}</span>
</button>
<button
className={`settings-nav-entry ${activeSection === 'mcp' ? 'active' : ''}`}
onClick={() => handleNavClick('mcp')}
>
<span className="settings-nav-entry-icon">🔌</span>
<span>{t('sidebar.nav.mcp')}</span>
</button>
<button
className={`settings-nav-entry ${isStyleTabActive ? 'active' : ''}`}
onClick={handleStyleClick}

View File

@@ -975,5 +975,21 @@
"importAnalysis.macroUses": "{count} Verwendungen",
"importAnalysis.usedIn": "Verwendet in: {items}{more}",
"importAnalysis.moreSuffix": ", +{count} weitere",
"importAnalysis.noParameters": "(keine Parameter)"
"importAnalysis.noParameters": "(keine Parameter)",
"sidebar.nav.mcp": "MCP-Server",
"settings.mcp.title": "MCP-Server",
"settings.mcp.description": "Konfigurieren Sie den Model Context Protocol Server, der KI-Programmieragenten die Interaktion mit Ihrem Blog ermöglicht.",
"settings.mcp.statusLabel": "Serverstatus",
"settings.mcp.statusDescription": "Aktueller Status des MCP-Servers.",
"settings.mcp.portRunning": "Läuft auf Port {port}",
"settings.mcp.portStopped": "Nicht gestartet",
"settings.mcp.agentsTitle": "Agenten-Konfiguration",
"settings.mcp.agentsDescription": "Fügen Sie den bDS MCP-Server zur Konfiguration Ihres Programmieragenten hinzu. Vorhandene Einstellungen bleiben erhalten.",
"settings.mcp.addToAgent": "Zu {agent} hinzufügen",
"settings.mcp.alreadyConfigured": "Konfiguriert",
"settings.toast.mcpConfigSuccess": "bDS MCP-Server zur {agent}-Konfiguration hinzugefügt",
"settings.toast.mcpConfigFailed": "Konfiguration von {agent} fehlgeschlagen: {error}",
"settings.toast.mcpConfigPath": "Konfiguration geschrieben nach {path}"
}

View File

@@ -975,5 +975,21 @@
"importAnalysis.macroUses": "{count} uses",
"importAnalysis.usedIn": "Used in: {items}{more}",
"importAnalysis.moreSuffix": ", +{count} more",
"importAnalysis.noParameters": "(no parameters)"
"importAnalysis.noParameters": "(no parameters)",
"sidebar.nav.mcp": "MCP Server",
"settings.mcp.title": "MCP Server",
"settings.mcp.description": "Configure the Model Context Protocol server that allows AI coding agents to interact with your blog.",
"settings.mcp.statusLabel": "Server Status",
"settings.mcp.statusDescription": "Current status of the MCP server.",
"settings.mcp.portRunning": "Running on port {port}",
"settings.mcp.portStopped": "Not running",
"settings.mcp.agentsTitle": "Agent Configuration",
"settings.mcp.agentsDescription": "Add the bDS MCP server to your coding agent's configuration. Existing settings are preserved.",
"settings.mcp.addToAgent": "Add to {agent}",
"settings.mcp.alreadyConfigured": "Configured",
"settings.toast.mcpConfigSuccess": "bDS MCP server added to {agent} configuration",
"settings.toast.mcpConfigFailed": "Failed to configure {agent}: {error}",
"settings.toast.mcpConfigPath": "Config written to {path}"
}

View File

@@ -975,5 +975,21 @@
"importAnalysis.macroUses": "{count} usos",
"importAnalysis.usedIn": "Usado en: {items}{more}",
"importAnalysis.moreSuffix": ", +{count} más",
"importAnalysis.noParameters": "(sin parámetros)"
"importAnalysis.noParameters": "(sin parámetros)",
"sidebar.nav.mcp": "Servidor MCP",
"settings.mcp.title": "Servidor MCP",
"settings.mcp.description": "Configure el servidor Model Context Protocol que permite a los agentes de programación IA interactuar con su blog.",
"settings.mcp.statusLabel": "Estado del servidor",
"settings.mcp.statusDescription": "Estado actual del servidor MCP.",
"settings.mcp.portRunning": "Ejecutándose en el puerto {port}",
"settings.mcp.portStopped": "No está en ejecución",
"settings.mcp.agentsTitle": "Configuración de agentes",
"settings.mcp.agentsDescription": "Añada el servidor MCP de bDS a la configuración de su agente de programación. Las configuraciones existentes se conservan.",
"settings.mcp.addToAgent": "Añadir a {agent}",
"settings.mcp.alreadyConfigured": "Configurado",
"settings.toast.mcpConfigSuccess": "Servidor MCP de bDS añadido a la configuración de {agent}",
"settings.toast.mcpConfigFailed": "Error al configurar {agent}: {error}",
"settings.toast.mcpConfigPath": "Configuración escrita en {path}"
}

View File

@@ -973,5 +973,21 @@
"importAnalysis.macroUses": "{count} utilisations",
"importAnalysis.usedIn": "Utilisé dans : {items}{more}",
"importAnalysis.moreSuffix": ", +{count} de plus",
"importAnalysis.noParameters": "(aucun paramètre)"
"importAnalysis.noParameters": "(aucun paramètre)",
"sidebar.nav.mcp": "Serveur MCP",
"settings.mcp.title": "Serveur MCP",
"settings.mcp.description": "Configurez le serveur Model Context Protocol qui permet aux agents de programmation IA d'interagir avec votre blog.",
"settings.mcp.statusLabel": "État du serveur",
"settings.mcp.statusDescription": "État actuel du serveur MCP.",
"settings.mcp.portRunning": "En cours d'exécution sur le port {port}",
"settings.mcp.portStopped": "Non démarré",
"settings.mcp.agentsTitle": "Configuration des agents",
"settings.mcp.agentsDescription": "Ajoutez le serveur MCP bDS à la configuration de votre agent de programmation. Les paramètres existants sont préservés.",
"settings.mcp.addToAgent": "Ajouter à {agent}",
"settings.mcp.alreadyConfigured": "Configuré",
"settings.toast.mcpConfigSuccess": "Serveur MCP bDS ajouté à la configuration de {agent}",
"settings.toast.mcpConfigFailed": "Échec de la configuration de {agent}: {error}",
"settings.toast.mcpConfigPath": "Configuration écrite dans {path}"
}

View File

@@ -973,5 +973,21 @@
"importAnalysis.macroUses": "{count} utilizzi",
"importAnalysis.usedIn": "Usato in: {items}{more}",
"importAnalysis.moreSuffix": ", +{count} altri",
"importAnalysis.noParameters": "(nessun parametro)"
"importAnalysis.noParameters": "(nessun parametro)",
"sidebar.nav.mcp": "Server MCP",
"settings.mcp.title": "Server MCP",
"settings.mcp.description": "Configura il server Model Context Protocol che permette agli agenti di programmazione IA di interagire con il tuo blog.",
"settings.mcp.statusLabel": "Stato del server",
"settings.mcp.statusDescription": "Stato attuale del server MCP.",
"settings.mcp.portRunning": "In esecuzione sulla porta {port}",
"settings.mcp.portStopped": "Non in esecuzione",
"settings.mcp.agentsTitle": "Configurazione agenti",
"settings.mcp.agentsDescription": "Aggiungi il server MCP bDS alla configurazione del tuo agente di programmazione. Le impostazioni esistenti vengono preservate.",
"settings.mcp.addToAgent": "Aggiungi a {agent}",
"settings.mcp.alreadyConfigured": "Configurato",
"settings.toast.mcpConfigSuccess": "Server MCP bDS aggiunto alla configurazione di {agent}",
"settings.toast.mcpConfigFailed": "Configurazione di {agent} non riuscita: {error}",
"settings.toast.mcpConfigPath": "Configurazione scritta in {path}"
}

View File

@@ -86,7 +86,7 @@ describe('GenerationRouteRendererFactory', () => {
}
if (typeof filter.month === 'number') {
filtered = filtered.filter((post) => post.createdAt.getMonth() === filter.month);
filtered = filtered.filter((post) => post.createdAt.getMonth() === filter.month - 1);
}
if (filter.startDate) {

View File

@@ -0,0 +1,339 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MCPAgentConfigEngine, type MCPAgentId, type AgentConfigResult } from '../../src/main/engine/MCPAgentConfigEngine';
// Mock fs and os
const mockReadFileSync = vi.fn();
const mockWriteFileSync = vi.fn();
const mockExistsSync = vi.fn();
const mockMkdirSync = vi.fn();
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
default: {
...actual,
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
existsSync: (...args: unknown[]) => mockExistsSync(...args),
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
},
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
existsSync: (...args: unknown[]) => mockExistsSync(...args),
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
};
});
describe('MCPAgentConfigEngine', () => {
let engine: MCPAgentConfigEngine;
beforeEach(() => {
vi.clearAllMocks();
engine = new MCPAgentConfigEngine({
homeDir: '/home/testuser',
platform: 'darwin',
mcpUrl: 'http://127.0.0.1:4124/mcp',
});
});
describe('getAgents', () => {
it('returns all supported agent definitions', () => {
const agents = engine.getAgents();
expect(agents).toHaveLength(4);
const ids = agents.map((a) => a.id);
expect(ids).toContain('claude-code');
expect(ids).toContain('github-copilot');
expect(ids).toContain('gemini-cli');
expect(ids).toContain('opencode');
});
it('includes display labels for each agent', () => {
const agents = engine.getAgents();
for (const agent of agents) {
expect(agent.label).toBeTruthy();
expect(typeof agent.label).toBe('string');
}
});
});
describe('getConfigPath', () => {
it('returns ~/.claude.json for claude-code', () => {
expect(engine.getConfigPath('claude-code')).toBe('/home/testuser/.claude.json');
});
it('returns macOS VS Code user mcp.json for github-copilot on darwin', () => {
expect(engine.getConfigPath('github-copilot')).toBe(
'/home/testuser/Library/Application Support/Code/User/mcp.json',
);
});
it('returns Linux VS Code user mcp.json for github-copilot on linux', () => {
const linuxEngine = new MCPAgentConfigEngine({
homeDir: '/home/user',
platform: 'linux',
mcpUrl: 'http://127.0.0.1:4124/mcp',
});
expect(linuxEngine.getConfigPath('github-copilot')).toBe(
'/home/user/.config/Code/User/mcp.json',
);
});
it('returns ~/.gemini/settings.json for gemini-cli', () => {
expect(engine.getConfigPath('gemini-cli')).toBe('/home/testuser/.gemini/settings.json');
});
it('returns ~/.opencode.json for opencode', () => {
expect(engine.getConfigPath('opencode')).toBe('/home/testuser/.opencode.json');
});
});
describe('addToConfig (claude-code)', () => {
it('creates new config when file does not exist', () => {
mockExistsSync.mockReturnValue(false);
const result = engine.addToConfig('claude-code');
expect(result.success).toBe(true);
expect(result.configPath).toBe('/home/testuser/.claude.json');
expect(mockWriteFileSync).toHaveBeenCalledOnce();
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
expect(written.mcpServers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' });
});
it('merges into existing config without overwriting other servers', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(
JSON.stringify({
mcpServers: { other: { type: 'stdio', command: 'npx' } },
someOtherKey: 'keep',
}),
);
const result = engine.addToConfig('claude-code');
expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
expect(written.mcpServers.other).toEqual({ type: 'stdio', command: 'npx' });
expect(written.mcpServers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' });
expect(written.someOtherKey).toBe('keep');
});
it('overwrites existing bDS entry to update URL', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(
JSON.stringify({
mcpServers: { bDS: { type: 'http', url: 'http://old:1234/mcp' } },
}),
);
const result = engine.addToConfig('claude-code');
expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
expect(written.mcpServers.bDS.url).toBe('http://127.0.0.1:4124/mcp');
});
});
describe('addToConfig (github-copilot)', () => {
it('creates new .vscode/mcp.json with correct servers key', () => {
mockExistsSync.mockReturnValue(false);
const result = engine.addToConfig('github-copilot');
expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
expect(written.servers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' });
// Should NOT have mcpServers key
expect(written.mcpServers).toBeUndefined();
});
it('creates parent directory if needed', () => {
mockExistsSync.mockReturnValue(false);
engine.addToConfig('github-copilot');
expect(mockMkdirSync).toHaveBeenCalledWith(
expect.stringContaining('Code/User'),
{ recursive: true },
);
});
it('merges into existing VS Code config preserving other servers', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(
JSON.stringify({
servers: { github: { type: 'http', url: 'https://api.githubcopilot.com/mcp' } },
}),
);
const result = engine.addToConfig('github-copilot');
expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
expect(written.servers.github.url).toBe('https://api.githubcopilot.com/mcp');
expect(written.servers.bDS.url).toBe('http://127.0.0.1:4124/mcp');
});
});
describe('addToConfig (gemini-cli)', () => {
it('creates new settings.json with httpUrl entry', () => {
mockExistsSync.mockReturnValue(false);
const result = engine.addToConfig('gemini-cli');
expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
expect(written.mcpServers.bDS).toEqual({ httpUrl: 'http://127.0.0.1:4124/mcp' });
});
it('creates ~/.gemini directory if needed', () => {
mockExistsSync.mockReturnValue(false);
engine.addToConfig('gemini-cli');
expect(mockMkdirSync).toHaveBeenCalledWith(
expect.stringContaining('.gemini'),
{ recursive: true },
);
});
it('preserves existing settings when adding MCP server', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(
JSON.stringify({
theme: 'dark',
mcpServers: { existing: { command: 'python' } },
}),
);
const result = engine.addToConfig('gemini-cli');
expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
expect(written.theme).toBe('dark');
expect(written.mcpServers.existing).toEqual({ command: 'python' });
expect(written.mcpServers.bDS).toEqual({ httpUrl: 'http://127.0.0.1:4124/mcp' });
});
});
describe('addToConfig (opencode)', () => {
it('creates new .opencode.json with sse type', () => {
mockExistsSync.mockReturnValue(false);
const result = engine.addToConfig('opencode');
expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
expect(written.mcpServers.bDS).toEqual({ type: 'sse', url: 'http://127.0.0.1:4124/mcp' });
});
it('merges into existing opencode config', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(
JSON.stringify({
providers: { openai: { apiKey: 'key' } },
mcpServers: { debugger: { type: 'stdio', command: 'debug' } },
}),
);
const result = engine.addToConfig('opencode');
expect(result.success).toBe(true);
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
expect(written.providers.openai.apiKey).toBe('key');
expect(written.mcpServers.debugger.command).toBe('debug');
expect(written.mcpServers.bDS.type).toBe('sse');
});
});
describe('error handling', () => {
it('returns error result when read fails', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockImplementation(() => {
throw new Error('Permission denied');
});
const result = engine.addToConfig('claude-code');
expect(result.success).toBe(false);
expect(result.error).toContain('Permission denied');
});
it('returns error result when write fails', () => {
mockExistsSync.mockReturnValue(false);
mockWriteFileSync.mockImplementation(() => {
throw new Error('Disk full');
});
const result = engine.addToConfig('claude-code');
expect(result.success).toBe(false);
expect(result.error).toContain('Disk full');
});
it('returns error for invalid existing JSON', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('not valid json{{{');
const result = engine.addToConfig('claude-code');
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
});
describe('isConfigured', () => {
it('returns true when bDS entry exists in config', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(
JSON.stringify({
mcpServers: { bDS: { type: 'http', url: 'http://127.0.0.1:4124/mcp' } },
}),
);
expect(engine.isConfigured('claude-code')).toBe(true);
});
it('returns false when config file does not exist', () => {
mockExistsSync.mockReturnValue(false);
expect(engine.isConfigured('claude-code')).toBe(false);
});
it('returns false when bDS entry is missing', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(
JSON.stringify({ mcpServers: { other: {} } }),
);
expect(engine.isConfigured('claude-code')).toBe(false);
});
it('checks VS Code servers key for github-copilot', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(
JSON.stringify({ servers: { bDS: { type: 'http', url: 'x' } } }),
);
expect(engine.isConfigured('github-copilot')).toBe(true);
});
});
describe('dynamic port', () => {
it('uses the provided mcpUrl in server entries', () => {
const customEngine = new MCPAgentConfigEngine({
homeDir: '/tmp',
platform: 'darwin',
mcpUrl: 'http://127.0.0.1:9999/mcp',
});
mockExistsSync.mockReturnValue(false);
customEngine.addToConfig('claude-code');
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
expect(written.mcpServers.bDS.url).toBe('http://127.0.0.1:9999/mcp');
});
});
});

View File

@@ -0,0 +1,212 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { MCPServer, type MCPServerDependencies } from '../../src/main/engine/MCPServer';
/**
* Integration tests for MCPServer HTTP transport.
* These start the actual HTTP server and send MCP protocol requests.
*/
function createMockDeps(): MCPServerDependencies {
return {
getPostEngine: () => ({
getAllPosts: vi.fn().mockResolvedValue({ items: [{ id: 'p1', title: 'Test Post' }], hasMore: false, total: 1 }),
getPost: vi.fn().mockResolvedValue({ id: 'p1', title: 'Test Post', content: '# Hello', slug: 'test' }),
searchPosts: vi.fn().mockResolvedValue([{ id: 'p1', title: 'Test Post', slug: 'test' }]),
createPost: vi.fn().mockResolvedValue({ id: 'draft-1', title: 'Draft', status: 'draft' }),
updatePost: vi.fn().mockResolvedValue({ id: 'p1', title: 'Updated' }),
publishPost: vi.fn().mockResolvedValue({ id: 'draft-1', status: 'published' }),
deletePost: vi.fn().mockResolvedValue(true),
getTagsWithCounts: vi.fn().mockResolvedValue([{ tag: 'js', count: 5 }]),
getCategoriesWithCounts: vi.fn().mockResolvedValue([{ category: 'tech', count: 3 }]),
getBlogStats: vi.fn().mockResolvedValue({ totalPosts: 10 }),
getLinkedBy: vi.fn().mockResolvedValue([]),
getLinksTo: vi.fn().mockResolvedValue([]),
getPostsFiltered: vi.fn().mockResolvedValue([]),
searchPostsFiltered: vi.fn().mockResolvedValue([]),
}),
getMediaEngine: () => ({
getAllMedia: vi.fn().mockResolvedValue([]),
getMedia: vi.fn().mockResolvedValue(null),
updateMedia: vi.fn().mockResolvedValue(null),
getThumbnailDataUrl: vi.fn().mockResolvedValue(null),
}),
getScriptEngine: () => ({
createScript: vi.fn().mockResolvedValue({ id: 's1' }),
validateScript: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
}),
getTemplateEngine: () => ({
createTemplate: vi.fn().mockResolvedValue({ id: 't1' }),
validateTemplate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
}),
getMetaEngine: () => ({
getProjectMetadata: vi.fn().mockResolvedValue(null),
}),
getPostMediaEngine: () => ({
getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]),
getLinkedPostsForMedia: vi.fn().mockResolvedValue([]),
}),
getTagEngine: () => ({
getTagsWithCounts: vi.fn().mockResolvedValue([]),
}),
};
}
async function sendMcpRequest(port: number, method: string, params?: unknown, id = 1): Promise<unknown> {
const body = JSON.stringify({
jsonrpc: '2.0',
id,
method,
params: params ?? {},
});
const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
body,
});
const text = await response.text();
const contentType = response.headers.get('content-type') ?? '';
// SSE format: "event: message\ndata: {json}\n\n"
if (contentType.includes('text/event-stream')) {
const dataLine = text.split('\n').find(l => l.startsWith('data: '));
if (dataLine) {
return JSON.parse(dataLine.slice(6));
}
}
return JSON.parse(text);
}
async function initializeSession(port: number): Promise<unknown> {
return sendMcpRequest(port, 'initialize', {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' },
});
}
describe('MCPServer integration', () => {
let server: MCPServer;
beforeEach(() => {
server = new MCPServer(createMockDeps());
});
afterEach(async () => {
await server.cleanup();
});
it('starts on a random port and responds to initialize', async () => {
const port = await server.start(0);
expect(port).toBeGreaterThan(0);
const result = await initializeSession(port) as { result?: { serverInfo?: { name: string } } };
expect(result.result?.serverInfo?.name).toBe('Blogging Desktop Server');
});
it('returns CORS headers', async () => {
const port = await server.start(0);
const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: 'OPTIONS',
});
expect(response.status).toBe(204);
expect(response.headers.get('Access-Control-Allow-Origin')).toBeTruthy();
expect(response.headers.get('Access-Control-Allow-Methods')).toContain('POST');
});
it('rejects requests from non-local origins', async () => {
const port = await server.start(0);
const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'https://evil-site.com',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } },
}),
});
expect(response.status).toBe(403);
});
it('allows requests from localhost origins', async () => {
const port = await server.start(0);
const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
'Origin': `http://localhost:${port}`,
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } },
}),
});
expect(response.status).not.toBe(403);
});
it('allows requests without Origin header', async () => {
const port = await server.start(0);
const result = await initializeSession(port) as { result?: { serverInfo?: { name: string } } };
expect(result.result?.serverInfo?.name).toBe('Blogging Desktop Server');
});
it('lists tools via tools/list after initialize', async () => {
const port = await server.start(0);
// MCP requires single request per connection in stateless mode — each request creates a new session
// Send initialize + tools/list in same session is not possible in stateless mode
// Instead, we can verify the server responds to a standalone initialize
const initResult = await initializeSession(port) as { result?: { capabilities?: { tools?: unknown } } };
expect(initResult.result?.capabilities?.tools).toBeDefined();
});
it('getPort returns the listening port', async () => {
expect(server.getPort()).toBeNull();
const port = await server.start(0);
expect(server.getPort()).toBe(port);
await server.stop();
expect(server.getPort()).toBeNull();
});
it('handles multiple sequential requests', async () => {
const port = await server.start(0);
const r1 = await initializeSession(port) as { result?: unknown };
const r2 = await initializeSession(port) as { result?: unknown };
expect(r1.result).toBeDefined();
expect(r2.result).toBeDefined();
});
it('returns 500 for malformed JSON', async () => {
const port = await server.start(0);
const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
body: '{invalid json',
});
// The server should handle this gracefully
expect(response.status).toBeGreaterThanOrEqual(400);
});
it('stop is idempotent', async () => {
await server.start(0);
await server.stop();
await expect(server.stop()).resolves.toBeUndefined();
});
it('cleanup stops server and clears proposals', async () => {
server.proposalStore.create('draftPost', { postId: 'p1' });
await server.start(0);
await server.cleanup();
expect(server.proposalStore.getAll()).toHaveLength(0);
expect(server.getPort()).toBeNull();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1380,7 +1380,7 @@ tags: ["nature", "sunset"]`;
return chain;
});
const result = await mediaEngine.getMediaFiltered({ year: 2024, month: 5 }); // June (0-indexed)
const result = await mediaEngine.getMediaFiltered({ year: 2024, month: 6 }); // June (1-indexed)
expect(result).toHaveLength(1);
});
@@ -1452,9 +1452,9 @@ tags: ["nature", "sunset"]`;
const result = await mediaEngine.getMediaByYearMonth();
// Note: month is 0-indexed from Date.getMonth()
expect(result).toContainEqual({ year: 2024, month: 0, count: 2 }); // January
expect(result).toContainEqual({ year: 2024, month: 1, count: 1 }); // February
// month is 1-indexed
expect(result).toContainEqual({ year: 2024, month: 1, count: 2 }); // January
expect(result).toContainEqual({ year: 2024, month: 2, count: 1 }); // February
});
it('should sort by year and month descending', async () => {
@@ -1479,7 +1479,7 @@ tags: ["nature", "sunset"]`;
const result = await mediaEngine.getMediaByYearMonth();
expect(result[0].year).toBe(2024);
expect(result[0].month).toBe(2); // March is month 2 (0-indexed)
expect(result[0].month).toBe(3); // March is month 3 (1-indexed)
});
});

View File

@@ -2418,6 +2418,69 @@ Published snapshot content`);
});
});
describe('searchPostsFiltered', () => {
it('should return empty array for empty query', async () => {
const result = await postEngine.searchPostsFiltered('', {});
expect(result).toEqual([]);
});
it('should use FTS JOIN with posts table to combine search and filters', async () => {
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{ id: 'p1', projectId: 'test-project', title: 'Found', slug: 'found', excerpt: 'Excerpt', content: 'Content', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '["js"]', categories: '["tech"]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null },
],
});
const result = await postEngine.searchPostsFiltered('search term', { status: 'published' });
expect(result).toHaveLength(1);
expect(result[0].id).toBe('p1');
// Verify SQL includes both MATCH and status filter
const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined;
const sql = call?.sql?.toLowerCase() ?? '';
expect(sql).toContain('match');
expect(sql).toContain('status');
expect(sql).toContain('order by');
expect(sql).toContain('rank');
});
it('should apply category filter in SQL', async () => {
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
await postEngine.searchPostsFiltered('term', { categories: ['tech'] });
const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined;
const sql = call?.sql?.toLowerCase() ?? '';
expect(sql).toContain('match');
expect(sql).toContain('json_each');
});
it('should apply tag filter client-side after SQL query', async () => {
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{ id: 'p1', projectId: 'test-project', title: 'Has Tag', slug: 'has-tag', excerpt: null, content: '', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '["js", "react"]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null },
{ id: 'p2', projectId: 'test-project', title: 'No Tag', slug: 'no-tag', excerpt: null, content: '', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '["python"]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null },
],
});
const result = await postEngine.searchPostsFiltered('term', { tags: ['js'] });
expect(result).toHaveLength(1);
expect(result[0].id).toBe('p1');
});
it('should apply pagination with offset and limit', async () => {
const rows = Array.from({ length: 5 }, (_, i) => ({
id: `p${i}`, projectId: 'test-project', title: `Post ${i}`, slug: `post-${i}`, excerpt: null, content: '', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '[]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null,
}));
mockLocalClient.execute.mockResolvedValueOnce({ rows });
const result = await postEngine.searchPostsFiltered('term', {}, { offset: 1, limit: 2 });
expect(result).toHaveLength(2);
expect(result[0].id).toBe('p1');
expect(result[1].id).toBe('p2');
});
});
describe('getTagsWithCounts', () => {
it('should return empty array when no posts have tags', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
@@ -2590,10 +2653,10 @@ Published snapshot content`);
const result = await postEngine.getPostsByYearMonth();
// Note: getMonth() returns 0-11, so January is 0, February is 1, etc.
expect(result).toContainEqual({ year: 2024, month: 0, count: 2 }); // January
expect(result).toContainEqual({ year: 2024, month: 1, count: 1 }); // February
expect(result).toContainEqual({ year: 2023, month: 11, count: 1 }); // December
// months are 1-indexed (January=1, February=2, etc.)
expect(result).toContainEqual({ year: 2024, month: 1, count: 2 }); // January
expect(result).toContainEqual({ year: 2024, month: 2, count: 1 }); // February
expect(result).toContainEqual({ year: 2023, month: 12, count: 1 }); // December
});
it('should sort by year and month descending', async () => {
@@ -2614,7 +2677,7 @@ Published snapshot content`);
const result = await postEngine.getPostsByYearMonth();
expect(result[0].year).toBe(2024);
expect(result[0].month).toBe(2); // March (0-indexed)
expect(result[0].month).toBe(3); // March (1-indexed)
expect(result[result.length - 1].year).toBe(2023);
});
});

View File

@@ -81,7 +81,7 @@ function makeEngine(posts: PostData[]): PostEngineLike {
}
if (filter.month !== undefined && filter.year !== undefined) {
result = result.filter((post) => post.createdAt.getUTCMonth() === filter.month);
result = result.filter((post) => post.createdAt.getUTCMonth() === filter.month - 1);
}
if (filter.startDate) {

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

@@ -2,6 +2,15 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as fs from 'fs/promises';
import { ScriptEngine } from '../../src/main/engine/ScriptEngine';
const { mockExecFile } = vi.hoisted(() => ({
mockExecFile: vi.fn(),
}));
vi.mock('node:child_process', () => ({
execFile: mockExecFile,
default: { execFile: mockExecFile },
}));
const mockScripts = new Map<string, any>();
const mockFiles = new Map<string, string>();
@@ -377,4 +386,63 @@ describe('ScriptEngine', () => {
expect(found).toBeNull();
});
});
describe('validateScript', () => {
it('returns valid for correct Python syntax', async () => {
mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: null, stdout: string) => void) => {
cb(null, JSON.stringify({ valid: true, errors: [] }));
return { stdin: { write: vi.fn(), end: vi.fn() } };
});
const result = await scriptEngine.validateScript('print("hello")');
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it('returns invalid with errors for bad Python syntax', async () => {
mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: null, stdout: string) => void) => {
cb(null, JSON.stringify({ valid: false, errors: ['invalid syntax (line 1, col 5)'] }));
return { stdin: { write: vi.fn(), end: vi.fn() } };
});
const result = await scriptEngine.validateScript('def (');
expect(result.valid).toBe(false);
expect(result.errors).toEqual(['invalid syntax (line 1, col 5)']);
});
it('returns valid when python3 is not available', async () => {
mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error, stdout: string) => void) => {
cb(new Error('spawn python3 ENOENT'), '');
return { stdin: { write: vi.fn(), end: vi.fn() } };
});
const result = await scriptEngine.validateScript('print("hello")');
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it('returns valid when python3 output is not parseable JSON', async () => {
mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: null, stdout: string) => void) => {
cb(null, 'not json');
return { stdin: { write: vi.fn(), end: vi.fn() } };
});
const result = await scriptEngine.validateScript('print("hello")');
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it('passes script content via stdin', async () => {
const writeFn = vi.fn();
const endFn = vi.fn();
mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: unknown, cb: (err: null, stdout: string) => void) => {
cb(null, JSON.stringify({ valid: true, errors: [] }));
return { stdin: { write: writeFn, end: endFn } };
});
await scriptEngine.validateScript('x = 42');
expect(writeFn).toHaveBeenCalledWith('x = 42');
expect(endFn).toHaveBeenCalled();
});
});
});

View File

@@ -59,7 +59,7 @@ function makeEngine(posts: PostData[], snapshotsById: Record<string, PostData |
}
if (filter.month !== undefined && filter.year !== undefined) {
result = result.filter((post) => post.createdAt.getMonth() === filter.month);
result = result.filter((post) => post.createdAt.getMonth() === filter.month - 1);
}
if (filter.startDate) {
@@ -110,7 +110,7 @@ describe('SharedSnapshotService', () => {
engine,
'my-post',
{ useDraftContent: true, draftPostId: 'draft-1' },
{ year: 2025, month: 2, day: 21 },
{ year: 2025, month: 3, day: 21 },
);
expect(result?.id).toBe('draft-1');
@@ -125,7 +125,7 @@ describe('SharedSnapshotService', () => {
findPublishedBySlug,
};
const result = await findSinglePostBySlug(engineWithShortcut, 'shortcut', undefined, { year: 2025, month: 0, day: 2 });
const result = await findSinglePostBySlug(engineWithShortcut, 'shortcut', undefined, { year: 2025, month: 1, day: 2 });
expect(result?.id).toBe('x1');
expect(findPublishedBySlug).toHaveBeenCalled();

View File

@@ -91,6 +91,7 @@ describe('main bootstrap preview behavior', () => {
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
registerEventForwarding: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
@@ -209,6 +210,7 @@ describe('main bootstrap preview behavior', () => {
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
registerEventForwarding: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
@@ -356,6 +358,7 @@ describe('main bootstrap preview behavior', () => {
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
registerEventForwarding: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
@@ -468,11 +471,16 @@ describe('main bootstrap preview behavior', () => {
PreviewServer: MockPreviewServer,
}));
vi.doMock('fs', () => ({
existsSync: vi.fn((targetPath: string) => targetPath.includes('window-state.json')),
readFileSync: vi.fn(() => JSON.stringify({ x: 120, y: 80, width: 1280, height: 820 })),
writeFileSync: vi.fn(),
}));
vi.doMock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
const mocked = {
...actual,
existsSync: vi.fn((targetPath: string) => targetPath.includes('window-state.json')),
readFileSync: vi.fn(() => JSON.stringify({ x: 120, y: 80, width: 1280, height: 820 })),
writeFileSync: vi.fn(),
};
return { ...mocked, default: mocked };
});
vi.doMock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
@@ -493,6 +501,7 @@ describe('main bootstrap preview behavior', () => {
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
registerEventForwarding: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
@@ -593,11 +602,16 @@ describe('main bootstrap preview behavior', () => {
PreviewServer: MockPreviewServer,
}));
vi.doMock('fs', () => ({
existsSync: vi.fn((targetPath: string) => targetPath.includes('window-state.json')),
readFileSync: vi.fn(() => JSON.stringify({ x: -40, y: -10, width: 1800, height: 1000 })),
writeFileSync: vi.fn(),
}));
vi.doMock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
const mocked = {
...actual,
existsSync: vi.fn((targetPath: string) => targetPath.includes('window-state.json')),
readFileSync: vi.fn(() => JSON.stringify({ x: -40, y: -10, width: 1800, height: 1000 })),
writeFileSync: vi.fn(),
};
return { ...mocked, default: mocked };
});
vi.doMock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
@@ -618,6 +632,7 @@ describe('main bootstrap preview behavior', () => {
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
registerEventForwarding: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
@@ -770,6 +785,7 @@ describe('main bootstrap preview behavior', () => {
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
registerEventForwarding: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
@@ -951,6 +967,7 @@ describe('main bootstrap preview behavior', () => {
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
registerEventForwarding: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
@@ -1135,6 +1152,7 @@ describe('main bootstrap preview behavior', () => {
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
registerEventForwarding: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),

View File

@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import { buildMcpView, type McpViewConfig } from '../../src/main/engine/mcp-view-builder';
describe('mcp-view-builder', () => {
describe('buildMcpView', () => {
const minimalConfig: McpViewConfig = {
title: 'Test View',
waitingMessage: 'Waiting for data...',
renderBody: `
document.getElementById("review").innerHTML = "<h1>Hello</h1>";
`,
acceptLabel: 'Accept',
discardLabel: 'Discard',
};
it('returns a valid HTML document', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('sets the page title', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('<title>Test View</title>');
});
it('contains the waiting message', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('Waiting for data...');
});
it('inlines the ext-apps bundle and exposes App globally', () => {
const html = buildMcpView(minimalConfig);
// The bundle is inlined (no external import URLs) — the loader strips the
// ESM export block and exposes App on globalThis.__bdsExtApp so the page
// script can instantiate it without a module specifier.
expect(html).toContain('__bdsExtApp');
expect(html).toContain('new App(');
// Must NOT reference an external CDN for the bundle (distribution isolation).
expect(html).not.toMatch(/<script[^>]+src=["']https?:/i);
expect(html).not.toMatch(/<link[^>]+href=["']https?:/i);
});
it('contains accept and discard proposal handlers', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('window.acceptProposal');
expect(html).toContain('window.discardProposal');
});
it('uses custom button labels in renderBody', () => {
const config: McpViewConfig = {
...minimalConfig,
renderBody: `
document.getElementById("review").innerHTML = \`
<div class="actions">
<button class="btn btn-accept" onclick="acceptProposal()">Accept</button>
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
</div>
\`;`,
};
const html = buildMcpView(config);
expect(html).toContain('onclick="acceptProposal()"');
expect(html).toContain('onclick="discardProposal()"');
});
it('calls accept_proposal and discard_proposal tools via app bridge', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('app.callServerTool');
expect(html).toContain('"accept_proposal"');
expect(html).toContain('"discard_proposal"');
});
it('contains the custom renderBody code', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('document.getElementById("review").innerHTML = "<h1>Hello</h1>"');
});
it('uses module script type', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('type="module"');
});
it('connects the App on load', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('app.connect()');
});
it('has a status display element', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('id="status"');
expect(html).toContain('showStatus');
});
it('disables buttons during action', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('setButtonsDisabled(true)');
});
it('uses XSS-safe escaping function', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('function esc(');
expect(html).toContain('document.createElement("div")');
});
it('renders tool result data via ontoolresult handler', () => {
const html = buildMcpView(minimalConfig);
expect(html).toContain('app.ontoolresult');
expect(html).toContain('renderReview');
});
it('includes extra CSS when provided', () => {
const config: McpViewConfig = {
...minimalConfig,
extraCss: '.custom-class { color: red; }',
};
const html = buildMcpView(config);
expect(html).toContain('.custom-class { color: red; }');
});
it('includes extra JS helpers when provided', () => {
const config: McpViewConfig = {
...minimalConfig,
extraJsHelpers: 'function fmt(v) { return String(v); }',
};
const html = buildMcpView(config);
expect(html).toContain('function fmt(v) { return String(v); }');
});
it('does not include extra CSS/JS when not provided', () => {
const html = buildMcpView(minimalConfig);
// The shared CSS is always present; just ensure no extra markers
expect(html).not.toContain('function fmt(');
});
});
});

View File

@@ -0,0 +1,176 @@
import { describe, it, expect } from 'vitest';
import {
reviewPostHtml,
reviewScriptHtml,
reviewTemplateHtml,
reviewMetadataHtml,
} from '../../src/main/engine/mcp-views';
describe('mcp-views', () => {
describe('reviewPostHtml', () => {
it('returns valid HTML document', () => {
const html = reviewPostHtml();
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('inlines ext-apps bundle and uses App class', () => {
const html = reviewPostHtml();
expect(html).toContain('__bdsExtApp'); // inlined bundle sets global
expect(html).toContain('new App('); // SHARED_JS uses the global
});
it('contains accept and discard buttons', () => {
const html = reviewPostHtml();
expect(html).toContain('acceptProposal()');
expect(html).toContain('discardProposal()');
});
it('calls accept_proposal and discard_proposal tools via app bridge', () => {
const html = reviewPostHtml();
expect(html).toContain('app.callServerTool');
expect(html).toContain('"accept_proposal"');
expect(html).toContain('"discard_proposal"');
});
it('contains post-specific UI elements', () => {
const html = reviewPostHtml();
expect(html).toContain('Review Post');
expect(html).toContain('Publish');
expect(html).toContain('badge-draft');
expect(html).toContain('word-count');
});
it('renders tool result data via ontoolresult handler', () => {
const html = reviewPostHtml();
expect(html).toContain('app.ontoolresult');
expect(html).toContain('renderReview');
});
it('uses XSS-safe escaping function', () => {
const html = reviewPostHtml();
expect(html).toContain('function esc(');
expect(html).toContain('document.createElement("div")');
});
});
describe('reviewScriptHtml', () => {
it('returns valid HTML document', () => {
const html = reviewScriptHtml();
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('inlines ext-apps bundle and uses App class', () => {
const html = reviewScriptHtml();
expect(html).toContain('__bdsExtApp');
expect(html).toContain('new App(');
});
it('contains accept and discard buttons', () => {
const html = reviewScriptHtml();
expect(html).toContain('acceptProposal()');
expect(html).toContain('discardProposal()');
});
it('contains script-specific UI elements', () => {
const html = reviewScriptHtml();
expect(html).toContain('Review Script');
expect(html).toContain('Create Script');
expect(html).toContain('Python Code');
});
});
describe('reviewTemplateHtml', () => {
it('returns valid HTML document', () => {
const html = reviewTemplateHtml();
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('inlines ext-apps bundle and uses App class', () => {
const html = reviewTemplateHtml();
expect(html).toContain('__bdsExtApp');
expect(html).toContain('new App(');
});
it('contains accept and discard buttons', () => {
const html = reviewTemplateHtml();
expect(html).toContain('acceptProposal()');
expect(html).toContain('discardProposal()');
});
it('contains template-specific UI elements', () => {
const html = reviewTemplateHtml();
expect(html).toContain('Review Template');
expect(html).toContain('Create Template');
expect(html).toContain('Liquid Template');
});
});
describe('reviewMetadataHtml', () => {
it('returns valid HTML document', () => {
const html = reviewMetadataHtml();
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('inlines ext-apps bundle and uses App class', () => {
const html = reviewMetadataHtml();
expect(html).toContain('__bdsExtApp');
expect(html).toContain('new App(');
});
it('contains accept and discard buttons', () => {
const html = reviewMetadataHtml();
expect(html).toContain('acceptProposal()');
expect(html).toContain('discardProposal()');
});
it('contains metadata-diff UI elements', () => {
const html = reviewMetadataHtml();
expect(html).toContain('Metadata Changes');
expect(html).toContain('Apply Changes');
expect(html).toContain('diff-table');
expect(html).toContain('Current');
expect(html).toContain('Proposed');
});
it('contains diff formatting function', () => {
const html = reviewMetadataHtml();
expect(html).toContain('function fmt(');
expect(html).toContain('diff-old');
expect(html).toContain('diff-new');
});
});
describe('shared behavior', () => {
const allViews = [
{ name: 'reviewPostHtml', fn: () => reviewPostHtml() },
{ name: 'reviewScriptHtml', fn: () => reviewScriptHtml() },
{ name: 'reviewTemplateHtml', fn: () => reviewTemplateHtml() },
{ name: 'reviewMetadataHtml', fn: () => reviewMetadataHtml() },
];
it.each(allViews)('$name connects the App on load', ({ fn }) => {
const html = fn();
expect(html).toContain('app.connect()');
});
it.each(allViews)('$name has a status display element', ({ fn }) => {
const html = fn();
expect(html).toContain('id="status"');
expect(html).toContain('showStatus');
});
it.each(allViews)('$name disables buttons during action', ({ fn }) => {
const html = fn();
expect(html).toContain('setButtonsDisabled(true)');
});
it.each(allViews)('$name uses module script type', ({ fn }) => {
const html = fn();
expect(html).toContain('type="module"');
});
});
});

View File

@@ -10,6 +10,9 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"paths": {
"@modelcontextprotocol/ext-apps/server": ["./node_modules/@modelcontextprotocol/ext-apps/dist/src/server/index.d.ts"]
},
"declaration": true,
"declarationMap": true,
"sourceMap": true,