Dayward AI
Week 1 · D4About 6 hours

Remote MCP: the Streamable HTTP Binding, the Stateless Model and Request Metadata, OAuth 2.1 Authorization, Container Deployment

Move a server from a local subprocess onto the public internet: one POST endpoint, two response shapes, one set of required headers, plus OAuth 2.1 audience validation and a container deployment that can scale out.

Today's goals 0/3

Sign in to tick these off and save your progress.

Today's Goals

  1. Implement a Streamable HTTP server with a single POST endpoint that returns JSON or an event stream as needed
  2. State how a client must change now that sessions, the GET stream, and stream resumption are gone in this version
  3. Explain the token audience validation an MCP server must do as a protected resource, and what happens if it doesn't

For three days your server lived locally, launched by the host as a child process, taking credentials from environment variables, with nobody able to touch it from outside. Today it moves onto the public internet — a step that pushes three questions in front of you at once: who may connect, whose account this counts against, and what happens when it dies. Come back and tick off the three goals above.

Plain-Language Walkthrough

From a cable run across the room to joining the public grid

stdio is a cable you ran across the room: both ends are in your house, the plug shape is yours to decide, and nobody comes to steal power. Streamable HTTP is joining the public grid: voltage, frequency, and socket standard all follow the public specification, because you do not know who is at the other end of the line.

Four classes of problem appear, each with a corresponding hard requirement.

The first is who is knocking. Any web page in a browser can send a request to http://127.0.0.1:3034, which is the entry point for a DNS rebinding attack: an attacker controls a domain, resolves it to their own server first to pass the origin check, then re-resolves it to 127.0.0.1, and your local file-reading MCP server is taken over by a stranger's page. The spec is hard here: a server must validate the Origin header and must return 403 when it is present and invalid. When running locally it should also bind only the loopback address 127.0.0.1 rather than 0.0.0.0 — plenty of people habitually write 0.0.0.0 inside containers and carry the habit back to bare metal.

The second is whose account this counts against. Under stdio a server naturally serves one user; on the public internet one endpoint faces thousands. The spec is blunt: all connections should implement authentication. The specifics come in the OAuth section below.

The third is how many of me there are. Locally there is always one process; on the internet you run three or five replicas and a request must be handleable wherever it lands. That one is the stateless protocol's biggest dividend, and the last section works out the arithmetic.

The fourth is when it counts as finished. A local call returns in milliseconds, while a tool taking thirty seconds is normal on the internet, and during that time the connection sits idle with the user unable to tell computing from dead.

The first is solved by one line of validation, and the rest all start from the transport layer's shape. So what does this endpoint actually look like?

One endpoint, two ways to answer

The conclusion is simpler than most people expect: a server must provide exactly one HTTP path supporting POST, such as https://example.com/mcp. Just that one. No second path, no WebSocket, no polling endpoint.

The client-side rules are short too: every JSON-RPC message must be a new HTTP POST; the Accept header must list both application/json and text/event-stream; and the body must be a single JSON-RPC request or notification and must not be a JSON-RPC response.

On receipt the server has two cases. When the body is a notification (no id), accepting it returns 202 Accepted with no body. When it is a request, the server decides for itself which shape to return:

  • Content-Type: application/json — one JSON object, done.
  • Content-Type: text/event-stream — an event stream belonging to this request alone, streaming out any notifications related to this request and finally the actual response.

A client must support both, because the choice belongs to the server. This is the most easily missed rule: many home-grown clients handle only application/json and break outright against a server that streams progress.

The event stream has three hard rules. It may carry notifications/progress and notifications/message, but they must relate to the request that opened the stream; the server must never send an independent JSON-RPC request on that stream (the previous revision did this and this one forbids it, for the reason given in the next section); and the final response should terminate the stream. Two engineering recommendations are not to be skipped either: send X-Accel-Buffering: no when opening the stream, or a reverse proxy such as nginx will buffer events and deliver them together — everything works locally and in production all the progress smears into the last second; and a long-lived stream should periodically send an SSE comment line beginning with a colon as a keepalive, so intermediaries do not cut it on an idle timeout.

There is also an easily overlooked semantic: a client closing the event stream is the cancellation signal for that request. Because each request has its own response stream, disconnection is unambiguous, so HTTP needs no notifications/cancelled message at all (it is used only on stdio).

endpoint.js
// One endpoint, two ways to answer. The only real branch is right here
async function handlePost(req, res, msg) {
  if (msg.id === undefined) {
    res.writeHead(202) // a notification: accepted means 202, with no body
    return res.end()
  }
 
  const tool = TOOLS.get(msg.params.name)
  if (!tool.streaming) {
    const result = await tool.run(msg.params.arguments)
    return sendJson(res, 200, { jsonrpc: '2.0', id: msg.id, result })
  }
 
  res.writeHead(200, {
    'content-type': 'text/event-stream',
    'cache-control': 'no-cache',
    'x-accel-buffering': 'no', // without this line nginx buffers the progress and sends it all at once
  })
  res.write(':\n\n') // a leading colon is an SSE comment line used as a keepalive; clients must ignore it
  const send = (payload) => res.write(`data: ${JSON.stringify(payload)}\n\n`)
 
  const token = msg.params._meta?.progressToken
  const result = await tool.run(msg.params.arguments, {
    // With no progressToken from the client, not one progress notification may be sent
    progress: token ? (u) => send({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: token, ...u } }) : undefined,
  })
  send({ jsonrpc: '2.0', id: msg.id, result })
  res.end() // the final response terminates this stream
}

Required headers: protocol version, method name, target name

This revision added a set of required request headers for Streamable HTTP that mirror a few key body fields onto the HTTP headers.

HeaderTaken from the bodyWhen required
MCP-Protocol-Versionthe protocol version in _metaevery POST
Mcp-Methodmethodevery POST
Mcp-Nameparams.name or params.uritools/call, resources/read, prompts/get
httphttp
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather

Why copy them? Because an intermediary should not have to parse the body to route. A gateway, a rate limiter, or an observability probe wanting to split by method name or rate-limit tools/call separately needs only the headers.

The crucial sentence follows: since intermediaries decide by header and the server executes by body, a disagreement between the two is a vulnerability. Picture a gateway configured with "tools/list needs no auth, tools/call does"; an attacker writes tools/list in the header and tools/call in the body and walks around it. So the spec requires that a server processing the body must validate that the header and body values agree, returning 400 Bad Request plus JSON-RPC error code -32020 (HeaderMismatch) when they do not.

JSONJSON
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32020,
    "message": "Header mismatch: Mcp-Name header value 'foo' does not match body value 'bar'"
  }
}

Two more error paths are worth memorizing, because their HTTP status codes differ: a server that does not support the requested protocol version returns 400 plus -32022 (UnsupportedProtocolVersion) with the list of versions it supports; a server that does not implement the method returns 404 Not Found plus -32601. That 404 is a deliberate choice — it lets a client distinguish "this endpoint does not know this method" from "there is no MCP endpoint at this address at all."

Value encoding has one pothole: an HTTP header value may only be visible ASCII, so a tool name in Chinese cannot be represented. The spec gives a sentinel format wrapping the Base64 of the UTF-8 bytes:

TextText
Mcp-Name: =?base64?5p+l5aSp5rCU?=

A server must decode before comparing, or your own validation will call a perfectly normal request a header mismatch. This is the easiest thing to trip over in today's lab.

A word on x-mcp-header while we are here: a server marks a parameter with it in a tool's inputSchema, and the client then mirrors that value into an Mcp-Param-{Name} header so a gateway can route by parameter value (a region, say). It may only be used on primitive fields reachable from the schema root through properties. Do not mark sensitive parameters this way — once a password or a token is in a header, every hop along the way can see it.

Sessions are gone, the GET stream is gone, and stream resumption is gone

If you wrote a remote MCP server following a tutorial online, the three things below are removed in this revision — not deprecated, removed:

Protocol-level sessions and the Mcp-Session-Id header. A server no longer mints session ids and no longer terminates sessions with HTTP DELETE. Returns from listing endpoints must not vary by connection — though they may vary by the authorization the request carries, because a credential is per-request input rather than connection state.

Opening a separate long-lived GET connection. In the previous revision a client opened a GET stream to receive server-initiated messages; this revision has none. To receive change notifications, use subscriptions/listen (covered on day 3), an ordinary request whose response stream simply stays open.

Last-Event-ID stream resumption. A broken stream loses that request, and the client must resend with a new request id. Do not plan on compensating delivery; the protocol layer no longer handles it.

For a server supporting only this revision meeting older client traffic, the spec prescribes definite behavior: a GET or DELETE to the MCP endpoint returns 405 Method Not Allowed; an Mcp-Session-Id header is ignored, neither minted nor echoed; and a Last-Event-ID is ignored too, since streams are not resumable.

Three things a client has to add: manage retries itself (which brings idempotence along, and is why idempotentHint exists among the tool annotations); move subscriptions to subscriptions/listen; and use explicit handles for cross-call state, the shopping basket pattern from day 1.

What if the server needs the client's cooperation

In the previous revision, a server needing the user to fill in a form, or needing to borrow the client's model, initiated a JSON-RPC request to the client. This revision blocks that road: a server must never initiate a JSON-RPC request.

The replacement is called multi round-trip requests (MRTR): the server puts what it needs into the result it returns, the client goes and gets it, and having got it resends the original request.

tools/call id=1 resultType input_requiredinputRequests + requestState pop a form fills in the email tools/call id=2original args + inputResponses + requestState resultType complete attendee email missing User Client Server
Mermaid source
mermaidmermaid
sequenceDiagram
  participant U as User
  participant C as Client
  participant S as Server
  C->>S: tools/call id=1
  Note over S: attendee email missing
  S-->>C: resultType input_required<br/>inputRequests + requestState
  C->>U: pop a form
  U-->>C: fills in the email
  C->>S: tools/call id=2<br/>original args + inputResponses + requestState
  S-->>C: resultType complete

Several rules are worth pinning down. The result's resultType is "input_required", and inputRequests is a map whose keys the server chooses and whose values are one of three request objects: elicitation/create, sampling/createMessage, or roots/list. Having supplied the answers, the client must resend the original request with a different JSON-RPC id (the two are independent requests), placing the answers in inputResponses under matching keys. Only tools/call, resources/read, and prompts/get can receive such a result. A server must not send a type the client never declared in its capabilities — if the other side did not say it can pop a form, you may not ask for one.

requestState is the mechanism's key: an opaque string only the server understands, which the client must not parse, modify, or assume anything about, and must return verbatim on retry. The server signs its context into it and thereby needs no server-side storage and no sticky routing — which is exactly why the stateless design works.

The cost is that the entire security burden sits with the server. The spec is unambiguous: requestState must be treated as attacker-controlled input; whenever it affects authorization, resource access, or business logic, the server must apply integrity protection (HMAC or AEAD) and reject values that fail validation; and it should sign in the authenticated principal, a short expiry, and the originating request identifier, blocking cross-user, timeout, and cross-request replay respectively.

request-state.js
import crypto from 'node:crypto'
 
// The server mints it: stuffing context in means no server-side storage is needed on retry
function sign(payload) {
  const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
  const mac = crypto.createHmac('sha256', SECRET).update(body).digest('base64url')
  return `${body}.${mac}`
}
 
function verify(state, expect) {
  const [body, mac] = state.split('.')
  if (!body || !mac) return null
  const want = crypto.createHmac('sha256', SECRET).update(body).digest('base64url')
  // Constant-time comparison, so returning early per byte cannot leak information
  if (mac.length !== want.length || !crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(want))) return null
 
  const p = JSON.parse(Buffer.from(body, 'base64url').toString())
  if (p.principal !== expect.principal) return null // blocks cross-user replay
  if (p.origin !== expect.origin) return null // blocks taking tool A's state to tool B
  if (p.exp < Date.now()) return null // blocks timeout replay
  return p
}

The spec adds a reminder: the measures above narrow the replay window and block cross-user and cross-request reuse, but do not guarantee single use. A scenario requiring a state to be redeemed exactly once (a one-time discount, say) needs a consumption record on the server.

The OAuth 2.1 layer

Authorization is optional for MCP: the HTTP transport should follow this specification and the stdio transport should not, taking credentials from environment variables instead. That line is drawn clearly, so do not wrestle with OAuth in a local server.

The role mapping fits in one sentence: the MCP server is the OAuth 2.1 resource server and the MCP client is the OAuth 2.1 client, with the authorization server being a third party (co-located with the resource server or independent).

Four actions to remember in the flow. One, discovery: the client accesses with no token, the server returns 401 and points at the protected resource metadata's location in WWW-Authenticate; the server must implement that metadata (RFC 9728) and the client must use it to find the authorization server.

httphttp
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
                         scope="files:read"

Two, carry the resource indicator: the client must implement RFC 8707, sending a resource parameter in both the authorization request and the token request, valued as the MCP server's canonical URI, such as https://mcp.example.com/mcp (no missing scheme, no fragment, and as specific as possible). That step tells the authorization server which service the token is for, so it can write that audience into the token.

Three, audience validation, this section's most important sentence: an MCP server must validate that a token was issued specifically for it and must return 401 when validation fails; and it must accept only tokens valid for its own resource and must not accept or forward any other token.

Four, handling insufficient privilege: discovering at runtime that a scope is insufficient, it should return 403 with error="insufficient_scope" and the required scope, from which the client performs one elevation, and should take the union of the old and new scopes so raising one does not drop another.

Failing to validate the audience has a name of its own — token passthrough — and the spec lists it as an explicitly forbidden anti-pattern. It is bad in three ways: it bypasses security controls — rate limiting, request validation, and traffic monitoring usually hang off the premise that a token was issued to me, and a client bringing a token from elsewhere makes all of them idle; it breaks the audit chain — the server cannot tell which client is calling, and the identity in downstream logs is not the server that genuinely forwarded it, so after an incident nobody can reconstruct the scene; and it punches through the trust boundary — downstream grants trust on the premise that only the upstream service could hold this token, so once one service is compromised the attacker can move sideways with the same token.

Container deployment and scaling out

Back to the third question from the first section: how many of me there are.

Statelessness pays out in full here: nothing at the protocol level needs sharing between replicas. No session table, no sticky routing, no connection affinity configuration. One client sending three requests to three different replicas gets identical results. That is this revision's most tangible benefit, and the reason it is worth paying for repeating the version and capability blocks in every request.

YAMLYAML
services:
  mcp-a:
    build: .
    ports: ['3034:3034']
    environment:
      # The only thing that must be shared is the signing key: a requestState signed by A must verify on B
      REQUEST_STATE_SECRET: shared-dev-secret
    stop_grace_period: 10s
  mcp-b:
    build: .
    ports: ['4034:3034']
    environment:
      REQUEST_STATE_SECRET: shared-dev-secret
    stop_grace_period: 10s

Three operational matters to handle in passing. The health check needs its own path and cannot hit the MCP endpoint, which accepts only POST and answers a GET with 405. Graceful shutdown has to actually wait: on SIGTERM, stop accepting new connections first and then let in-flight requests finish; an MCP tool call routinely takes tens of seconds, so stop_grace_period must exceed the longest tool. Bind 0.0.0.0 inside a container and 127.0.0.1 on bare metal, and do not mix the two up.

One pothole to close on: ordinary requests need no connection affinity, but that long-lived subscriptions/listen stream is still a stateful connection — when it drops, the client has to resubscribe. So the load balancer's idle timeout must exceed the keepalive interval, and a rolling deploy has to accept that subscriptions break once. Stateless describes the protocol, not the TCP connection.

Source Reading

Hands-On Lab

🧪 D4 lab: a containerizable Streamable HTTP MCP server

Code location: labs/mcp-7days/day-04-streamable-http-deploy

Acceptance criteria:

  1. All 4 exercise points in starter are completed, and MOCK=1 SELFTEST=1 pnpm start shows 8 of 8 self-checks green with exit code 0
  2. The slow tool item shows content-type as text/event-stream with at least 2 notifications/progress received before the final response
  3. A missing MCP-Protocol-Version header, and an Mcp-Method header disagreeing with the body, both return 400 with -32020
  4. The meeting-booking tool returns resultType of input_required on the first round, and after the client retries with inputResponses, the verbatim requestState, and a different JSON-RPC id, the final result arrives
  5. With two replicas up under compose, the same tools/call gets identical results against 3034 and 4034

Today's lab deliberately avoids the official SDK: the JavaScript SDK through 1.30.0 still implements the previous transport, and following it would teach you sessions and the GET stream all over again. The hand-written endpoint is only about two hundred lines with comments, and every MUST in the spec maps onto it at a glance. Run as-is, starter shows 3 green and 5 red, and your job is turning those 5 green.

  1. Read the solution's server.ts first, finding origin validation, header validation, and the two response branches for JSON and the event stream.
  2. Complete the header-body consistency check in the starter's checkHeaders, run the self-test, and watch items 3 and 4 go from red to green.
  3. Complete the event stream branch, send one slow tool call by hand with curl and -N, and watch the progress appear one line at a time rather than all at the end.
  4. Complete the meeting-booking tool's multi-round-trip return and then the requestState signature verification, turning items 7 and 8 green.
  5. Bring up two replicas with the compose file in the lab root, send the same curl to 3034 and 4034, and confirm identical returns.

Interview Questions

Today's 3 questions are in the question bank below, weighted toward the trade-offs of stateless transport, header-body consistency, and token audience validation. Expand each one and read the analysis before the key points — practicing the derivation beats memorizing bullets. The high-frequency labels for the domestic and overseas markets are there so you can pick by target market.

Checklist and Tomorrow

  • Implement a Streamable HTTP server with a single POST endpoint that returns JSON or an event stream as needed
  • State how a client must change now that sessions, the GET stream, and stream resumption are gone in this version
  • Explain the token audience validation an MCP server must do as a protected resource, and what happens if it doesn't
  • Write the three required headers from memory, and which error code a header-body mismatch returns
  • All 5 acceptance criteria of the lab pass
  • Answer at least 2 of the 3 interview questions without looking at the key points

Tomorrow (D5) we move to the other side of the table and hand-write an MCP client. The order is again deliberate: only after implementing the server's two response shapes, the required headers, and multi round-trip requests do you know how many situations the client side must handle. Today you wrote what a server may return; tomorrow you write what a client must catch every one of — including the name collision that inevitably appears when merging several servers' tools for the model.

Interview questions

  • The 2026-07-28 revision removed protocol-level sessions. How should a remote server that needs cross-call state — a shopping cart, a database transaction — be designed?2026-07-28 去掉了协议级会话。那一个需要跨调用保存状态的远程服务端——比如购物车、数据库事务——应该怎么设计?
    Common in ChinaCommon overseasIntermediate#statelessness#api-design

    How to reason about it · think before answering

    1. The screen is whether you treat statelessness as a design constraint. Answering 'use Mcp-Session-Id' fails immediately — that header was removed. So does 'keep it in server memory keyed by connection', since clients are not required to reuse connections.
    2. Give the structure first: state must travel with the client, and the server trusts only what arrives in the request. Two concrete shapes — a server-minted explicit handle returned by a creation tool and passed back as an ordinary tool argument, or a signed opaque blob like the requestState used by multi round-trip requests.
    3. The difference is who stores the data. A handle is just a primary key into server-side storage; a requestState encodes the context itself, so the server stores nothing. Handles suit long-lived business objects, requestState suits continuing a single interaction.
    4. Conclusion: either way the server keeps nothing per client in memory, so any replica can serve any request and scaling needs no sticky routing — which is exactly what the change was buying.
    5. Volunteer the security half: a handle is a name, not a credential. Generate it from a secure random source, bind it server-side to the authenticated principal (key storage as user id plus handle), expire it, and re-authorize on every call — the spec says possession of a handle must not be treated as authentication. requestState passes through the client, so it is attacker-controlled input and must be integrity-protected with HMAC or AEAD, carrying the principal, an originating-request identifier, and a short expiry.
    6. Likely follow-up: what about requestState across replicas? Share the signing key; it is still stateless because the state lives with the client and replicas only verify. A second follow-up is single use — signing bounds the replay window but does not guarantee one-time consumption, which needs a server-side redemption record.

    分析过程 · 先想清楚再作答

    1. 这题在筛「有没有把无状态当成设计约束」。答「用 Mcp-Session-Id 头」的当场出局,那个头这一版已经删了;答「存在服务端内存里按连接查」的同样出局,因为客户端根本不保证复用连接。
    2. 先给结构:状态必须由客户端携带,服务端只认请求里带来的东西。落地成两种形态——一是服务端铸造的显式句柄,创建工具返回一个 id,后续调用把它当普通工具参数传回来;二是签过名的不透明状态串,比如多轮请求里的 requestState,服务端把上下文签进去,重试时原样收回。
    3. 两者的差别在于「谁存数据」:句柄背后的购物车内容还是存在服务端的库里,句柄只是主键;requestState 是把上下文本身编码进字符串,服务端零存储。前者适合长期存在的业务对象,后者适合一次交互内的续接。
    4. 结论:不管哪种,服务端内存里都不为某个客户端留东西,所以任何副本都能处理任何请求,扩容不需要粘性路由——这正是这次改动想换来的东西。
    5. 安全是必须主动补的一句:句柄是名字不是凭证。要用安全随机数生成、绑定到已认证的主体(按 user_id 加 handle 做键)、设过期时间,并且每次调用重新校验调用者身份。规范明确写了服务端不得把持有句柄当成身份认证。requestState 同理,它经客户端转手,是攻击者可控输入,必须 HMAC 或 AEAD 验签,并把主体、原请求标识、短过期签进去。
    6. 可预期的追问:多副本时 requestState 怎么办?答案是所有副本共享签名密钥即可,这仍然是无状态的——状态在客户端手里,副本只负责验签。追问二可能是「怎么保证一次性」,答案是签名只能缩小重放窗口,真要单次消费得自己在服务端加一层消费记录。

    Key points

    • State travels with the client: the server mints an explicit handle that later calls pass back as an ordinary tool argument
    • Within one interaction, a signed opaque blob works with zero server storage; replicas just share the signing key
    • A handle is not a credential: securely random, bound to the authenticated principal, expiring, re-authorized on every call
    • The payoff is that any replica serves any request, so scaling needs no sticky routing and retries are cheap

    答题要点

    • 状态必须由客户端携带:服务端铸造显式句柄,作为普通工具参数在后续调用里传回
    • 一次交互内的续接可以用签名的不透明状态串,服务端零存储,多副本共享签名密钥即可
    • 句柄不是凭证:安全随机生成、绑定已认证主体、设过期,每次调用重新鉴权
    • 收益是任何副本能处理任何请求,扩容不需要粘性路由,重启后重发即可
  • Streamable HTTP requires the Mcp-Method header to match the method in the request body. Why mirror it at all, and what breaks if the server does not validate the match?Streamable HTTP 要求 Mcp-Method 头必须和请求体里的 method 一致。为什么要抄一遍?不校验会有什么风险?
    Common in ChinaCommon overseasIntermediate#transport#security

    How to reason about it · think before answering

    1. The real question is the second half. 'It helps gateways route' is half an answer; the interviewer is waiting for a concrete attack, which separates having read the spec from having understood it.
    2. Why mirror: intermediaries should not parse the body to make decisions. A load balancer routing by method, a rate limiter capping tools/call, an observability probe tagging spans — all can read a header instead of deserializing tens of kilobytes. The same applies to Mcp-Name (from params.name or params.uri) and MCP-Protocol-Version.
    3. Then derive the risk: if intermediaries decide on the header and the server executes on the body, there are two sources of truth. Concretely, a gateway configured as 'tools/list is unauthenticated, tools/call is authenticated' is bypassed by sending the header as tools/list and the body as tools/call. The same trick evades rate limits, audit tagging, and per-parameter regional isolation.
    4. Conclusion: the spec therefore requires any server that processes the body to validate the match and reject with 400 plus -32020 (HeaderMismatch). It is not pedantry — it collapses two sources of truth back into one.
    5. Volunteer the implementation trap: header values are visible ASCII only, so non-ASCII tool names or resource URIs use the =?base64?...?= sentinel, and the server must decode before comparing or its own check will reject valid requests. Integer values should be compared numerically, not as strings.
    6. Likely follow-up: should intermediaries validate too? The spec advises that any intermediary enforcing policy from mirrored headers first confirm MCP-Protocol-Version names a revision that mandates header-body validation, and otherwise reject rather than trust unvalidated headers.

    分析过程 · 先想清楚再作答

    1. 这题的题眼在后半句。只答「方便网关路由」是答了一半,面试官等的是「不一致会怎样」——能不能自己举出攻击场景,是区分「读过规范」和「理解规范」的地方。
    2. 先说为什么镜像:中间层不该为了做决策去解析请求体。负载均衡想按方法分流、限流器想给 tools/call 单独设阈值、可观测探针想打标签,只看头就够了,不用把几十 KB 的 body 反序列化一遍。同理还有 Mcp-Name(取自 params.name 或 params.uri)和 MCP-Protocol-Version。
    3. 再推风险:既然中间层按头决策、服务端按体执行,两个事实来源就分叉了。举个具体的:网关配了「tools/list 免鉴权、tools/call 要鉴权」,攻击者把头写成 tools/list、体写成 tools/call,鉴权就被绕过去了。同样的套路可以绕限流、绕审计、绕按参数值做的地域隔离。
    4. 结论:所以规范规定处理请求体的服务端必须校验头体一致,不一致必须回 400 加 -32020(HeaderMismatch)。这不是格式洁癖,是把「两个事实来源」重新合并成一个。
    5. 实现上有个坑值得主动说:头值只能是可见 ASCII,非 ASCII 的工具名或资源 URI 要用 =?base64?...?= 哨兵格式编码,服务端必须先解码再比对,否则自己的校验会把正常请求判成不一致。整数值应当按数值比较而不是按字符串比较。
    6. 可预期的追问:中间层自己要不要校验?规范建议按头做策略的中间层先确认 MCP-Protocol-Version 指向的是一个要求头体校验的版本,版本更老或头缺失时应当直接拒绝,而不是信任未经校验的头值。

    Key points

    • Mirroring lets gateways, rate limiters, and probes route and tag without parsing the body
    • Skipping validation creates two sources of truth: header tools/list with body tools/call bypasses per-method auth and limits
    • The spec requires any body-processing server to validate the match and return 400 with -32020 on mismatch
    • Non-ASCII values use the base64 sentinel, so decode before comparing; compare integers numerically

    答题要点

    • 镜像是为了让网关、限流器、探针不用解析请求体就能路由和打标签
    • 不校验就有两个事实来源:头写 tools/list、体写 tools/call 可以绕过按方法配置的鉴权与限流
    • 规范要求处理请求体的服务端必须校验一致性,不一致回 400 与 -32020
    • 非 ASCII 值用 base64 哨兵格式,服务端必须先解码再比对;整数按数值比较
  • Why must an MCP server never forward the client's access token straight to a downstream API?为什么 MCP 服务端绝对不能把客户端给的访问令牌直接转发给下游 API?
    Common in ChinaCommon overseasDeep dive#oauth#security

    How to reason about it · think before answering

    1. This probes your instinct for trust boundaries. 'It is insecure' is empty; the spec names this anti-pattern token passthrough and forbids it, so you need the three concrete failure modes.
    2. Set up the premise: in the authorization model an MCP server is an OAuth 2.1 resource server. It must validate that tokens were issued with itself as the audience — clients make that possible via the RFC 8707 resource parameter — and must accept only tokens valid for its own resources, accepting or transiting nothing else.
    3. Derive the harm by asking whose assumption breaks. First, security controls are circumvented: rate limiting, request validation, and traffic monitoring hang off 'this token was issued to me', and a token minted elsewhere makes them no-ops. Second, the audit trail breaks: the server cannot distinguish clients when the upstream token is opaque to it, downstream logs show an identity that is not the forwarding server, and a thief of a stolen token can use the server as an exfiltration proxy. Third, the trust boundary is punctured: downstream grants trust on the assumption that only the upstream service holds the token, so one compromise travels sideways.
    4. Conclusion: to call downstream, the server must obtain its own credential as an OAuth client, fully isolated from the token the client presented to it.
    5. Give the correct pattern too: for third-party access on the user's behalf, use URL-mode elicitation so the user authorizes the third party directly in a browser, and the server stores those tokens bound to the authenticated user identity. The spec requires third-party credentials never to transit the MCP client.
    6. Likely follow-up: how does this relate to the confused deputy? Token passthrough is the downstream consequence of failed audience validation, while the confused deputy is authorization-code hijacking caused by a proxy server combining a static client id with skipped per-client consent. Both come from a server acting for someone without confirming who that someone is.

    分析过程 · 先想清楚再作答

    1. 这题在考安全边界的直觉。答「不安全」「会泄露」是空话;规范给这个反模式起了名字叫令牌转发(token passthrough),并明令禁止,能说出它坏在哪三处才算过关。
    2. 先把前提说清:MCP 服务端在授权体系里是 OAuth 2.1 的资源服务器,它必须校验收到的令牌受众就是自己(客户端靠 RFC 8707 的 resource 参数让授权服务器把受众写进令牌),并且必须只接受对自己资源有效的令牌,不得接受或转接其它令牌。
    3. 拆危害的角度是「谁的假设被打破了」。第一,绕过安全控制:限流、请求校验、流量监控往往挂在「这个令牌是发给我的」这个前提上,客户端拿着别处的令牌直连或经服务端转发,这些控制全空转。第二,审计链断裂:服务端分不清是哪个客户端在调(上游令牌对它可能是不透明的),下游日志里的身份又不是真正在转发的那个服务端,出事之后没人能还原现场;持有失窃令牌的人还能把服务端当成数据外泄的代理。第三,信任边界被打穿:下游是按「只有上游那个服务能拿到这个令牌」授信的,一旦某个服务被攻破,同一个令牌就能横着走。
    4. 结论:服务端要访问下游,就得自己作为 OAuth 客户端去拿一份属于自己的凭证,和客户端给自己的令牌完全隔离。
    5. 正确做法要一起说:需要代表用户访问第三方时走 URL 模式的补充输入,让用户在浏览器里直接和第三方完成授权,服务端把第三方令牌存在自己这边并绑定到已认证的用户身份。规范要求第三方凭证不得经由 MCP 客户端传输。
    6. 可预期的追问:那和混淆代理是什么关系?令牌转发是受众校验失败的下游后果,混淆代理是代理型服务端用静态 client id 加上跳过按客户端的同意确认造成的授权码劫持——两者都源于「服务端替别人做决定却没确认这个别人是谁」。这一条第 6 天会展开。

    Key points

    • An MCP server is an OAuth 2.1 resource server: it must validate that it is the token audience and must not accept or transit other tokens
    • Forwarding bypasses rate limiting, request validation, and monitoring that assume audience-bound tokens
    • The audit trail breaks: the server cannot identify callers, downstream sees the wrong identity, and the server can become an exfiltration proxy
    • The correct pattern is for the server to obtain its own downstream credential as an OAuth client, with third-party credentials never transiting the MCP client

    答题要点

    • MCP 服务端是 OAuth 2.1 资源服务器,必须校验令牌受众是自己,不得接受或转接其它令牌
    • 转发会绕过挂在受众上的限流、请求校验与流量监控
    • 审计链断裂:服务端分不清调用方,下游看到的身份也不是真正的转发者,还可能被当成外泄代理
    • 正确做法是服务端自己作为 OAuth 客户端取下游凭证,第三方凭证绝不经由 MCP 客户端

Comments