feat: mcp server implementation third round
This commit is contained in:
16
MCP_PLAN.md
16
MCP_PLAN.md
@@ -24,7 +24,7 @@ MCPServer (HTTP on 127.0.0.1:4124) ← NEW, standalone
|
|||||||
└── ui:// resources → MCP App review Views (via ext-apps)
|
└── 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.
|
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.
|
`MCPServer` is a new engine class that owns the `McpServer` factory, tool/resource/prompt registration, and the `ProposalStore`. It runs independently of PreviewServer.
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps
|
|||||||
### Step 3: MCPServer engine (`src/main/engine/MCPServer.ts`)
|
### Step 3: MCPServer engine (`src/main/engine/MCPServer.ts`)
|
||||||
- Constructor: inject engine getters (PostEngine, MediaEngine, ScriptEngine, TemplateEngine, MetaEngine, PostMediaEngine, TagEngine)
|
- 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)
|
- `createServer()` → factory that instantiates a fresh `McpServer` and registers all tools/resources/prompts (stateless mode — one per request)
|
||||||
- `start(port)` → uses `createMcpExpressApp` + `StreamableHTTPServerTransport` in stateless mode, listens on `127.0.0.1:port`
|
- `start(port)` → uses `http.createServer` + `StreamableHTTPServerTransport` in stateless mode, listens on `127.0.0.1:port`, validates Origin header
|
||||||
- `stop()` → close HTTP server
|
- `stop()` → close HTTP server
|
||||||
- `cleanup()` → discard proposals, stop intervals, stop server
|
- `cleanup()` → discard proposals, stop intervals, stop server
|
||||||
- Singleton pattern with `getMCPServer()` getter
|
- Singleton pattern with `getMCPServer()` getter
|
||||||
@@ -65,6 +65,7 @@ Register MCP resources mapping to existing engine methods:
|
|||||||
| `bds://posts/{id}/outlinks` | PostEngine.getLinksTo(id) |
|
| `bds://posts/{id}/outlinks` | PostEngine.getLinksTo(id) |
|
||||||
| `bds://posts/{id}/media` | PostMediaEngine.getLinkedMediaDataForPost(id) |
|
| `bds://posts/{id}/media` | PostMediaEngine.getLinkedMediaDataForPost(id) |
|
||||||
| `bds://media/{id}/posts` | PostMediaEngine.getLinkedPostsForMedia(id) |
|
| `bds://media/{id}/posts` | PostMediaEngine.getLinkedPostsForMedia(id) |
|
||||||
|
| `bds://media/{id}/image` | MediaEngine.getThumbnailDataUrl(id, 'medium') |
|
||||||
|
|
||||||
### Step 5: Read tools
|
### Step 5: Read tools
|
||||||
- `search_posts` — annotations: `{ readOnlyHint: true, openWorldHint: false }`
|
- `search_posts` — annotations: `{ readOnlyHint: true, openWorldHint: false }`
|
||||||
@@ -88,6 +89,7 @@ Resource registration uses `registerAppResource` from `@modelcontextprotocol/ext
|
|||||||
### Step 7: Accept/discard tools
|
### Step 7: Accept/discard tools
|
||||||
- `accept_proposal({ proposalId })` — dispatch by type, commit change
|
- `accept_proposal({ proposalId })` — dispatch by type, commit change
|
||||||
- `discard_proposal({ proposalId })` — dispatch by type, clean up
|
- `discard_proposal({ proposalId })` — dispatch by type, clean up
|
||||||
|
- Registered via `registerAppTool` with `visibility: ["app"]` (app-only, hidden from agent LLM)
|
||||||
- Annotations: idempotentHint: true
|
- Annotations: idempotentHint: true
|
||||||
|
|
||||||
### Step 8: MCP Prompts
|
### Step 8: MCP Prompts
|
||||||
@@ -109,7 +111,7 @@ Each View:
|
|||||||
3. On accept/discard: calls `app.callServerTool({ name: 'accept_proposal' | 'discard_proposal', arguments: { proposalId } })`
|
3. On accept/discard: calls `app.callServerTool({ name: 'accept_proposal' | 'discard_proposal', arguments: { proposalId } })`
|
||||||
4. Updates UI to show outcome
|
4. Updates UI to show outcome
|
||||||
|
|
||||||
Build step: `vite build` with `vite-plugin-singlefile` bundles each into a self-contained HTML string. These are read at runtime by `registerAppResource` handlers.
|
Build step: Review Views are inline HTML template strings in `mcp-views.ts` — no separate build step needed.
|
||||||
|
|
||||||
### Step 10: Lifecycle in main.ts
|
### Step 10: Lifecycle in main.ts
|
||||||
- MCPServer starts independently alongside PreviewServer during app init
|
- MCPServer starts independently alongside PreviewServer during app init
|
||||||
@@ -136,12 +138,8 @@ Each step above is preceded by failing tests:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `src/main/engine/ProposalStore.ts` | NEW — in-memory proposal storage |
|
| `src/main/engine/ProposalStore.ts` | NEW — in-memory proposal storage |
|
||||||
| `src/main/engine/MCPServer.ts` | NEW — standalone MCP server engine |
|
| `src/main/engine/MCPServer.ts` | NEW — standalone MCP server engine |
|
||||||
| `src/main/mcp-apps/review-post.html` | NEW — post review View |
|
| `src/main/engine/mcp-views.ts` | NEW — inline review View HTML templates |
|
||||||
| `src/main/mcp-apps/review-script.html` | NEW — script review View |
|
| `tests/engine/mcp-views.test.ts` | NEW |
|
||||||
| `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 |
|
| `src/main/main.ts` | MODIFY — start/cleanup MCPServer |
|
||||||
| `tests/engine/ProposalStore.test.ts` | NEW |
|
| `tests/engine/ProposalStore.test.ts` | NEW |
|
||||||
| `tests/engine/MCPServer.test.ts` | NEW |
|
| `tests/engine/MCPServer.test.ts` | NEW |
|
||||||
|
|||||||
68
TODO.md
68
TODO.md
@@ -284,14 +284,18 @@ anything enters the system.
|
|||||||
- **`@modelcontextprotocol/sdk` v1.27.1 and `@modelcontextprotocol/ext-apps`
|
- **`@modelcontextprotocol/sdk` v1.27.1 and `@modelcontextprotocol/ext-apps`
|
||||||
v1.1.2 are installed.**
|
v1.1.2 are installed.**
|
||||||
- **`ProposalStore` engine is implemented and tested (18 tests).**
|
- **`ProposalStore` engine is implemented and tested (18 tests).**
|
||||||
- **`MCPServer` engine is implemented and tested (37 tests).** Standalone HTTP
|
- **`MCPServer` engine is implemented and tested (70 tests).** Standalone HTTP
|
||||||
server on port 4124, stateless mode (new `McpServer` per request), registers
|
server on port 4124, stateless mode (new `McpServer` per request), registers
|
||||||
5 static resources, 6 resource templates, 8 tools, 3 prompts, and 4
|
5 static resources, 7 resource templates, 8 tools (6 model-facing +
|
||||||
`ui://` review-app resources.
|
2 app-only), 3 prompts, and 4 `ui://` review-app resources.
|
||||||
- **`mcp-views.ts` provides review HTML** for posts, scripts, templates, and
|
- **`mcp-views.ts` provides review HTML** for posts, scripts, templates, and
|
||||||
metadata diffs via `@modelcontextprotocol/ext-apps` App class.
|
metadata diffs via `@modelcontextprotocol/ext-apps` App class.
|
||||||
- **Lifecycle integrated in `main.ts`** — MCP server starts on app ready and
|
- **Lifecycle integrated in `main.ts`** — MCP server starts on app ready and
|
||||||
cleans up on before-quit.
|
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
|
### Design Principles
|
||||||
|
|
||||||
@@ -352,10 +356,9 @@ anything enters the system.
|
|||||||
accordingly (structured preview data for Apps-capable hosts, formatted
|
accordingly (structured preview data for Apps-capable hosts, formatted
|
||||||
text for others).
|
text for others).
|
||||||
|
|
||||||
7. **Input validation and rate limiting** — all tool inputs are validated
|
7. **Input validation** — all tool inputs are validated at the MCP
|
||||||
at the MCP boundary before forwarding to engine methods. The server
|
boundary (via Zod schemas in `inputSchema`) before forwarding to
|
||||||
rate-limits tool invocations to prevent abuse. Do not rely solely on
|
engine methods. Do not rely solely on downstream engine validation.
|
||||||
downstream engine validation.
|
|
||||||
|
|
||||||
### Implementation Plan
|
### Implementation Plan
|
||||||
|
|
||||||
@@ -413,9 +416,12 @@ actions. Each resource is registered via `resources/list` and read via
|
|||||||
| `bds://media/{id}/image` | OpenCodeManager.view_image | Image binary (for visual context) |
|
| `bds://media/{id}/image` | OpenCodeManager.view_image | Image binary (for visual context) |
|
||||||
|
|
||||||
Use `bds://` as the custom URI scheme. Parameterized URIs use MCP resource
|
Use `bds://` as the custom URI scheme. Parameterized URIs use MCP resource
|
||||||
templates (`resources/templates/list`). Emit `notifications/resources/
|
templates (`resources/templates/list`).
|
||||||
list_changed` when posts, media, or tags are created/updated/deleted so
|
|
||||||
the host can refresh cached data.
|
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
|
List resources (`bds://posts`, `bds://media`) support cursor-based
|
||||||
pagination following the MCP pagination spec. The initial response
|
pagination following the MCP pagination spec. The initial response
|
||||||
@@ -565,9 +571,10 @@ Stages metadata changes for an existing post (title, excerpt, slug, tags).
|
|||||||
#### 3.6 App-Internal Tools (Accept / Discard)
|
#### 3.6 App-Internal Tools (Accept / Discard)
|
||||||
|
|
||||||
These tools are called by the MCP App (via the App Bridge's `tools/call`
|
These tools are called by the MCP App (via the App Bridge's `tools/call`
|
||||||
mechanism), **not** by the agent LLM. The host forwards the call from the
|
mechanism), **not** by the agent LLM. They are registered with
|
||||||
sandboxed iframe to the MCP server. They are not listed in `tools/list`
|
`registerAppTool` from `@modelcontextprotocol/ext-apps` using
|
||||||
responses to agents.
|
`visibility: ["app"]`, which signals to compliant hosts that these tools
|
||||||
|
should not be shown to or invoked by the model.
|
||||||
|
|
||||||
##### `accept_proposal`
|
##### `accept_proposal`
|
||||||
|
|
||||||
@@ -656,32 +663,19 @@ tools know to call PostEngine rather than look in the store.
|
|||||||
|
|
||||||
#### 3.9 Transport
|
#### 3.9 Transport
|
||||||
|
|
||||||
Support two transports:
|
**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).
|
||||||
|
|
||||||
- **stdio** — for local integration (agent runs `bds --mcp` or connects via
|
**Security:**
|
||||||
named pipe). This is the standard for MCP in coding agents. Credentials
|
|
||||||
come from the environment.
|
|
||||||
- **Streamable HTTP** — for network access, running alongside PreviewServer
|
|
||||||
on a different port (e.g., 5174). Uses the current MCP Streamable HTTP
|
|
||||||
transport: a single HTTP endpoint that accepts JSON-RPC POST requests and
|
|
||||||
responds with either `application/json` (single response) or
|
|
||||||
`text/event-stream` (SSE stream for multiple messages). Supports session
|
|
||||||
management via `Mcp-Session-Id` headers and requires
|
|
||||||
`MCP-Protocol-Version` headers after initialization.
|
|
||||||
|
|
||||||
Start with stdio since that is what Claude Code and Cursor use.
|
- Bind to `127.0.0.1` (localhost only).
|
||||||
|
- Validate the `Origin` header on all requests — reject non-localhost
|
||||||
**Security requirements for Streamable HTTP:**
|
origins to prevent DNS rebinding attacks.
|
||||||
|
- Requests without an `Origin` header are allowed (CLI tools like curl
|
||||||
- Bind to `127.0.0.1` (localhost only) when running locally.
|
and local MCP clients typically do not send one).
|
||||||
- Validate the `Origin` header on all requests to prevent DNS rebinding
|
|
||||||
attacks.
|
|
||||||
- Use cryptographically random, non-deterministic session IDs.
|
|
||||||
- Implement a session token or shared secret for authentication (generated
|
|
||||||
on server start, displayed in the settings UI for the user to configure
|
|
||||||
in their agent).
|
|
||||||
- Rate-limit incoming requests.
|
|
||||||
- Set appropriate timeouts for tool invocations.
|
|
||||||
|
|
||||||
#### 3.10 Lifecycle Integration
|
#### 3.10 Lifecycle Integration
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ import {
|
|||||||
|
|
||||||
// ── Dependency contracts ──────────────────────────────────────────────
|
// ── Dependency contracts ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PostFilter {
|
||||||
|
status?: 'draft' | 'published' | 'archived';
|
||||||
|
tags?: string[];
|
||||||
|
categories?: string[];
|
||||||
|
year?: number;
|
||||||
|
month?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface PostEngineContract {
|
interface PostEngineContract {
|
||||||
getAllPosts: (options?: { limit?: number; offset?: number }) => Promise<{ items: Array<Record<string, unknown>>; hasMore: boolean; total: number }>;
|
getAllPosts: (options?: { limit?: number; offset?: number }) => Promise<{ items: Array<Record<string, unknown>>; hasMore: boolean; total: number }>;
|
||||||
getPost: (id: string) => Promise<Record<string, unknown> | null>;
|
getPost: (id: string) => Promise<Record<string, unknown> | null>;
|
||||||
@@ -30,7 +38,7 @@ interface PostEngineContract {
|
|||||||
getBlogStats: () => Promise<Record<string, unknown>>;
|
getBlogStats: () => Promise<Record<string, unknown>>;
|
||||||
getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
||||||
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
||||||
getPostsFiltered: (filter: Record<string, unknown>) => Promise<Array<Record<string, unknown>>>;
|
getPostsFiltered: (filter: PostFilter) => Promise<Array<Record<string, unknown>>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaEngineContract {
|
interface MediaEngineContract {
|
||||||
@@ -109,8 +117,26 @@ export class MCPServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const app = createHttpServer(async (req, res) => {
|
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
|
// CORS headers
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
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-Methods', 'GET, POST, DELETE, OPTIONS');
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id');
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id');
|
||||||
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
|
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
|
||||||
@@ -347,15 +373,18 @@ export class MCPServer {
|
|||||||
annotations: { readOnlyHint: true, openWorldHint: false },
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
||||||
}, async (args) => {
|
}, async (args) => {
|
||||||
const hasFilters = args.category || args.tags || args.year || args.month || args.status;
|
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) {
|
if (args.query && !hasFilters) {
|
||||||
// Pure text search — use FTS
|
// Pure text search — use FTS
|
||||||
const results = await this.deps.getPostEngine().searchPosts(args.query);
|
const results = await this.deps.getPostEngine().searchPosts(args.query);
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] };
|
const paginated = results.slice(offset, offset + limit);
|
||||||
|
return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter-based query (optionally narrowed by text search)
|
// Filter-based query (optionally narrowed by text search)
|
||||||
const filter: Record<string, unknown> = {};
|
const filter: PostFilter = {};
|
||||||
if (args.category) filter.categories = [args.category];
|
if (args.category) filter.categories = [args.category];
|
||||||
if (args.tags) filter.tags = args.tags;
|
if (args.tags) filter.tags = args.tags;
|
||||||
if (args.year) filter.year = args.year;
|
if (args.year) filter.year = args.year;
|
||||||
@@ -374,7 +403,8 @@ export class MCPServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] };
|
const paginated = results.slice(offset, offset + limit);
|
||||||
|
return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,26 +566,28 @@ export class MCPServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private registerAcceptDiscardTools(server: McpServer): void {
|
private registerAcceptDiscardTools(server: McpServer): void {
|
||||||
server.registerTool('accept_proposal', {
|
registerAppTool(server, 'accept_proposal', {
|
||||||
title: 'Accept Proposal',
|
title: 'Accept Proposal',
|
||||||
description: 'Accept a pending proposal, committing the proposed change.',
|
description: 'Accept a pending proposal, committing the proposed change.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
proposalId: z.string().describe('ID of the proposal to accept'),
|
proposalId: z.string().describe('ID of the proposal to accept'),
|
||||||
},
|
},
|
||||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
||||||
}, async (args) => {
|
_meta: { ui: { visibility: ['app'] } },
|
||||||
|
}, async (args: { proposalId: string }) => {
|
||||||
const result = await this.acceptProposal(args.proposalId);
|
const result = await this.acceptProposal(args.proposalId);
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
|
||||||
});
|
});
|
||||||
|
|
||||||
server.registerTool('discard_proposal', {
|
registerAppTool(server, 'discard_proposal', {
|
||||||
title: 'Discard Proposal',
|
title: 'Discard Proposal',
|
||||||
description: 'Discard a pending proposal, rolling back any draft changes.',
|
description: 'Discard a pending proposal, rolling back any draft changes.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
proposalId: z.string().describe('ID of the proposal to discard'),
|
proposalId: z.string().describe('ID of the proposal to discard'),
|
||||||
},
|
},
|
||||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
||||||
}, async (args) => {
|
_meta: { ui: { visibility: ['app'] } },
|
||||||
|
}, async (args: { proposalId: string }) => {
|
||||||
const result = await this.discardProposal(args.proposalId);
|
const result = await this.discardProposal(args.proposalId);
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -109,10 +109,53 @@ describe('MCPServer integration', () => {
|
|||||||
method: 'OPTIONS',
|
method: 'OPTIONS',
|
||||||
});
|
});
|
||||||
expect(response.status).toBe(204);
|
expect(response.status).toBe(204);
|
||||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
|
expect(response.headers.get('Access-Control-Allow-Origin')).toBeTruthy();
|
||||||
expect(response.headers.get('Access-Control-Allow-Methods')).toContain('POST');
|
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 () => {
|
it('lists tools via tools/list after initialize', async () => {
|
||||||
const port = await server.start(0);
|
const port = await server.start(0);
|
||||||
|
|
||||||
|
|||||||
@@ -446,6 +446,38 @@ describe('MCPServer', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Tool visibility ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('tool visibility', () => {
|
||||||
|
function getToolMeta(toolName: string): Record<string, unknown> | undefined {
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const tool = (mcpServer as Record<string, Record<string, { _meta?: Record<string, unknown> }>>)._registeredTools[toolName];
|
||||||
|
return tool?._meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accept_proposal has app-only visibility', () => {
|
||||||
|
const meta = getToolMeta('accept_proposal');
|
||||||
|
expect(meta).toBeDefined();
|
||||||
|
const ui = (meta as Record<string, unknown>).ui as Record<string, unknown>;
|
||||||
|
expect(ui?.visibility).toEqual(['app']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discard_proposal has app-only visibility', () => {
|
||||||
|
const meta = getToolMeta('discard_proposal');
|
||||||
|
expect(meta).toBeDefined();
|
||||||
|
const ui = (meta as Record<string, unknown>).ui as Record<string, unknown>;
|
||||||
|
expect(ui?.visibility).toEqual(['app']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draft_post has model+app visibility (default)', () => {
|
||||||
|
const meta = getToolMeta('draft_post');
|
||||||
|
expect(meta).toBeDefined();
|
||||||
|
const ui = (meta as Record<string, unknown>).ui as Record<string, unknown>;
|
||||||
|
// no explicit visibility = default ["model", "app"]
|
||||||
|
expect(ui?.visibility).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Resource handler behavior ───────────────────────────────────────
|
// ── Resource handler behavior ───────────────────────────────────────
|
||||||
|
|
||||||
describe('resource handlers', () => {
|
describe('resource handlers', () => {
|
||||||
@@ -543,6 +575,28 @@ describe('MCPServer', () => {
|
|||||||
expect(JSON.parse(result.content[0].text)).toEqual(searchResults);
|
expect(JSON.parse(result.content[0].text)).toEqual(searchResults);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('search_posts with query applies offset and limit', async () => {
|
||||||
|
const searchResults = Array.from({ length: 10 }, (_, i) => ({ id: `p${i}`, title: `Post ${i}`, slug: `post-${i}` }));
|
||||||
|
mockPostEngine.searchPosts.mockResolvedValue(searchResults);
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const tool = getTool(mcpServer, 'search_posts');
|
||||||
|
const result = await tool.handler({ query: 'test', offset: 2, limit: 3 }, {}) as { content: Array<{ text: string }> };
|
||||||
|
const parsed = JSON.parse(result.content[0].text);
|
||||||
|
expect(parsed).toHaveLength(3);
|
||||||
|
expect(parsed[0].id).toBe('p2');
|
||||||
|
expect(parsed[2].id).toBe('p4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('search_posts defaults to limit 50 when not specified', async () => {
|
||||||
|
const searchResults = Array.from({ length: 60 }, (_, i) => ({ id: `p${i}`, title: `Post ${i}`, slug: `post-${i}` }));
|
||||||
|
mockPostEngine.searchPosts.mockResolvedValue(searchResults);
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const tool = getTool(mcpServer, 'search_posts');
|
||||||
|
const result = await tool.handler({ query: 'test' }, {}) as { content: Array<{ text: string }> };
|
||||||
|
const parsed = JSON.parse(result.content[0].text);
|
||||||
|
expect(parsed).toHaveLength(50);
|
||||||
|
});
|
||||||
|
|
||||||
it('search_posts with filters only calls getPostsFiltered', async () => {
|
it('search_posts with filters only calls getPostsFiltered', async () => {
|
||||||
const filtered = [{ id: 'p2', title: 'Filtered' }];
|
const filtered = [{ id: 'p2', title: 'Filtered' }];
|
||||||
mockPostEngine.getPostsFiltered.mockResolvedValue(filtered);
|
mockPostEngine.getPostsFiltered.mockResolvedValue(filtered);
|
||||||
@@ -553,6 +607,17 @@ describe('MCPServer', () => {
|
|||||||
expect(JSON.parse(result.content[0].text)).toEqual(filtered);
|
expect(JSON.parse(result.content[0].text)).toEqual(filtered);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('search_posts with filters applies offset and limit', async () => {
|
||||||
|
const filtered = Array.from({ length: 10 }, (_, i) => ({ id: `p${i}`, title: `Post ${i}` }));
|
||||||
|
mockPostEngine.getPostsFiltered.mockResolvedValue(filtered);
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const tool = getTool(mcpServer, 'search_posts');
|
||||||
|
const result = await tool.handler({ category: 'tech', offset: 3, limit: 2 }, {}) as { content: Array<{ text: string }> };
|
||||||
|
const parsed = JSON.parse(result.content[0].text);
|
||||||
|
expect(parsed).toHaveLength(2);
|
||||||
|
expect(parsed[0].id).toBe('p3');
|
||||||
|
});
|
||||||
|
|
||||||
it('search_posts with query + filters uses getPostsFiltered and client-side text filter', async () => {
|
it('search_posts with query + filters uses getPostsFiltered and client-side text filter', async () => {
|
||||||
const allFiltered = [
|
const allFiltered = [
|
||||||
{ id: 'p1', title: 'TypeScript Guide', content: 'Learn TS', excerpt: '' },
|
{ id: 'p1', title: 'TypeScript Guide', content: 'Learn TS', excerpt: '' },
|
||||||
|
|||||||
Reference in New Issue
Block a user