yggdrasil/docs/mcp-research.md
xfy 801cb79e0d 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 强制项)。
2026-07-28 11:09:45 +08:00

39 KiB
Raw Blame History

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.
  • 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.
  • 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.
  • 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.
  • 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, rmcp README.
  • 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, 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.

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.

1.2 Defined transports

The spec defines exactly two transports (transports spec):

  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.

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; client support: Claude Code MCP.

1.4 What changed across revisions

2024-11-052025-03-26: introduced the HTTP+SSE transport (later replaced). Authorization framework introduced at the transport layer.

2025-03-262025-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-182025-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):

  • 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 (0x210x7E), 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).

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.

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):

"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.
  • PKCE (S256) — clients MUST implement it and MUST refuse to proceed without code_challenge_methods_supported.
  • Resource Indicators (RFC 8707) — 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) — servers MUST publish authorization_servers via a WWW-Authenticate 401 challenge and/or .well-known/oauth-protected-resource.
  • Authorization Server Metadata (RFC 8414) or OpenID Connect Discovery 1.0 for AS discovery.
  • Dynamic Client Registration (RFC 7591) — 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.

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.
  • Cursor: ~/.cursor/mcp.jsontype: "streamable-http" (or "http"), url, headers.Authorization. Source: Cursor MCP docs.
  • Cline: cline_mcp_settings.jsontype: "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, GitHub issue on header override.

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). 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). → 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.
  • Scope minimization is an explicit spec recommendation: avoid wildcard/omnibus scopes, prefer progressive least-privilege. Source: 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.
  • 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.
  • 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.
  • 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):

  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, resources, 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.

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.
  • 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.

Concretely for Yggdrasil:

  • resources/list → paginated list of PUBLISHED posts (title, URI, mimeType text/markdown).
  • resources/templates/listpost://{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_links 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.
  • 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):

  • 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_links 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)

{
  "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
Cursor ~/.cursor/mcp.json "streamable-http" or "http" Single /mcp endpoint; headers sent on every request. Cursor MCP
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
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), 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, rmcp README.

5.2 Feature flags (cargo)

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.

5.3 Mounting on Axum — there IS a first-class adapter

StreamableHttpService is a Tower Service, mounted on Axum with nest_service:

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.

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 fns 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.

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.

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: initializenotifications/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_links). Resources are the addressable corpus. This is intentional — the protocol is agnostic to how you retrieve. Source: tools spec, resources spec.

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.
  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 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).

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 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)
  • Serve over HTTPS only (the nginx-proxy in front of Yggdrasil already terminates TLS). OAuth 2.1 requires HTTPS for all endpoints (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.

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_links), 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:

// 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>" }
    }
  }
}
// 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": []
    }
  }
}
# 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.