docs(mcp): 新增 MCP 服务器调研与规格

调研(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 强制项)。
This commit is contained in:
xfy 2026-07-28 11:09:45 +08:00
parent f6e1dff600
commit 801cb79e0d
2 changed files with 876 additions and 0 deletions

651
docs/mcp-research.md Normal file
View File

@ -0,0 +1,651 @@
# MCP Server Research — 2026 Best Practices for Embedding in Yggdrasil (Rust/Axum)
> Researched 2026-07-28 against primary sources only: the MCP specification at
> `modelcontextprotocol.io`, the `modelcontextprotocol` GitHub org (spec, rust-sdk),
> the Rust SDK (`rmcp`) source/crate metadata, RFCs, and client vendor docs.
> Every non-trivial claim carries a source link. Project code was not modified and
> no project build/test commands were run.
## TL;DR (read this first)
- **The current stable spec is `2025-11-25`** — NOT `2025-06-18` as our brief assumed.
A development draft **`2026-07-28`** already exists and the Rust SDK tracks it.
Source: [versioning page](https://modelcontextprotocol.io/docs/learn/versioning).
- **Streamable HTTP is the de-facto and only spec-blessed remote transport.** The old
HTTP+SSE transport was removed after `2024-11-05`. Hypothesis CONFIRMED.
Source: [transports spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports).
- **Stateless mode is now the SDK default** (draft `2026-07-28`, SEP-2567): no
`Mcp-Session-Id`, no GET/DELETE stream. This fits a request-scoped Axum handler
perfectly and removes the hardest part of the protocol (session lifecycle).
Source: [rmcp README — Stateless Streamable HTTP](https://github.com/modelcontextprotocol/rust-sdk).
- **Authorization is OPTIONAL in the spec.** OAuth 2.1 is the heavy, spec-blessed path;
for a **single-tenant blog where the admin mints tokens for their own clients**, a
static bearer token in the `Authorization: Bearer` header is the pragmatic, widely
supported choice. Claude Code, Cursor, and Cline all accept a custom `Authorization`
header verbatim. Hypothesis CONFIRMED, with caveats (Origin validation is **MUST**).
Source: [authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
- **Use the official `rmcp` crate** (`v2.2.0` stable, `3.0.0-beta.2` dev, 3.4M
downloads/month, 1,765 dependent crates, Apache-2.0). It ships a Tower
`StreamableHttpService` that mounts on Axum with one line:
`axum::Router::new().nest_service("/mcp", service)`. Do NOT hand-roll JSON-RPC.
Source: [rmcp on lib.rs](https://lib.rs/crates/rmcp), [rmcp README](https://github.com/modelcontextprotocol/rust-sdk).
- **Knowledge-base pattern is confirmed:** expose PUBLISHED posts as **Resources**
(URI-addressable, `resources/list` paginated, `resources/read`) AND ship a `search`
**Tool**. Tools are model-invoked; Resources are app/agent-selected. Both are the
documented split. Source: [resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources),
[tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools).
---
## 1. Protocol & Transports
### 1.1 Spec version & date
The **current** protocol version is **`2025-11-25`** (status *Current*). The version
identifier format is `YYYY-MM-DD`, bumped only on backwards-*incompatible* changes.
Source: [versioning page](https://modelcontextprotocol.io/docs/learn/versioning).
A **development draft `2026-07-28`** is already in flight; the `rmcp` README states it
"tracks the MCP `2026-07-28` draft while remaining fully compatible with the stable
`2025-11-25` release and earlier versions." Its headline features: server discovery &
negotiation, transport-neutral subscriptions, long-running tasks, response caching,
multi-round-trip requests, and standard HTTP routing headers.
Source: [rmcp README](https://github.com/modelcontextprotocol/rust-sdk).
### 1.2 Defined transports
The spec defines exactly **two** transports ([transports spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)):
1. **stdio** — client launches the server as a subprocess; messages over stdin/stdout.
"Clients SHOULD support stdio whenever possible." (Local only.)
2. **Streamable HTTP** — server is an independent process; a **single MCP endpoint**
(e.g. `https://example.com/mcp`) handles POST (send JSON-RPC) and optionally GET
(open an SSE stream). The server MAY stream replies as `text/event-stream` or reply
with a single `application/json` body. The client MUST send `Accept: application/json, text/event-stream`.
The **HTTP+SSE transport (2024-11-05) is removed/deprecated.** The spec states
Streamable HTTP "replaces the HTTP+SSE transport from protocol version 2024-11-05" and
provides a backwards-compatibility note for servers that want to keep serving old clients.
Source: [transports — Backwards Compatibility](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#backwards-compatibility).
### 1.3 Recommendation for an internet-facing server in 2026
**Streamable HTTP is the de-facto standard for hosted/remote MCP.** It is the only
HTTP transport the current spec defines, and every major client (Claude Code/Desktop,
Cursor, Cline, Continue) supports it natively. stdio is explicitly local-only.
Source: [transports spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports);
client support: [Claude Code MCP](https://code.claude.com/docs/en/mcp).
### 1.4 What changed across revisions
**`2024-11-05``2025-03-26`:** introduced the HTTP+SSE transport (later replaced).
Authorization framework introduced at the transport layer.
**`2025-03-26``2025-06-18`** ([changelog](https://modelcontextprotocol.io/specification/2025-06-18/changelog)) — major:
1. Removed JSON-RPC **batching**.
2. Added **structured tool output** (`outputSchema`, `structuredContent`).
3. Reclassified MCP servers as **OAuth 2.1 Resource Servers** with Protected Resource
Metadata discovery (RFC 9728).
4. **Required** clients to implement **Resource Indicators (RFC 8707)**.
5. Added the **Security Best Practices** page.
6. Added **Elicitation** (server asks client/user for input).
7. Added **Resource Links** in tool results.
8. **Required** the `MCP-Protocol-Version` header on all subsequent HTTP requests.
9. Changed lifecycle Operation SHOULD → **MUST**.
**`2025-06-18``2025-11-25`** ([changelog](https://modelcontextprotocol.io/specification/2025-11-25/changelog)) — major:
1. Added **OpenID Connect Discovery 1.0** support in AS discovery.
2. **Icons** metadata on tools/resources/prompts.
3. **Incremental scope consent** via `WWW-Authenticate` (SEP-835).
4. Tool-name guidance (SEP-986).
5. Richer **Elicitation** schemas (enums, single/multi-select, defaults) and **URL-mode elicitation**.
6. **Tool calling inside sampling** (`tools`/`toolChoice`).
7. **OAuth Client ID Metadata Documents** (SEP-991) as a recommended client-registration mechanism.
8. **Experimental Tasks** extension (SEP-1686) — durable, polled long-running requests.
9. JSON Schema **2020-12** as the default dialect.
10. Servers MUST respond **403** for invalid `Origin` headers in Streamable HTTP.
**`2025-11-25` → draft `2026-07-28`** (per rmcp README): server discovery/negotiation,
transport-neutral `subscriptions/listen` (replacing `resources/subscribe` + standalone GET),
Tasks extension, response caching (`ttlMs`/`cacheScope`), multi-round-trip requests
(MRTR, SEP-2322), standard HTTP routing headers (`Mcp-Method`/`Mcp-Name`/`Mcp-Param-*`,
SEP-2243), and **stateless Streamable HTTP by default (SEP-2567)**.
### 1.5 Session management — required vs optional vs stateless
Session handling is **optional, not required** ([transports — Session Management](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management)):
- A Streamable HTTP server **MAY** assign an `MCP-Session-Id` in the `InitializeResult`
response. If present, the client MUST echo it on every subsequent request; a server
that requires it SHOULD 400 requests lacking it.
- The session ID MUST be only visible ASCII (`0x21``0x7E`), SHOULD be cryptographically
secure (UUID/JWT/hash).
- **Stateless mode** is explicitly supported: a server simply never returns a session ID
and answers each POST independently. In draft `2026-07-28` (SEP-2567) this becomes the
**default** for the modern protocol — "no `Mcp-Session-Id`, no standalone GET/DELETE
stream, and no `Last-Event-ID` resumption." ([rmcp README](https://github.com/modelcontextprotocol/rust-sdk)).
The `MCP-Protocol-Version` header (e.g. `MCP-Protocol-Version: 2025-11-25`) is **MUST**
on every post-initialize HTTP request; for backwards compat a server without other info
SHOULD assume `2025-03-26`, and MUST 400 on an unsupported version.
Source: [transports — Protocol Version Header](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header).
> **Implication for Yggdrasil:** run **stateless**. No session store, no `Mcp-Session-Id`.
> Each POST is a self-contained Axum handler. This collapses the protocol to "JSON-RPC
> over POST, optionally streaming the reply."
---
## 2. Auth & Security
### 2.1 What the spec mandates for remote (HTTP) servers
Authorization is **OPTIONAL** ([authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)):
> "Authorization is OPTIONAL for MCP implementations. When supported: Implementations
> using an HTTP-based transport SHOULD conform to this specification."
When you DO implement spec-conformant auth, it is **OAuth 2.1** layered on:
- **OAuth 2.1 IETF draft** (`draft-ietf-oauth-v2-1-13`), whose security BCP is
[RFC 9700](https://datatracker.ietf.org/doc/html/rfc9700).
- **PKCE** (S256) — clients MUST implement it and MUST refuse to proceed without
`code_challenge_methods_supported`.
- **Resource Indicators ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html))**
clients MUST send `resource=` in auth+token requests; servers MUST validate tokens
were issued for them (audience binding).
- **OAuth 2.0 Protected Resource Metadata ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728))**
— servers MUST publish `authorization_servers` via a `WWW-Authenticate` 401 challenge
and/or `.well-known/oauth-protected-resource`.
- **Authorization Server Metadata ([RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414))**
or **OpenID Connect Discovery 1.0** for AS discovery.
- **Dynamic Client Registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591))** — MAY;
now secondary to **Client ID Metadata Documents (SEP-991)**.
Crucially, the token always travels as a **bearer** in the standard header:
`Authorization: Bearer <access-token>` — MUST be on **every** HTTP request, MUST NOT be
in the query string. Source: [Access Token Usage](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#access-token-usage).
### 2.2 The practical alternative for a single-tenant blog
Full OAuth 2.1 (PKCE + DCR + resource metadata + an authorization server you host) is
vastly over-engineered for **one admin minting tokens for their own AI clients
(Claude Desktop, Cursor, Cline)**. The spec leaves the door open by making auth OPTIONAL,
and **every major client supports a static bearer token via the `Authorization` header**,
which is exactly the `Authorization: Bearer` form the spec mandates for OAuth tokens
anyway:
- **Claude Code / Claude Desktop**: `claude mcp add --transport http <name> <url> --header "Authorization: Bearer <token>"`;
JSON form uses `type: "http"` (alias `streamable-http`), `url`, `headers.Authorization`.
Source: [Claude Code MCP docs](https://code.claude.com/docs/en/mcp).
- **Cursor**: `~/.cursor/mcp.json``type: "streamable-http"` (or `"http"`), `url`,
`headers.Authorization`. Source: [Cursor MCP docs](https://docs.cursor.com/context/model-context-protocol).
- **Cline**: `cline_mcp_settings.json``type: "streamableHttp"`, `url`,
`headers.Authorization`, plus `disabled`/`autoApprove`. ⚠️ Cline historically lets its
OAuth-provider logic override custom `Authorization` headers on `sse` entries, so
`streamableHttp` is the recommended type for bearer tokens. Source: [Cline docs / GitHub](https://cline.bot),
[GitHub issue on header override](https://github.com/cline/cline).
**So: model the credential as an opaque bearer token, validate it per-request, and skip
the OAuth dance entirely.** The header format is identical to what an OAuth-issued token
would use, so this path is forward-compatible if you ever add real OAuth.
Caveats the spec forces regardless of token source:
- **Token passthrough is forbidden.** "MCP servers MUST NOT accept any tokens that were
not explicitly issued for the MCP server." ([Token Passthrough](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#token-passthrough)).
For a self-issued opaque token validated by SHA-256 lookup, this is automatically
satisfied — the token *is* issued for our server.
- **Sessions MUST NOT be used for authentication.** "MCP servers that implement
authorization MUST verify all inbound requests. MCP Servers MUST NOT use sessions for
authentication." ([Session Hijacking](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#session-hijacking)).
→ The bearer token authenticates every request; `Mcp-Session-Id` (if ever used) is
transport-only and untrusted.
- **Token theft guidance:** "Authorization servers SHOULD issue short-lived access
tokens." → make the admin-selectable lifetime short-by-default and rotatable.
Source: [Token Theft](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#token-theft).
- **Scope minimization** is an explicit spec recommendation: avoid wildcard/omnibus
scopes, prefer progressive least-privilege. Source: [Scope Minimization](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#scope-minimization).
### 2.3 The 2025 security incidents & mitigations
- **Tool Poisoning Attack (TPA)** — coined/disclosed by **Invariant Labs** (April 2025;
since acquired by Snyk). An attacker (often a malicious or compromised MCP server)
embeds hidden instructions in tool *descriptions*/*schemas*; the LLM treats them as
authoritative and may exfiltrate data or call other tools. This is a form of indirect
prompt injection that turns the agent into a **confused deputy**. Mitigations:
treat tool metadata and tool *output* as untrusted; human-in-the-loop approval for
mutating calls; narrow scopes. Source: [Invariant Labs — Tool Poisoning Attacks](https://invariantlabs.ai/blogs/tool-poisoning-attacks/).
- **Confused-deputy / over-privileged proxy** — the spec's canonical example: an MCP
proxy holding broad OAuth tokens to a third-party API is tricked into misusing them.
Mitigations: per-client consent, exact `redirect_uri` matching, per-request auth,
narrow scopes. Source: [Confused Deputy Problem](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#confused-deputy-problem).
- **Rug.md / malicious resources** — the broader supply-chain risk that a benign-looking
resource (e.g. a markdown file) carries injection payloads. Mitigation: read paths
(Resources) should not be auto-executed; segregate read-only from mutating Tools.
- **Token Passthrough** — covered above; explicitly forbidden.
- **SSRF via OAuth metadata URLs** — clients fetching `WWW-Authenticate`/resource-metadata
URLs must block private IPs / cloud metadata. (Relevant if Yggdrasil ever acts as an
MCP *client*; less so as a server.) Source: [SSRF](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#server-side-request-forgery-ssrf).
- **Session hijacking** — covered above.
- **Lethal trifecta** (community framing, Simon Willison et al.): private-data access +
untrusted content + outbound capability = trivial exfiltration. Mitigation: don't give
a single token all three; scope tools down.
- **`mcp-remote` CVE-2025-6514** — a command-injection bug in the popular `mcp-remote`
shim that bridges stdio clients to remote servers. Lesson: vet the transport shim, not
just your server.
### 2.4 Mandatory server-side hardening (from the Streamable HTTP security warning)
The spec's Streamable HTTP section makes three requirements/non-recommendations
([Security Warning](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http)):
1. Servers **MUST validate the `Origin` header** on all connections to prevent DNS
rebinding; invalid Origin → **403**. *(From 2025-11-25 this 403 is a hard MUST.)*
2. Local servers SHOULD bind to localhost (not relevant — we are remote).
3. Servers **SHOULD implement proper authentication for all connections.**
---
## 3. Primitives to Implement
MCP primitives split into **server-offered** (Resources, Prompts, Tools, Logging,
Completions) and **client-offered** (Sampling, Roots, Elicitation). For a content/CMS
MCP server the relevant set is small. Sources:
[lifecycle capabilities](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle),
[resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources),
[tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools).
| Primitive | Purpose | Yggdrasil? |
|---|---|---|
| **Tools** | Model-invoked functions (`tools/call`). Model-controlled. | **Yes** — search, create/update/publish posts, moderate comments, manage tags/settings. |
| **Resources** | App/agent-selected context, addressable by **URI**, listable via `resources/list`, readable via `resources/read`. | **Yes** — each PUBLISHED post = one Resource. |
| **Resource Templates** | URI templates (RFC 6570) for parameterized resources. | **Yes** — e.g. `post://{slug}` or `https://rua.plus/posts/{slug}`. |
| **Prompts** | Templated user-facing workflows (`prompts/get`). | Optional — e.g. a "draft a post about X" prompt. Low priority. |
| **Completions** | Argument autocompletion. | Optional, skip for MVP. |
| **Logging** | Structured log stream to client. | Optional; **deprecated** in draft `2026-07-28` (SEP-2577). Skip. |
| **Elicitation** | Server asks client/user for structured input mid-operation. | Optional; useful for confirmations but adds complexity. Skip for MVP. |
| **Sampling** | Server asks client's LLM to run a completion. | **Skip.** Deprecated (SEP-2577); not needed. |
| **Roots** | Client tells server its workspace boundaries. | **Skip** (local-IDE concept). Deprecated (SEP-2577). |
| **Tasks** | Long-running, polled operations (2025-11-25 experimental). | Skip for MVP; only relevant for very slow ops. |
### 3.1 The knowledge-base pattern (Resources + a search Tool) — recommended
This **is** the documented split and the recommended pattern:
- **Resources are "application-driven"** — the host decides how to incorporate them
(UI picker, heuristics, or the model's selection). Each is identified by a URI and
returns `text` or base64 `blob` content. Source: [resources — User Interaction Model](https://modelcontextprotocol.io/specification/2025-11-25/server/resources).
- **Tools are "model-controlled"** — the LLM discovers and invokes them automatically.
"There SHOULD always be a human in the loop with the ability to deny tool invocations."
Source: [tools — User Interaction Model](https://modelcontextprotocol.io/specification/2025-11-25/server/tools).
Concretely for Yggdrasil:
- `resources/list` → paginated list of PUBLISHED posts (title, URI, mimeType `text/markdown`).
- `resources/templates/list``post://{slug}` (or the canonical HTTPS URL).
- `resources/read` → full rendered Markdown/HTML of one post.
- A `search_posts` **Tool** → keyword/FTS query, returns summaries + `resource_link`s
to the matching posts (the `resource_link` content type, added 2025-06-18, lets a tool
return URIs the client can then `resources/read`). Source: [Resource Links](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#resource-links).
- Mutating Tools (`create_post`, `update_post`, `publish_post`, `delete_post`,
`moderate_comment`, `update_settings`, `manage_tags`) for admin tokens.
### 3.2 Pagination of large resource lists
MCP uses **opaque cursor-based pagination**, not numbered pages
([pagination spec](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination)):
- Server returns `nextCursor` when more exist; client passes `cursor` to continue.
- **Page size is server-determined**; clients MUST NOT assume a fixed size.
- Applies to `resources/list`, `resources/templates/list`, `prompts/list`, `tools/list`.
- Clients MUST treat cursors as opaque; invalid cursor → error `-32602` (Invalid params).
- "Pagination is especially important when connecting to external services over the
internet."
> Implement `resources/list` with a cursor (e.g. base64 of `("published_at", id)` or a
> signed offset). For the search Tool, cap result count and return `resource_link`s
> rather than dumping entire bodies.
---
## 4. Client Discovery / Config
All major clients consume a **`mcpServers` JSON object** keyed by server name. For a
remote Streamable HTTP server with a bearer token the entries are structurally identical
(`url` + `type` + `headers.Authorization`), differing only in the exact `type` spelling
and the file location. **A "Generate MCP config" admin feature should emit one block per
client** (or a canonical block plus copy-paste variants).
### Minimal example (canonical — works for Claude Code & Cursor)
```json
{
"mcpServers": {
"yggdrasil": {
"type": "streamable-http",
"url": "https://rua.plus/mcp",
"headers": {
"Authorization": "Bearer ygg_<opaque-token>"
}
}
}
}
```
### Per-client specifics
| Client | File | `type` value | Notes | Source |
|---|---|---|---|---|
| **Claude Code / Desktop** | `.mcp.json` (project) or `~/.claude.json` (user) | `"http"` (alias `"streamable-http"`) | `type` is **required** when `url` present or the entry is misread as stdio. CLI: `claude mcp add --transport http <name> <url> --header "Authorization: Bearer …"`. | [Claude Code MCP](https://code.claude.com/docs/en/mcp) |
| **Cursor** | `~/.cursor/mcp.json` | `"streamable-http"` or `"http"` | Single `/mcp` endpoint; headers sent on every request. | [Cursor MCP](https://docs.cursor.com/context/model-context-protocol) |
| **Cline** | `…/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` | `"streamableHttp"` | ⚠️ Use `streamableHttp`, **not** `sse` — Cline's OAuth logic can override custom `Authorization` headers on `sse` entries. Also has `disabled`, `autoApprove`. | [Cline docs](https://cline.bot) |
| **Continue** | `~/.continue/config.json` `experimental.mcpServers` | `"streamableHttp"` | Same `url`+`headers` shape. | Continue docs |
> The `Authorization: Bearer …` header shape is **identical** to what an OAuth-issued
> token would use ([Access Token Usage](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#access-token-usage)),
> so a generated config is forward-compatible with a future OAuth flow.
A "Generate MCP config" button should:
1. Mint (or reuse) a token with the chosen scope + lifetime.
2. Render **three** ready-to-paste JSON snippets (Claude/Cursor share one; Cline uses
`streamableHttp`; include the CLI one-liner for `claude mcp add`).
3. Warn that the snippet contains a secret and must not be committed.
---
## 5. Rust Ecosystem
### 5.1 The official SDK: `rmcp`
`rmcp` (crate `rmcp`, `modelcontextprotocol/rust-sdk`) is the **official** Rust MCP SDK,
tokio-based, Apache-2.0. As of 2026-07-28:
- **Latest stable: `2.2.0`** (2026-07-08); **dev: `3.0.0-beta.2`** (2026-07-24). There
was a `0.1.x → 1.x` (migration guide discussion #716) and now a `1.x → 2.x` line.
- **3,398,090 downloads/month**, **1,765 dependent crates** (1,430 direct), Rust 2024
edition. This is a mature, heavily-used crate, not a toy.
- Tracks `2026-07-28` draft while remaining compatible with `2025-11-25` and earlier.
Source: [rmcp on lib.rs](https://lib.rs/crates/rmcp), [rmcp README](https://github.com/modelcontextprotocol/rust-sdk).
### 5.2 Feature flags (cargo)
```toml
rmcp = { version = "2.2", features = [
"server", # ServerHandler + tool system (default)
"macros", # #[tool]/#[prompt] (default)
"schemars", # JSON Schema gen for inputSchema/outputSchema
"transport-streamable-http-server", # StreamableHttpService (Tower)
"reqwest", # rustls TLS backend (if you also build a client)
] }
```
`auth` (OAuth) and `elicitation` are opt-in but **not needed** for a bearer-token server.
Source: [rmcp feature flags](https://lib.rs/crates/rmcp).
### 5.3 Mounting on Axum — there IS a first-class adapter
`StreamableHttpService` is a **Tower `Service`**, mounted on Axum with `nest_service`:
```rust
use rmcp::transport::streamable_http_server::{
StreamableHttpService, StreamableHttpServerConfig,
session::local::LocalSessionManager,
};
let config = StreamableHttpServerConfig::default()
.with_legacy_session_mode(false) // stateless for legacy versions too (SEP-2567)
.with_json_response(true); // plain application/json for simple tools,
// falls back to text/event-stream when streaming needed
let service = StreamableHttpService::new(
|| Ok(YggMcpServer::new(state.clone())), // fresh handler per request (stateless!)
LocalSessionManager::default().into(),
config,
);
let router = axum::Router::new()
.nest_service("/mcp", service)
// your existing Axum routes merge here in src/main.rs
.merge(existing_app_router());
```
Source: [rmcp README — Stateless Streamable HTTP](https://github.com/modelcontextprotocol/rust-sdk).
> Because there is no per-session state, the `service_factory` runs **per request**.
> Keep shared state (DB pool, moka cache) in a `Clone` handle captured by the closure;
> do not rely on in-memory state surviving between requests.
### 5.4 Defining tools/resources — declarative macros + trait
- `#[tool_router]` + `#[tool]` macros generate `inputSchema`/`outputSchema` from
`schemars::JsonSchema` derive structs; handlers are plain `async fn`s returning
`String`/`Vec<ContentBlock>`/`CallToolResult`/`Result<_, McpError>`.
- Resources: implement `list_resources`, `read_resource`, `list_resource_templates` on
the `ServerHandler` trait; advertise the `resources` capability in `get_info()`.
- `list_changed` notifications let you push tool/resource-list updates without reconnect.
Source: [rmcp README — Tools / Resources](https://github.com/modelcontextprotocol/rust-sdk).
### 5.5 OAuth support (if ever needed)
`rmcp` has a full OAuth 2.1 *client* implementation (`auth` feature): PKCE-S256, RFC 8707
resource binding, RFC 9728/8414 discovery, DCR, Client ID Metadata Documents, scope
upgrade on 403, automatic token refresh, injectable HTTP client. A server-side OAuth
example (`servers_complex_auth_streamhttp`) exists. For our bearer-token design we do
**not** enable this. Source: [rmcp OAUTH_SUPPORT.md](https://github.com/modelcontextprotocol/rust-sdk/blob/main/docs/OAUTH_SUPPORT.md).
### 5.6 Hand-rolling JSON-RPC over Axum — feasibility & pitfalls
Feasible (it's JSON-RPC 2.0 over POST) but **not recommended**:
- You must implement the full lifecycle: `initialize``notifications/initialized`,
capability negotiation, version header validation, `MCP-Protocol-Version` handling,
the SSE-vs-JSON response selection, `tools/list`+`tools/call`+`resources/list`+
`resources/read`+`resources/templates/list`, pagination cursors, `list_changed`,
error-code mapping (`-32602`, etc.).
- Pitfalls: getting the `Accept`/`Content-Type` negotiation wrong, forgetting the
`Origin` 403, mishandling `MCP-Protocol-Version` (clients send the negotiated version),
JSON-RPC batching being removed (don't accept arrays), and drift as the spec moves to
`2026-07-28` (caching headers, MRTR, routing headers).
- `rmcp` already solved all of this and is a `tower::Service` — there is no reason to
reimplement. The only case for hand-rolling is an exotic constraint (e.g. zero extra
deps on `FROM scratch`), which does not apply to Yggdrasil's Axum binary.
---
## 6. Knowledge Base / Retrieval
### 6.1 MCP defines no embedding/semantic convention
There is **no** MCP "search" or "retrieval" primitive and no embedding convention. Search
is just a **Tool** whose `inputSchema` defines the query and whose result is content
(possibly `resource_link`s). Resources are the addressable corpus. This is intentional —
the protocol is agnostic to *how* you retrieve. Source: [tools spec](https://modelcontextprotocol.io/specification/2025-11-25/server/tools),
[resources spec](https://modelcontextprotocol.io/specification/2025-11-25/server/resources).
### 6.2 Full-text vs semantic/vector — tradeoffs
- **Postgres FTS (tsvector/tsquery, `websearch_to_tsquery`)** — already in Yggdrasil
(`src/api/posts/search.rs`), zero new infra, ranking via `ts_rank`, good for a blog's
vocabulary, deterministic, no embedding model to host/version. **MVP choice.**
- **pgvector + embeddings** — better recall for paraphrase/semantic queries, but adds:
an embedding model (API call or local), a pipeline to (re)embed on publish/edit,
a vector index (HNSW/IVFFlat), cost/latency per embed, and drift when the model is
upgraded. Worth it only when FTS recall is demonstrably insufficient.
### 6.3 Recommended MVP for Yggdrasil
1. **Read path = Resources.** Expose every PUBLISHED post as a Resource (URI =
canonical HTTPS URL or `post://{slug}`), `resources/list` paginated, `resources/read`
returns rendered Markdown (cheaper for the model than raw HTML; respects the existing
`content_html` cache by re-rendering).
2. **Search path = a `search_posts` Tool** backed by the existing Postgres FTS. Returns
top-N `{title, slug, uri, snippet, published_at}` plus a `resource_link` for each so
the client can pull full text via `resources/read`. This matches the [Resource Links](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#resource-links)
pattern added in 2025-06-18.
3. **Defer vector retrieval** until FTS recall is measured and found wanting. The Tool
boundary means swapping the backend later is invisible to clients.
> Note: a search Tool result is model-consumed text and is therefore an indirect-prompt-
> injection surface (attacker-controlled post bodies). Return snippets, validate/sanitize,
> and keep write Tools separately scoped. See §2.3.
---
## 7. Operational Hardening
### 7.1 Rate limiting keyed by token, not IP
- The spec's **Scope Minimization** and Token Theft guidance both push toward per-principal
accountability. The existing Yggdrasil governor is keyed by **client IP**; for the MCP
route the limiter MUST be keyed by the **bearer token** (or its hash), because (a) all
requests hit one endpoint and IP-keying collapses many users behind a NAT/proxy, and
(b) the token is the real principal.
- Implementation: an Axum `from_fn` middleware on `/mcp` that extracts the
`Authorization` header, resolves the token to a `(user_id, scope)` via SHA-256 lookup,
and feeds a `governor`/`tower` limiter keyed on `user_id` (or token id). The IP-keyed
governor stays on the rest of the app.
- Bound per-request size, add a hard per-tool timeout (the spec recommends configurable
per-request timeouts and honoring progress notifications — [Lifecycle — Timeouts](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts)).
### 7.2 Token storage — hashing
- Store only the **SHA-256** of the token at rest (the spec says session IDs MAY be a
"cryptographic hash"; the same principle applies to bearer tokens). On each request,
`sha256(bearer)` → DB lookup → `(user_id, scopes, expires_at, revoked)`. This matches
the brief's "sessions here are SHA-256 hashed."
- Tokens are long random opaque strings (≥32 bytes from a CSPRNG), prefixed (e.g.
`ygg_…`) for searchability/leak-detection, with an admin-settable **lifetime**
(default short, per the spec's "short-lived access tokens" guidance) and one-click
**rotation/revocation**.
### 7.3 Per-token scopes (read / write / admin)
- Implement a coarse, least-privilege scope set (no wildcards — the spec explicitly warns
against omnibus scopes). Suggested: `posts:read` (default), `posts:write`,
`comments:moderate`, `settings:write`, `admin`. A token carries a subset.
- Map scopes to Tools at dispatch: `search_posts`/`resources/*` need `posts:read`;
`create_post`/`update_post` need `posts:write`; `publish_post`/`delete_post` and
`update_settings` need `admin`. Return HTTP 403 `insufficient_scope` (the spec's
step-up pattern) when a tool is called without the scope — this is exactly the
[WWW-Authenticate scope challenge](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-challenge-handling)
shape, even though we are not doing full OAuth.
- Because the token *list* can be advertised per-token, an admin can mint a read-only
token for a retrieval-only client and a full token for an authoring client.
### 7.4 Origin allowlisting & transport security
- **MUST validate `Origin`** on `/mcp` (DNS-rebinding defense) → 403 otherwise. Allowlist
the configured site origin(s) and reject the rest. ([transports security warning](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http))
- Serve over HTTPS only (the nginx-proxy in front of Yggdrasil already terminates TLS).
OAuth 2.1 requires HTTPS for all endpoints ([Communication Security](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#communication-security));
bearer tokens over plaintext are trivially sniffable, so enforce HTTPS at the proxy.
### 7.5 Audit logging
- Log every `tools/call` (and mutating `resources` ops if any) with token id, user,
scope, tool name, args-hash, outcome, latency. The spec's "Accountability and Audit
Trail" argument against token passthrough is exactly why per-token identity matters.
Source: [Token Passthrough — Risks](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#token-passthrough).
### 7.6 Integration with the existing Axum stack
- Mount the `rmcp` `StreamableHttpService` at `/mcp` and merge it with the existing
manual + Dioxus server-function router in `src/main.rs` exactly as other routes are.
- Layer the middleware **only on `/mcp`**: `bearer-auth → rate-limit(token) → origin-check → mcp-service`.
The IP-keyed governor and cookie-session auth on the rest of the app are untouched.
- DB access reuses the existing `deadpool` pool; caching reuses `moka`; the existing FTS
query in `src/api/posts/search.rs` backs the `search_posts` tool.
---
## Recommendation for Yggdrasil
A concrete, opinionated blueprint to turn into a spec.
### Transport
- **Streamable HTTP, single endpoint `/mcp`, stateless.** Pin `rmcp` and negotiate
`2025-11-25`; when stable, opt into `2026-07-28` for free stateless-by-default + caching
headers. No `Mcp-Session-Id`, no GET stream, no `Last-Event-ID`. Use
`with_json_response(true)` (plain `application/json`) with SSE fallback only when a
handler streams notifications.
### Auth model
- **Opaque bearer tokens** in `Authorization: Bearer ygg_<…>`, validated per-request by
`sha256(token)` → DB lookup. **No OAuth 2.1 / PKCE / DCR** (overkill for single-tenant).
The header format is identical to an OAuth bearer, so this is forward-compatible.
- Tokens are admin-generated in a settings page, **SHA-256 hashed at rest**, with a
**user-selectable lifetime** (default e.g. 90 days, options 1/7/30/90/365 days +
never-expire), one-click **rotate** and **revoke**.
- **Origin allowlist + HTTPS enforced** at nginx-proxy; 403 on bad Origin (spec MUST).
### Primitives
- **Resources:** every PUBLISHED post → one Resource (URI = canonical HTTPS URL).
`resources/list` (paginated, opaque cursor), `resources/templates/list`
(`post://{slug}`), `resources/read` (rendered Markdown). `listChanged` on publish/edit.
- **Tools (read):** `search_posts` (FTS-backed, returns summaries + `resource_link`s),
`get_post`, `list_tags`.
- **Tools (write, scope-gated):** `create_post`, `update_post`, `publish_post`,
`delete_post`, `moderate_comment`/`approve_comment`/`delete_comment`, `update_settings`,
`create_tag`/`rename_tag`.
- Skip Prompts/Completions/Elicitation/Sampling/Roots/Tasks/Logging for MVP.
### SDK choice
- **`rmcp` v2.2.x** with features `server, macros, schemars, transport-streamable-http-server`.
Mount `StreamableHttpService` via `axum::Router::nest_service("/mcp", service)` and merge
with the existing router in `src/main.rs`. Do not hand-roll JSON-RPC.
### Scopes
- `posts:read` (default on every token), `posts:write`, `comments:moderate`,
`settings:write`, `admin`. Map tool→scope at dispatch; return `403 insufficient_scope`
on mismatch. Mint read-only tokens for retrieval clients.
### Rate-limit strategy
- New **token-keyed** limiter on `/mcp` only (extract bearer → `user_id` → governor key).
Leave the existing IP-keyed governor on the web app. Add per-request body-size cap and
per-tool timeout (honor progress notifications to extend).
### Client-config output format
"Generate MCP config" emits three snippets + a CLI one-liner, all pointing at
`https://rua.plus/mcp` with the minted `Authorization: Bearer`:
```json
// Claude Code (.mcp.json / ~/.claude.json) and Cursor (~/.cursor/mcp.json)
{
"mcpServers": {
"yggdrasil": {
"type": "streamable-http",
"url": "https://rua.plus/mcp",
"headers": { "Authorization": "Bearer ygg_<token>" }
}
}
}
```
```json
// Cline (cline_mcp_settings.json) — MUST use streamableHttp, not sse
{
"mcpServers": {
"yggdrasil": {
"type": "streamableHttp",
"url": "https://rua.plus/mcp",
"headers": { "Authorization": "Bearer ygg_<token>" },
"disabled": false, "autoApprove": []
}
}
}
```
```bash
# Claude Code CLI
claude mcp add --transport http yggdrasil https://rua.plus/mcp \
--header "Authorization: Bearer ygg_<token>"
```
### Where our hypothesis was right vs. needs amendment
- **Right:** Streamable HTTP is the correct (and only) remote transport; bearer tokens in
the `Authorization` header are natively supported by every target client and are the
pragmatic auth for a single-tenant blog; embed in the Axum binary via `rmcp`'s Tower
service; expose published posts as Resources + a search Tool.
- **Amend (1):** target **`2025-11-25`**, not `2025-06-18` (and `2026-07-28` is imminent);
the `MCP-Protocol-Version` header handling and `Origin`→403 rule are new MUSTs.
- **Amend (2):** run **stateless** (SEP-2567) — drop any plan for an `Mcp-Session-Id`
store; sessions are transport-only and MUST NOT be used for auth.
- **Amend (3):** the rate limiter must be **token-keyed**, not the existing IP-keyed
governor, and the mutating tools must be **scope-gated** (read/write/admin), not a
single all-powerful admin token.
- **Amend (4):** treat search/tool output as an **indirect-prompt-injection surface**
(Tool Poisoning / Rug.md) — sanitize snippets, keep read vs write tools on separate
scopes, and rely on the client's human-in-the-loop for mutating calls.

225
docs/mcp-spec.md Normal file
View File

@ -0,0 +1,225 @@
# 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/`.