调研(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 强制项)。
39 KiB
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, themodelcontextprotocolGitHub 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— NOT2025-06-18as our brief assumed. A development draft2026-07-28already 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): noMcp-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: Bearerheader is the pragmatic, widely supported choice. Claude Code, Cursor, and Cline all accept a customAuthorizationheader verbatim. Hypothesis CONFIRMED, with caveats (Origin validation is MUST). Source: authorization spec. - Use the official
rmcpcrate (v2.2.0stable,3.0.0-beta.2dev, 3.4M downloads/month, 1,765 dependent crates, Apache-2.0). It ships a TowerStreamableHttpServicethat 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/listpaginated,resources/read) AND ship asearchTool. 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):
- stdio — client launches the server as a subprocess; messages over stdin/stdout. "Clients SHOULD support stdio whenever possible." (Local only.)
- 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 astext/event-streamor reply with a singleapplication/jsonbody. The client MUST sendAccept: 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-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) — major:
- Removed JSON-RPC batching.
- Added structured tool output (
outputSchema,structuredContent). - Reclassified MCP servers as OAuth 2.1 Resource Servers with Protected Resource Metadata discovery (RFC 9728).
- Required clients to implement Resource Indicators (RFC 8707).
- Added the Security Best Practices page.
- Added Elicitation (server asks client/user for input).
- Added Resource Links in tool results.
- Required the
MCP-Protocol-Versionheader on all subsequent HTTP requests. - Changed lifecycle Operation SHOULD → MUST.
2025-06-18 → 2025-11-25 (changelog) — major:
- Added OpenID Connect Discovery 1.0 support in AS discovery.
- Icons metadata on tools/resources/prompts.
- Incremental scope consent via
WWW-Authenticate(SEP-835). - Tool-name guidance (SEP-986).
- Richer Elicitation schemas (enums, single/multi-select, defaults) and URL-mode elicitation.
- Tool calling inside sampling (
tools/toolChoice). - OAuth Client ID Metadata Documents (SEP-991) as a recommended client-registration mechanism.
- Experimental Tasks extension (SEP-1686) — durable, polled long-running requests.
- JSON Schema 2020-12 as the default dialect.
- Servers MUST respond 403 for invalid
Originheaders 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-Idin theInitializeResultresponse. 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 — "noMcp-Session-Id, no standalone GET/DELETE stream, and noLast-Event-IDresumption." (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_serversvia aWWW-Authenticate401 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 usestype: "http"(aliasstreamable-http),url,headers.Authorization. Source: Claude Code MCP docs. - Cursor:
~/.cursor/mcp.json→type: "streamable-http"(or"http"),url,headers.Authorization. Source: Cursor MCP docs. - Cline:
cline_mcp_settings.json→type: "streamableHttp",url,headers.Authorization, plusdisabled/autoApprove. ⚠️ Cline historically lets its OAuth-provider logic override customAuthorizationheaders onsseentries, sostreamableHttpis 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_urimatching, 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-remoteCVE-2025-6514 — a command-injection bug in the popularmcp-remoteshim 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):
- Servers MUST validate the
Originheader on all connections to prevent DNS rebinding; invalid Origin → 403. (From 2025-11-25 this 403 is a hard MUST.) - Local servers SHOULD bind to localhost (not relevant — we are remote).
- 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. |
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
textor base64blobcontent. 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, mimeTypetext/markdown).resources/templates/list→post://{slug}(or the canonical HTTPS URL).resources/read→ full rendered Markdown/HTML of one post.- A
search_postsTool → keyword/FTS query, returns summaries +resource_links to the matching posts (theresource_linkcontent type, added 2025-06-18, lets a tool return URIs the client can thenresources/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
nextCursorwhen more exist; client passescursorto 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/listwith a cursor (e.g. base64 of("published_at", id)or a signed offset). For the search Tool, cap result count and returnresource_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:
- Mint (or reuse) a token with the chosen scope + lifetime.
- Render three ready-to-paste JSON snippets (Claude/Cursor share one; Cline uses
streamableHttp; include the CLI one-liner forclaude mcp add). - 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 a0.1.x → 1.x(migration guide discussion #716) and now a1.x → 2.xline. - 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-28draft while remaining compatible with2025-11-25and 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_factoryruns per request. Keep shared state (DB pool, moka cache) in aClonehandle 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 generateinputSchema/outputSchemafromschemars::JsonSchemaderive structs; handlers are plainasync fns returningString/Vec<ContentBlock>/CallToolResult/Result<_, McpError>.- Resources: implement
list_resources,read_resource,list_resource_templateson theServerHandlertrait; advertise theresourcescapability inget_info(). list_changednotifications 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:
initialize↔notifications/initialized, capability negotiation, version header validation,MCP-Protocol-Versionhandling, 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-Typenegotiation wrong, forgetting theOrigin403, mishandlingMCP-Protocol-Version(clients send the negotiated version), JSON-RPC batching being removed (don't accept arrays), and drift as the spec moves to2026-07-28(caching headers, MRTR, routing headers). rmcpalready solved all of this and is atower::Service— there is no reason to reimplement. The only case for hand-rolling is an exotic constraint (e.g. zero extra deps onFROM 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 viats_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
- Read path = Resources. Expose every PUBLISHED post as a Resource (URI =
canonical HTTPS URL or
post://{slug}),resources/listpaginated,resources/readreturns rendered Markdown (cheaper for the model than raw HTML; respects the existingcontent_htmlcache by re-rendering). - Search path = a
search_postsTool backed by the existing Postgres FTS. Returns top-N{title, slug, uri, snippet, published_at}plus aresource_linkfor each so the client can pull full text viaresources/read. This matches the Resource Links pattern added in 2025-06-18. - 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_fnmiddleware on/mcpthat extracts theAuthorizationheader, resolves the token to a(user_id, scope)via SHA-256 lookup, and feeds agovernor/towerlimiter keyed onuser_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/*needposts:read;create_post/update_postneedposts:write;publish_post/delete_postandupdate_settingsneedadmin. Return HTTP 403insufficient_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
Originon/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 mutatingresourcesops 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
rmcpStreamableHttpServiceat/mcpand merge it with the existing manual + Dioxus server-function router insrc/main.rsexactly 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
deadpoolpool; caching reusesmoka; the existing FTS query insrc/api/posts/search.rsbacks thesearch_poststool.
Recommendation for Yggdrasil
A concrete, opinionated blueprint to turn into a spec.
Transport
- Streamable HTTP, single endpoint
/mcp, stateless. Pinrmcpand negotiate2025-11-25; when stable, opt into2026-07-28for free stateless-by-default + caching headers. NoMcp-Session-Id, no GET stream, noLast-Event-ID. Usewith_json_response(true)(plainapplication/json) with SSE fallback only when a handler streams notifications.
Auth model
- Opaque bearer tokens in
Authorization: Bearer ygg_<…>, validated per-request bysha256(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).listChangedon 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
rmcpv2.2.x with featuresserver, macros, schemars, transport-streamable-http-server. MountStreamableHttpServiceviaaxum::Router::nest_service("/mcp", service)and merge with the existing router insrc/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; return403 insufficient_scopeon mismatch. Mint read-only tokens for retrieval clients.
Rate-limit strategy
- New token-keyed limiter on
/mcponly (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
Authorizationheader are natively supported by every target client and are the pragmatic auth for a single-tenant blog; embed in the Axum binary viarmcp's Tower service; expose published posts as Resources + a search Tool. - Amend (1): target
2025-11-25, not2025-06-18(and2026-07-28is imminent); theMCP-Protocol-Versionheader handling andOrigin→403 rule are new MUSTs. - Amend (2): run stateless (SEP-2567) — drop any plan for an
Mcp-Session-Idstore; 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.