@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
150
CLAUDE.md
@@ -1,149 +1 @@
|
||||
# GitHub Copilot Instructions for Blogging Desktop Server (bDS)
|
||||
|
||||
This document provides context and best practices for GitHub Copilot when working on this Electron + TypeScript + SQLite blogging application.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Blogging Desktop Server (bDS)** is a desktop blogging application built with:
|
||||
- **Electron** v28+ for cross-platform desktop
|
||||
- **TypeScript** for all code (strict mode)
|
||||
- **React** for the renderer UI
|
||||
- **Drizzle ORM** for type-safe database access
|
||||
- **@libsql/client** for SQLite (local database)
|
||||
- **Zustand** for React state management
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ MANDATORY: Test-First Development
|
||||
|
||||
**STOP!** Before writing ANY implementation code, you MUST:
|
||||
|
||||
1. **Write a failing test first** that describes the expected behavior
|
||||
2. **Run the test** to confirm it fails (Red)
|
||||
3. **Write minimal code** to make the test pass (Green)
|
||||
4. **Refactor** while keeping tests green
|
||||
|
||||
> **No code without tests. No exceptions.**
|
||||
>
|
||||
> Tests must import and exercise the REAL implementation classes, not inline helper functions.
|
||||
> Mock only external dependencies (database, filesystem), never the class under test.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ MANDATORY: Fix All Test Failures
|
||||
|
||||
**You MUST investigate and fix ALL test failures before completing any task.**
|
||||
|
||||
- Never leave tests failing, even if they appear unrelated to your changes
|
||||
- If a test failure is pre-existing, fix it as part of your current work
|
||||
- Run the full test suite (`npm test`) before considering any task complete
|
||||
- If you cannot fix a test, explain why and propose a solution
|
||||
|
||||
> **Zero failing tests. No exceptions.**
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ MANDATORY: Remove Unused Code
|
||||
|
||||
**Never keep unused code around. Always delete it completely.**
|
||||
|
||||
- When a feature is removed, delete ALL related code (implementation, tests, types, configs)
|
||||
- Do NOT comment out code "for later" - use version control history
|
||||
- Do NOT skip tests for removed functionality - delete them
|
||||
- Do NOT leave dead code paths, unused imports, or orphaned functions
|
||||
- When refactoring, actively look for and remove any code that becomes unused
|
||||
|
||||
> **Delete unused code immediately. No exceptions.**
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ MANDATORY: Build Verification After Code Changes
|
||||
|
||||
**You MUST run the full build after making code changes.**
|
||||
|
||||
- Run `npm run build` after any code modifications
|
||||
- Fix ALL build errors before considering the task complete
|
||||
- Build errors indicate issues that may not be caught by `tsc --noEmit` alone (e.g., event forwarding, renderer build)
|
||||
- The build must complete successfully before the task is complete
|
||||
|
||||
> **Successful build required. No exceptions.**
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ MANDATORY: No External JS/CSS in Preview or Generated HTML
|
||||
|
||||
**Do not reference external JavaScript or CSS libraries (CDNs/remote URLs) from the preview server output or generated HTML.**
|
||||
|
||||
- Preview HTML must reference only local/package-bundled assets
|
||||
- Generated HTML must not include CDN-hosted JS/CSS libraries
|
||||
- If a library is needed (e.g., Pico CSS, Lightbox), include it as a local dependency and serve/reference it locally
|
||||
- Avoid introducing any new `<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
149
GEMINI.md
@@ -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
158
MCP_PLAN.md
Normal 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
512
TODO.md
@@ -256,13 +256,20 @@ In `PageRenderer` and `BlogGenerationEngine`:
|
||||
|
||||
---
|
||||
|
||||
## 3. MCP Server
|
||||
## 3. MCP Server — Agent-Assisted Content Creation
|
||||
|
||||
### Goal
|
||||
|
||||
Host an MCP (Model Context Protocol) server inside the application so external
|
||||
AI agents (Claude Code, Cursor, etc.) can connect and use bDS tools to query
|
||||
and manage blog content.
|
||||
AI agents (Claude Code, Cursor, etc.) can connect and work with bDS. The core
|
||||
principle: **read access is open, write access goes through user-reviewable
|
||||
drafts**. External agents never silently mutate data — they propose changes
|
||||
that the user accepts or discards inside the agent's UI.
|
||||
|
||||
This enables powerful workflows: an agent can read existing posts, draft a new
|
||||
one with pre-filled content, propose a Python script or Liquid template, or
|
||||
suggest image metadata improvements — and the user always has final say before
|
||||
anything enters the system.
|
||||
|
||||
### Current State
|
||||
|
||||
@@ -270,14 +277,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
1091
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -83,6 +83,8 @@
|
||||
"@milkdown/plugin-trailing": "^7.18.0",
|
||||
"@milkdown/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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
156
src/main/engine/MCPAgentConfigEngine.ts
Normal file
156
src/main/engine/MCPAgentConfigEngine.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
869
src/main/engine/MCPServer.ts
Normal file
869
src/main/engine/MCPServer.ts
Normal 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;
|
||||
}
|
||||
@@ -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++;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++;
|
||||
|
||||
68
src/main/engine/ProposalStore.ts
Normal file
68
src/main/engine/ProposalStore.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export type ProposalType =
|
||||
| 'draftPost'
|
||||
| 'proposeScript'
|
||||
| 'proposeTemplate'
|
||||
| 'proposeMediaMetadata'
|
||||
| 'proposePostMetadata';
|
||||
|
||||
export interface Proposal {
|
||||
id: string;
|
||||
type: ProposalType;
|
||||
data: Record<string, unknown>;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
export class ProposalStore {
|
||||
private readonly proposals = new Map<string, Proposal>();
|
||||
private readonly ttlMs: number;
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(ttlMs: number = DEFAULT_TTL_MS) {
|
||||
this.ttlMs = ttlMs;
|
||||
this.cleanupInterval = setInterval(() => this.cleanup(), this.ttlMs);
|
||||
}
|
||||
|
||||
create(type: ProposalType, data: Record<string, unknown>): string {
|
||||
const id = randomUUID();
|
||||
this.proposals.set(id, {
|
||||
id,
|
||||
type,
|
||||
data,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
get(id: string): Proposal | undefined {
|
||||
return this.proposals.get(id);
|
||||
}
|
||||
|
||||
remove(id: string): void {
|
||||
this.proposals.delete(id);
|
||||
}
|
||||
|
||||
getAll(): Proposal[] {
|
||||
return Array.from(this.proposals.values());
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [id, proposal] of this.proposals) {
|
||||
if (now - proposal.createdAt > this.ttlMs) {
|
||||
this.proposals.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.proposals.clear();
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
212
src/main/engine/mcp-view-builder.ts
Normal file
212
src/main/engine/mcp-view-builder.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
129
src/main/engine/mcp-views.ts
Normal file
129
src/main/engine/mcp-views.ts
Normal 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>
|
||||
\`;`,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { registerIpcHandlers } from './handlers';
|
||||
export { registerIpcHandlers, registerEventForwarding } from './handlers';
|
||||
export { registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './chatHandlers';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
339
tests/engine/MCPConfigEngine.test.ts
Normal file
339
tests/engine/MCPConfigEngine.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
212
tests/engine/MCPServer.integration.test.ts
Normal file
212
tests/engine/MCPServer.integration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
1002
tests/engine/MCPServer.test.ts
Normal file
1002
tests/engine/MCPServer.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
148
tests/engine/ProposalStore.test.ts
Normal file
148
tests/engine/ProposalStore.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { ProposalStore, type Proposal, type ProposalType } from '../../src/main/engine/ProposalStore';
|
||||
|
||||
describe('ProposalStore', () => {
|
||||
let store: ProposalStore;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
store = new ProposalStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.destroy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a proposal and returns an id', () => {
|
||||
const id = store.create('draftPost', { postId: 'post-1' });
|
||||
expect(id).toBeTruthy();
|
||||
expect(typeof id).toBe('string');
|
||||
});
|
||||
|
||||
it('generates unique ids for each proposal', () => {
|
||||
const id1 = store.create('draftPost', { postId: 'post-1' });
|
||||
const id2 = store.create('draftPost', { postId: 'post-2' });
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('returns a created proposal', () => {
|
||||
const id = store.create('proposeScript', { title: 'My Script', content: 'print("hello")' });
|
||||
const proposal = store.get(id);
|
||||
expect(proposal).toBeDefined();
|
||||
expect(proposal!.type).toBe('proposeScript');
|
||||
expect(proposal!.data).toEqual({ title: 'My Script', content: 'print("hello")' });
|
||||
});
|
||||
|
||||
it('returns undefined for non-existent id', () => {
|
||||
expect(store.get('non-existent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('removes a proposal', () => {
|
||||
const id = store.create('draftPost', { postId: 'post-1' });
|
||||
expect(store.get(id)).toBeDefined();
|
||||
store.remove(id);
|
||||
expect(store.get(id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing when removing non-existent id', () => {
|
||||
expect(() => store.remove('non-existent')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('returns all proposals', () => {
|
||||
store.create('draftPost', { postId: 'post-1' });
|
||||
store.create('proposeScript', { title: 'Script' });
|
||||
const all = store.getAll();
|
||||
expect(all).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns empty array when no proposals exist', () => {
|
||||
expect(store.getAll()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TTL expiry', () => {
|
||||
it('expires proposals after TTL', () => {
|
||||
const id = store.create('draftPost', { postId: 'post-1' });
|
||||
expect(store.get(id)).toBeDefined();
|
||||
|
||||
// Advance past 30-minute TTL
|
||||
vi.advanceTimersByTime(31 * 60 * 1000);
|
||||
store.cleanup();
|
||||
|
||||
expect(store.get(id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps proposals within TTL', () => {
|
||||
const id = store.create('draftPost', { postId: 'post-1' });
|
||||
|
||||
// Advance less than TTL
|
||||
vi.advanceTimersByTime(15 * 60 * 1000);
|
||||
store.cleanup();
|
||||
|
||||
expect(store.get(id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('supports custom TTL', () => {
|
||||
const shortStore = new ProposalStore(5 * 60 * 1000); // 5 minutes
|
||||
const id = shortStore.create('draftPost', { postId: 'post-1' });
|
||||
|
||||
vi.advanceTimersByTime(6 * 60 * 1000);
|
||||
shortStore.cleanup();
|
||||
|
||||
expect(shortStore.get(id)).toBeUndefined();
|
||||
shortStore.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('removes only expired proposals', () => {
|
||||
const id1 = store.create('draftPost', { postId: 'post-1' });
|
||||
|
||||
// Advance 20 minutes
|
||||
vi.advanceTimersByTime(20 * 60 * 1000);
|
||||
|
||||
const id2 = store.create('proposeScript', { title: 'Script' });
|
||||
|
||||
// Advance 15 more minutes (id1 = 35 min old = expired, id2 = 15 min old = ok)
|
||||
vi.advanceTimersByTime(15 * 60 * 1000);
|
||||
store.cleanup();
|
||||
|
||||
expect(store.get(id1)).toBeUndefined();
|
||||
expect(store.get(id2)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('clears all proposals', () => {
|
||||
store.create('draftPost', { postId: 'post-1' });
|
||||
store.create('proposeScript', { title: 'Script' });
|
||||
store.destroy();
|
||||
expect(store.getAll()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proposal types', () => {
|
||||
const types: ProposalType[] = [
|
||||
'draftPost',
|
||||
'proposeScript',
|
||||
'proposeTemplate',
|
||||
'proposeMediaMetadata',
|
||||
'proposePostMetadata',
|
||||
];
|
||||
|
||||
it.each(types)('stores and retrieves %s type', (type) => {
|
||||
const id = store.create(type, { test: true });
|
||||
const proposal = store.get(id);
|
||||
expect(proposal).toBeDefined();
|
||||
expect(proposal!.type).toBe(type);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
135
tests/engine/mcp-view-builder.test.ts
Normal file
135
tests/engine/mcp-view-builder.test.ts
Normal 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(');
|
||||
});
|
||||
});
|
||||
});
|
||||
176
tests/engine/mcp-views.test.ts
Normal file
176
tests/engine/mcp-views.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user