From 801cb79e0d4315ac7aa68215697084f0df39d42e Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 28 Jul 2026 11:09:45 +0800 Subject: [PATCH] =?UTF-8?q?docs(mcp):=20=E6=96=B0=E5=A2=9E=20MCP=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E8=B0=83=E7=A0=94=E4=B8=8E=E8=A7=84?= =?UTF-8?q?=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调研(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 强制项)。 --- docs/mcp-research.md | 651 +++++++++++++++++++++++++++++++++++++++++++ docs/mcp-spec.md | 225 +++++++++++++++ 2 files changed, 876 insertions(+) create mode 100644 docs/mcp-research.md create mode 100644 docs/mcp-spec.md diff --git a/docs/mcp-research.md b/docs/mcp-research.md new file mode 100644 index 0000000..9117234 --- /dev/null +++ b/docs/mcp-research.md @@ -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 ` — 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 --header "Authorization: Bearer "`; + 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_" + } + } + } +} +``` + +### 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 --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`/`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_" } + } + } +} +``` +```json +// Cline (cline_mcp_settings.json) — MUST use streamableHttp, not sse +{ + "mcpServers": { + "yggdrasil": { + "type": "streamableHttp", + "url": "https://rua.plus/mcp", + "headers": { "Authorization": "Bearer ygg_" }, + "disabled": false, "autoApprove": [] + } + } +} +``` +```bash +# Claude Code CLI +claude mcp add --transport http yggdrasil https://rua.plus/mcp \ + --header "Authorization: Bearer ygg_" +``` + +### 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. diff --git a/docs/mcp-spec.md b/docs/mcp-spec.md new file mode 100644 index 0000000..6441b11 --- /dev/null +++ b/docs/mcp-spec.md @@ -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_` 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` extractor → `parts.extensions.get::()`. +- 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/`.