8.5 KiB
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_TYPEfor interactive review UIs rendered inline in compliant hosts (Claude, ChatGPT, VS Code, etc.)express/cors— pulled in transitively by the SDK; used bycreateMcpExpressApp
zod v4 already in project satisfies peer dep requirements.
Architecture
PreviewServer (HTTP on 127.0.0.1:4123) ← unchanged
└── /* → Blog preview routes
MCPServer (HTTP on 127.0.0.1:4124) ← NEW, standalone
└── /mcp → Streamable HTTP (POST + GET/DELETE)
├── tools, resources, prompts
└── ui:// resources → MCP App review Views (via ext-apps)
The MCP SDK provides createMcpExpressApp which sets up Express with the correct Streamable HTTP handling. We use stateless mode (new McpServer per request) to avoid session management complexity.
MCPServer is a new engine class that owns the McpServer factory, tool/resource/prompt registration, and the ProposalStore. It runs independently of PreviewServer.
Implementation Steps
Step 1: Install deps
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps
Step 2: ProposalStore (src/main/engine/ProposalStore.ts)
Map<string, Proposal>with TTL (30 min default)create(type, data) → id,get(id),remove(id),cleanup()- Draft posts tracked by mapping proposalId → 'draftPost' (actual data in DB)
- Periodic cleanup via
setInterval
Step 3: MCPServer engine (src/main/engine/MCPServer.ts)
- Constructor: inject engine getters (PostEngine, MediaEngine, ScriptEngine, TemplateEngine, MetaEngine, PostMediaEngine, TagEngine)
createServer()→ factory that instantiates a freshMcpServerand registers all tools/resources/prompts (stateless mode — one per request)start(port)→ usescreateMcpExpressApp+StreamableHTTPServerTransportin stateless mode, listens on127.0.0.1:portstop()→ close HTTP servercleanup()→ discard proposals, stop intervals, stop server- Singleton pattern with
getMCPServer()getter
Step 4: Resources (read-only data)
Register MCP resources mapping to existing engine methods:
| Resource URI | Engine Method |
|---|---|
bds://posts |
PostEngine.getAllPosts() |
bds://posts/{id} |
PostEngine.getPost(id) |
bds://media |
MediaEngine.getAllMedia() |
bds://media/{id} |
MediaEngine.getMedia(id) |
bds://tags |
PostEngine.getTagsWithCounts() |
bds://categories |
PostEngine.getCategoriesWithCounts() |
bds://stats |
PostEngine.getBlogStats() + MediaEngine |
bds://posts/{id}/backlinks |
PostEngine.getLinkedBy(id) |
bds://posts/{id}/outlinks |
PostEngine.getLinksTo(id) |
bds://posts/{id}/media |
PostMediaEngine.getLinkedMediaDataForPost(id) |
bds://media/{id}/posts |
PostMediaEngine.getLinkedPostsForMedia(id) |
Step 5: Read tools
search_posts— annotations:{ readOnlyHint: true, openWorldHint: false }- Wraps PostEngine.searchPosts() with filter args (query, category, tags, year/month, offset, limit)
Step 6: Proposal tools (with MCP App Views)
Each proposal tool uses registerAppTool from @modelcontextprotocol/ext-apps/server to link the tool to a ui:// resource. The host renders the review View inline.
Each review View is a bundled HTML page (built with Vite vite-plugin-singlefile into a single self-contained HTML) that uses the App class from @modelcontextprotocol/ext-apps for bidirectional communication with the host.
| Tool | Action | ui:// Resource |
Accept | Discard |
|---|---|---|---|---|
draft_post |
PostEngine.createPost(draft) | ui://bds/review-post |
publishPost() | deletePost() |
propose_script |
Store in ProposalStore | ui://bds/review-script |
ScriptEngine.createScript() | remove from store |
propose_template |
Store in ProposalStore | ui://bds/review-template |
TemplateEngine.createTemplate() | remove from store |
propose_media_metadata |
Store diff in ProposalStore | ui://bds/review-metadata |
MediaEngine.updateMedia() | remove from store |
propose_post_metadata |
Store diff in ProposalStore | ui://bds/review-metadata |
PostEngine.updatePost() | remove from store |
Resource registration uses registerAppResource from @modelcontextprotocol/ext-apps/server. Each resource returns a bundled HTML string with mimeType: RESOURCE_MIME_TYPE.
Step 7: Accept/discard tools
accept_proposal({ proposalId })— dispatch by type, commit changediscard_proposal({ proposalId })— dispatch by type, clean up- Annotations: idempotentHint: true
Step 8: MCP Prompts
draft-blog-post(topic?, category?)— structured prompt guiding agent to read context and draftimprove-media-metadata(scope)— guide agent to review media and propose alt/captioncontent-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 callapp.callServerTool()→accept_proposal/discard_proposalreview-script.html— title, syntax-highlighted Python, validation statusreview-template.html— title, kind badge, syntax-highlighted Liquid, validationreview-metadata.html— side-by-side diff (current vs proposed)
Each View:
- Receives tool result data from host via
app.ontoolresult - Renders focused review interface
- On accept/discard: calls
app.callServerTool({ name: 'accept_proposal' | 'discard_proposal', arguments: { proposalId } }) - Updates UI to show outcome
Build step: vite build with vite-plugin-singlefile bundles each into a self-contained HTML string. These are read at runtime by registerAppResource handlers.
Step 10: Lifecycle in main.ts
- MCPServer starts independently alongside PreviewServer during app init
- On
before-quit: callMCPServer.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:
registerAppResourcereturns valid HTML withRESOURCE_MIME_TYPE
Key Files
| File | Purpose |
|---|---|
src/main/engine/ProposalStore.ts |
NEW — in-memory proposal storage |
src/main/engine/MCPServer.ts |
NEW — standalone MCP server engine |
src/main/mcp-apps/review-post.html |
NEW — post review View |
src/main/mcp-apps/review-script.html |
NEW — script review View |
src/main/mcp-apps/review-template.html |
NEW — template review View |
src/main/mcp-apps/review-metadata.html |
NEW — metadata diff View |
src/main/mcp-apps/src/*.ts |
NEW — View scripts using App class |
src/main/mcp-apps/vite.config.ts |
NEW — builds Views into single HTML files |
src/main/main.ts |
MODIFY — start/cleanup MCPServer |
tests/engine/ProposalStore.test.ts |
NEW |
tests/engine/MCPServer.test.ts |
NEW |
tests/engine/MCPServer.integration.test.ts |
NEW |
TODO.md |
MODIFY — remove stdio, update transport section |
Verification
npm test— all tests pass (new + existing)npm run build— clean build- Manual: start app, connect Claude Code or curl to
http://127.0.0.1:4124/mcpwith MCP protocol, verify tool listing and resource reading - Manual: call
draft_posttool, 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.