调研(docs/mcp-research.md):对照 2025-11-25 spec 与 rmcp 源码,确认 Streamable HTTP 是 2026 唯一远程传输、stateless 为默认、bearer token 为 单租户务实方案、官方 rmcp 的 Tower StreamableHttpService 可挂载于 axum。 规格(docs/mcp-spec.md):锁定 9 项设计决策(受众/作用域/有效期/检索机制/ 工具面/草稿可见性/加密存储/配置目标/构建路径)、模块划分、迁移、验收标准。 rmcp 锁定 =3.0.0-beta.3(稳定版 0.2.1 缺 Origin 校验等 spec 强制项)。
226 lines
12 KiB
Markdown
226 lines
12 KiB
Markdown
# MCP Server — Specification
|
|
|
|
> Buildable spec for a Model Context Protocol server embedded in Yggdrasil.
|
|
> Derives from the grilling decisions (2026-07-28) and `docs/mcp-research.md`.
|
|
> Spec version: 1.0.
|
|
|
|
## 1. Overview
|
|
|
|
Expose the blog as an **MCP server** so the admin's own AI clients (Claude Code,
|
|
Cursor, Cline/Continue) can (a) query published posts as a **knowledge base** and
|
|
(b) perform nearly all backend operations (write posts, moderate comments, manage
|
|
tags/settings, upload media, run code). An admin settings page generates per-token
|
|
MCP client configs with a user-selectable token lifetime and three permission scopes.
|
|
|
|
**Single-tenant model.** Tokens are minted by the admin for the admin's own clients.
|
|
No OAuth 2.1 / PKCE / dynamic client registration. The bearer-token header format is
|
|
identical to an OAuth bearer, so the path is forward-compatible if OAuth is ever added.
|
|
|
|
## 2. Decisions (locked during grilling)
|
|
|
|
| # | Decision | Choice |
|
|
|---|---|---|
|
|
| 1 | Audience | Admin's own AI clients only |
|
|
| 2 | Scope model | `read` / `write` / `admin` (3 scopes) |
|
|
| 3 | Token lifetime | Preset menu: 1d / 7d / 30d / 90d / never-expire (opt-in) |
|
|
| 4 | Knowledge-base retrieval | Full-text search MVP (reuse Postgres FTS); published posts as MCP Resources + `search_posts` tool; architecture leaves a seam for future pgvector |
|
|
| 5 | Tool surface | Post CRUD, post query, comment mgmt, tag mgmt, site settings, media upload, code runner. **Excludes** DB export/backup |
|
|
| 6 | Scope mapping | media=write; code runner=admin-only; read=published only; write/admin=includes drafts |
|
|
| 7 | Draft visibility | `read` → published only; `write`/`admin` → incl. drafts |
|
|
| 8 | Token storage | AES-GCM encrypted at rest (env key `MCP_TOKEN_ENC_KEY`); retrievable by admin |
|
|
| 9 | Config targets | Claude Code, Cursor, Cline/Continue, generic raw JSON |
|
|
|
|
## 3. Goals / Non-goals
|
|
|
|
**Goals**
|
|
- One `/mcp` Streamable HTTP endpoint, stateless, spec `2025-11-25`.
|
|
- Bearer-token auth with 3 scopes, encrypted-at-rest storage, selectable lifetime.
|
|
- Knowledge base: published posts as paginated Resources + FTS `search_posts` tool.
|
|
- Write/admin tools covering the locked surface.
|
|
- Admin UI: token CRUD + one-click config generation for 4 client formats.
|
|
|
|
**Non-goals (MVP)**
|
|
- OAuth 2.1 / PKCE / DCR (forward-compatible seam only).
|
|
- Semantic/vector retrieval (pgvector) — full-text first; interface is vector-ready.
|
|
- Prompts / Completions / Elicitation / Sampling / Roots / Tasks / Logging primitives.
|
|
- Multi-tenant / public token issuance.
|
|
- Database export/backup over MCP.
|
|
|
|
## 4. Architecture
|
|
|
|
### 4.1 Transport & mount
|
|
- **Streamable HTTP, stateless** (SEP-2567): no `Mcp-Session-Id`, no GET stream.
|
|
Each POST is a self-contained request; the bearer token authenticates it.
|
|
- Mount the official **`rmcp`** crate's Tower `StreamableHttpService` at `/mcp` via
|
|
`axum::Router::nest_service("/mcp", service)`, merged into the app router in
|
|
`src/main.rs` alongside the existing `upload_route`/`export_route`/`sse_route`.
|
|
- Negotiate protocol `2025-11-25`; respond `application/json` (no SSE stream needed
|
|
for non-streaming tools).
|
|
|
|
### 4.2 Authentication
|
|
- `Authorization: Bearer ygg_<opaque>` on every request.
|
|
- A new middleware/extractor resolves the bearer → `(user_id, scope)`:
|
|
decrypt-stored-token lookup by token id, enforce expiry, update `last_used_at`.
|
|
- **`Origin` → 403 is a hard MUST** (spec `2025-11-25`): validate `Origin` against the
|
|
same trusted-origin allowlist as CSRF (`APP_BASE_URL` / Host fallback).
|
|
- `MCP-Protocol-Version` header honored; reject unsupported versions with 400.
|
|
- Sessions MUST NOT be used for auth (spec); the token authenticates every request.
|
|
|
|
### 4.3 Rate limiting
|
|
- New **token-keyed** governor bucket on `/mcp` only (extract bearer → `user_id`).
|
|
The existing IP-keyed governor stays on the web app and is NOT reused for MCP.
|
|
- Per-request body-size cap; rely on the global `statement_timeout` for DB bounds.
|
|
|
|
### 4.4 Scope enforcement
|
|
- Each tool declares its required scope. Dispatch checks `token.scope >= required`;
|
|
mismatch → `403 insufficient_scope`-style MCP error.
|
|
- Read tools (search, get/list published) = `read`. Mutating post/comment/tag/media =
|
|
`write`. Settings + code runner = `admin`.
|
|
- Search/snippet output is an **indirect-prompt-injection surface**: sanitize, keep
|
|
read vs write on separate scopes, lean on the client's human-in-the-loop.
|
|
|
|
### 4.5 Feature gating
|
|
- All MCP code is **server-only**: `#[cfg(feature = "server")]` impl + compiling
|
|
`#[cfg(not(feature = "server"))]` stubs for the WASM build, per AGENTS.md §1.
|
|
- The admin UI page renders on both targets; it calls token-management server fns.
|
|
|
|
## 5. Data model
|
|
|
|
### 5.1 Migration `015_mcp_tokens.sql` (+ register in `MIGRATIONS`)
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS mcp_tokens (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
scope TEXT NOT NULL CHECK (scope IN ('read','write','admin')),
|
|
-- AES-GCM ciphertext of the full bearer token (nonce || ct || tag), hex-encoded.
|
|
token_enc TEXT NOT NULL,
|
|
-- SHA-256 of the token for fast constant-time lookup by bearer on each request.
|
|
token_hash CHAR(64) NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
expires_at TIMESTAMPTZ, -- NULL = never-expire (opt-in)
|
|
last_used_at TIMESTAMPTZ,
|
|
revoked_at TIMESTAMPTZ
|
|
);
|
|
CREATE UNIQUE INDEX mcp_tokens_token_hash_active_idx
|
|
ON mcp_tokens (token_hash) WHERE revoked_at IS NULL;
|
|
CREATE INDEX mcp_tokens_user_idx ON mcp_tokens (user_id);
|
|
```
|
|
|
|
### 5.2 Model (`src/models/mcp_token.rs`)
|
|
`McpToken` (DB row), `McpTokenSummary` (list response — no secret), `CreateTokenResponse`
|
|
(returns plaintext once), plus a `TokenScope` enum (`Read`/`Write`/`Admin`) with
|
|
`>=` ordering and serde.
|
|
|
|
## 6. Module breakdown
|
|
|
|
New top-level module `src/mcp/` (server-only impl, stubs for WASM):
|
|
|
|
| Path | Responsibility |
|
|
|---|---|
|
|
| `src/mcp/mod.rs` | Module root, re-exports, `cfg` gate |
|
|
| `src/mcp/crypto.rs` | AES-GCM encrypt/decrypt of tokens; reads `MCP_TOKEN_ENC_KEY` |
|
|
| `src/mcp/auth.rs` | Bearer extractor → `(user_id, scope)`; Origin check; `last_used_at` update |
|
|
| `src/mcp/rate_limit.rs` | Token-keyed governor for `/mcp` |
|
|
| `src/mcp/server.rs` | rmcp `ServerHandler` impl: `tools/list`, `resources/list`, `resources/read`, dispatch |
|
|
| `src/mcp/tools/` | Tool impls: `read.rs`, `posts.rs`, `comments.rs`, `tags.rs`, `settings.rs`, `media.rs`, `runner.rs` |
|
|
| `src/mcp/resources.rs` | Published-post Resources + templates (`post://{slug}`) |
|
|
| `src/mcp/router.rs` | Builds the `StreamableHttpService`, mounts at `/mcp` (called from `main.rs`) |
|
|
| `src/mcp/config.rs` | Generates the 4 client config formats from a token |
|
|
|
|
Token management lives in the existing API tree:
|
|
| Path | Responsibility |
|
|
|---|---|
|
|
| `src/api/mcp_tokens.rs` | `#[server]` fns: `create_mcp_token`, `list_mcp_tokens`, `revoke_mcp_token`, `reveal_mcp_token` (admin-guarded) |
|
|
| `src/models/mcp_token.rs` | shared DTOs |
|
|
|
|
Admin UI:
|
|
| Path | Responsibility |
|
|
|---|---|
|
|
| `src/pages/admin/mcp.rs` | Token CRUD + config generator page |
|
|
| nav entry in the admin layout | link to `/admin/mcp` |
|
|
|
|
### 6.1 Tool catalogue
|
|
|
|
**Resources** (read scope, published posts only)
|
|
- `resources/list` — paginated published posts (opaque cursor; page size server-set).
|
|
- `resources/templates/list` — `post://{slug}`.
|
|
- `resources/read` — rendered Markdown of one post.
|
|
|
|
**Tools — `read`**
|
|
- `search_posts(query, limit?, cursor?)` — FTS via existing search helper; returns
|
|
summaries + `resource_link`s to matching posts.
|
|
- `get_post(slug|id)` — published only.
|
|
- `list_tags()`.
|
|
|
|
**Tools — `write`** (also readable incl. drafts)
|
|
- `create_post`, `update_post`, `publish_post`, `trash_post`, `delete_post`.
|
|
- `list_comments`, `approve_comment`, `delete_comment`, `set_comment_status`.
|
|
- `create_tag`, `rename_tag`.
|
|
- `upload_media` (base64 → existing upload pipeline).
|
|
|
|
**Tools — `admin`**
|
|
- `get_settings`, `update_settings`.
|
|
- `run_code` (delegates to the existing code-runner execute path; admin-only).
|
|
|
|
## 7. New dependencies & env
|
|
|
|
**Cargo.toml** (added under `[features] server`) — **pinned to the 3.x beta line,
|
|
verified 2026-07-28 by a throwaway axum-0.8 probe.** The stable `rmcp 0.2.1` is far
|
|
older and LACKS `with_json_response`, `with_allowed_origins` (Origin→403),
|
|
`with_max_request_body_bytes`, protocol-version validation, and stateless-by-default —
|
|
all of which the 3.x beta ships and which this spec relies on. `transport-worker` must
|
|
be added explicitly (`LocalSessionManager` unconditionally uses `transport::worker`).
|
|
- `rmcp = { version = "=3.0.0-beta.3", optional = true, features = ["server", "macros", "transport-streamable-http-server", "transport-worker"] }`
|
|
- `aes-gcm = { version = "0.10", optional = true }` (token encryption).
|
|
|
|
**Verified integration shape** (probe at `/tmp/rmcp-probe`):
|
|
- Mount: `Router::new().nest_service("/mcp", StreamableHttpService::new(factory, LocalSessionManager::default().into(), config))`.
|
|
- Config: `StreamableHttpServerConfig::default().with_legacy_session_mode(false).with_json_response(true).with_allowed_origins([APP_BASE_URL])`.
|
|
- Auth: axum `from_fn` middleware resolves bearer → inserts `McpPrincipal{user_id,scope}` into `request.extensions_mut()`.
|
|
- Tool reads it via `Extension<http::request::Parts>` extractor → `parts.extensions.get::<McpPrincipal>()`.
|
|
- Tool result: `CallToolResult::success(vec![ContentBlock::Text(TextContent::new(s))])`.
|
|
- rmcp handles Origin→403, protocol-version 400, body-size 413 internally.
|
|
|
|
**Env vars** (document in `.env.example`):
|
|
- `MCP_TOKEN_ENC_KEY` — 32-byte AES-256 key, base64. Required when MCP is used; the
|
|
server warns at startup if absent (tokens cannot be minted without it).
|
|
|
|
## 8. Acceptance criteria
|
|
|
|
1. `POST /mcp` with a valid `read`-scope bearer returns `tools/list` including
|
|
`search_posts`; a `search_posts` call returns ranked published posts.
|
|
2. `resources/list` paginates published posts; `resources/read` returns one post's
|
|
Markdown; cursor pagination round-trips.
|
|
3. A request with no/invalid/expired/revoked token is rejected (401/403).
|
|
4. A `read` token calling a `write` tool is rejected (`insufficient_scope`).
|
|
5. `write`/`admin` tokens can read drafts; `read` tokens cannot.
|
|
6. `create_post` via MCP writes a row, renders `content_html`, and invalidates the
|
|
matching moka + SSR caches (same write-flow as the web admin).
|
|
7. Invalid `Origin` → 403; missing `MCP-Protocol-Version` → 400.
|
|
8. Admin UI lists tokens (name, scope, created, expires, last-used), reveals plaintext
|
|
on demand, revokes, and emits copy-ready config for all 4 client formats.
|
|
9. Token DB column never stores plaintext; `token_enc` is AES-GCM ciphertext.
|
|
10. `cargo build --no-default-features --features web` compiles (WASM stubs present);
|
|
`cargo build --no-default-features --features server` compiles;
|
|
`cargo clippy --all-features -- -D warnings` passes.
|
|
11. `make test` (existing suite) still passes; new unit tests cover crypto, scope
|
|
ordering, cursor pagination, and config generation (pure, DB-free).
|
|
|
|
## 9. Risks & mitigations
|
|
|
|
- **rmcp/Axum version skew** — rmcp must target axum 0.8. Verify before pinning; if
|
|
incompatible, the fallback is a thin hand-rolled JSON-RPC-over-POST layer (larger
|
|
effort, research says avoid). *Mitigation: spike the mount in the tracer bullet first.*
|
|
- **Indirect prompt injection** — search/snippet output reaches the model. *Mitigation:
|
|
sanitize, separate read/write scopes, human-in-the-loop on mutating tools.*
|
|
- **Token leak = full blog takeover** — *Mitigation: encrypted-at-rest, selectable
|
|
short lifetimes, revocation, token-keyed rate limit, Origin enforcement.*
|
|
- **WASM build breakage** — *Mitigation: every server-only symbol gets a stub; the
|
|
tracer bullet must prove `--features web` compiles before tool flesh-out.*
|
|
|
|
## 10. Build path
|
|
|
|
Spec → tracer-bullet tickets (`/to-tickets`) → per-ticket `/implement` with cleared
|
|
context. Ticket files live under `.scratch/mcp-server/issues/`.
|