Dayward AI
Week 1 · D3About 5 hours

Resources and Prompts: URI Templates, Change Notifications, Progress and Logging, Pagination, and Client Capabilities

Turn read-only data into resources and repeated question patterns into prompts, then round out URI templates, pagination, progress, subscriptions, and caching — the things that keep a server usable at real data volumes.

Today's goals 0/3

Sign in to tick these off and save your progress.

Today's Goals

  1. Judge whether a piece of data should become a resource or a tool, and state the reasoning
  2. Expose a set of parameterized resources with a URI template, and implement cursor pagination for a list endpoint
  3. Explain the difference between a subscription stream and an in-request notification, and give a use case for each

Yesterday your server had only tools, two of them, equally fast for anyone. Today a realistic volume of data arrives — several thousand notes — and you immediately hit three walls: pagination, caching, and notifications. D1 already took the full message skeleton apart, so today looks only at the new fields. Come back and tick off the three goals above.

Plain-Language Walkthrough

Resources are application-driven: who decides whether one enters the context

Back to the adapter analogy. A tool is like the switch on an appliance: the model reads the manual and presses it. A resource is different, more like the label on a power strip — the host application or the user glances at it and decides whether that socket gets power.

The spec is blunt about this: resources are application-driven, with the host application deciding how to fold them into the context. The typical interface form is a file chooser, a directory tree, or a search box; the app can also attach them automatically by heuristic. A tool, by contrast, is model-controlled, with the model picking from the description itself.

So which form a piece of data takes is decided not by what it is but by who decides whether to use it this time:

SituationMake it a
Read-only, enumerable, and you want the user to pick it in the interfaceResource
Has side effects, or the model should judge when to use itTool
Needs one retrieval producing results rather than list-then-readTool (even though it is read-only)

That last row is the most easily confused. Searching code looks read-only and resource-like, but it cannot be enumerated — you cannot list every possible query for a user to choose from. Whether it can be listed is the most practical dividing line between a resource and a tool.

That line also has a benefit nobody says out loud and everybody feels: a resource that does not enter the context costs nothing. A tool's definition goes into the request every turn whether used or not; a resource is one catalog entry, occupying not a single token until the host selects it. A knowledge base of three thousand documents made into three thousand tools would blow the context outright, and as resources it costs only when the user picks one.

Which raises the question: three thousand notes, and resources/list returns them all at once?

URI templates: one definition for a whole family of resources

The answer is of course no, but before pagination there is an earlier problem to settle: some resources cannot be listed exhaustively and are nonetheless a genuinely enumerable family.

Reading notes by tag, say. There are dozens of tags with several notes each, hundreds or thousands of combinations, and listing them all is slow and unread. The spec's answer is a resource template: resources/templates/list returns a RFC 6570 URI template with the varying part left as a placeholder.

JSONJSON
{
  "resourceTemplates": [
    {
      "uriTemplate": "notes:///{tag}/{slug}",
      "name": "note-by-tag",
      "title": "Read one note by tag",
      "mimeType": "text/markdown"
    }
  ],
  "ttlMs": 300000,
  "cacheScope": "public"
}

Given that template, a client knows notes:///mcp/pagination is a valid resource address without it appearing in any list. Placeholders can also be wired to a completion endpoint so the user gets suggestions while typing in the interface.

On choosing a URI scheme, the spec offers a few ready-made ones: file:// for things that behave like a file system (not necessarily actual files); git:// for version control; and https:// with one special restriction — use it only when the client can fetch it from the network itself. If the content actually has to go through the server, do not use https and define another scheme. A custom scheme need only conform to RFC 3986, and this course's lab uses notes:///.

A custom scheme is easy to write and has one hole you must plug yourself:

Pagination is mandatory: cursors are opaque and page size is the server's call

Back to those three thousand notes. MCP's pagination uses an opaque cursor, not a page number. There are only a few rules and every one of them gets tripped over:

  • A cursor is an opaque string, and the client must not parse it, modify it, or make any judgment from its contents.
  • Page size is the server's decision, and the client must not assume it is fixed.
  • Only a missing nextCursor means the end. An empty string is a perfectly valid cursor, and treating it as the end is explicitly forbidden.
  • An invalid cursor should return -32602 (Invalid params) rather than silently returning the first page.

Four operations support pagination: resources/list, resources/templates/list, prompts/list, and tools/list.

The server may encode the cursor however it likes — a base64-wrapped offset, a database primary key, a timestamp — because the other side is not allowed to look:

pagination.ts
const PAGE_SIZE = 50
 
export function encodeCursor(offset: number): string {
  // The base64 wrapper is not for secrecy but to stop the client from casually parsing it
  return Buffer.from(`offset:${offset}`, 'utf8').toString('base64url')
}
 
export function decodeCursor(cursor: string | undefined): number {
  if (cursor === undefined) return 0 // only undefined means "no cursor"
  const decoded = Buffer.from(cursor, 'base64url').toString('utf8')
  const match = /^offset:(\d+)$/.exec(decoded)
  if (!match) throw new InvalidCursorError(cursor) // an invalid cursor errors; never silently return page one
  return Number(match[1])
}
 
export function paginate(all: string[], cursor: string | undefined) {
  const offset = decodeCursor(cursor)
  const items = all.slice(offset, offset + PAGE_SIZE)
  const nextOffset = offset + items.length
  // Send nextCursor only when there genuinely is a next page; its absence is the end signal
  return nextOffset < all.length ? { items, nextCursor: encodeCursor(nextOffset) } : { items }
}

The engineering cost hides somewhere inconspicuous: an offset cursor requires a stable list order. readdir does not return a consistent order across file systems, and inserting a note midway shifts every later offset, so a client turning to page two misses or repeats entries. So either sort first, or encode the cursor as the previous entry's primary key rather than an offset. Today's lab sorts and uses offsets, which is adequate and readable, but you should know its precondition.

The caching hints ttlMs and cacheScope

The 2026-07-28 revision added a CacheableResult interface making two fields required: the results of tools/list, prompts/list, resources/list, resources/read, and resources/templates/list all carry ttlMs and cacheScope.

ttlMs is a freshness hint in milliseconds telling the client how long this may be cached, with the aim of reducing polling. cacheScope has only two values: public means a shared intermediary (a gateway, a proxy) may also cache the response, and private means it may be cached only in this client.

The intuition in use: catalogs change slowly and bodies change fast, so their ttls should differ by an order of magnitude; and anything tied to the caller's identity is always private — a tool list may vary with authorization scope, so it cannot be public.

JSONJSON
{
  "resources": [{ "uri": "notes:///pagination", "name": "pagination", "mimeType": "text/markdown" }],
  "nextCursor": "b2Zmc2V0OjM",
  "ttlMs": 300000,
  "cacheScope": "public"
}

These caching hints and the listChanged notification are complementary rather than alternatives: ttl governs how long you may safely use something absent a notification, and the notification says it changed. Give both and a client can avoid frequent polling without holding stale data.

Incidentally, the spec also recommends that tools/list should return a deterministic order — the same set of tools should not shuffle between requests. The reason is not aesthetics: a stable order is what lets a client dare cache the tool list, and the tool list usually goes into the model's context, so a shifting order invalidates the prompt cache wholesale, which is real money.

Prompts: turning the sentence your team types to death into a slash command

The third primitive is the prompt. It is user-controlled: selected explicitly by the user, typically as a slash command in the chat box.

It solves a plain and very real problem: every team has a few sentences typed over and over — "review this code for me, focusing on concurrency safety," "translate this error into plain language and give three lines of investigation." Everyone keeps their own copy at a different version. As a prompt it travels with the server, and one edit updates everybody.

prompts/get returns a set of messages whose role is only user or assistant, with content being text, an image, audio, a resource link (resource_link), or an embedded resource (resource). There is a design choice here worth chewing on:

digest-prompt.ts
// A prompt that digests notes by tag
async function getDigestPrompt(tag: string) {
  const hits = await notesByTag(tag)
  return {
    description: `${hits.length} notes under the tag ${tag}`,
    messages: [
      { role: 'user', content: { type: 'text', text: `Digest the notes below into under 200 words of key points.` } },
      // resource_link rather than embedded bodies: let the host decide whether to actually read them,
      // so whether a body enters the context is the app's call — application-driven resources at the message level
      ...hits.map((n) => ({
        role: 'user' as const,
        content: { type: 'resource_link' as const, uri: `notes:///${n.slug}`, name: n.slug },
      })),
    ],
  }
}

Returning links rather than bodies hands the decision about spending those tokens back to the host. Embed them directly and a tag matching thirty notes floods in tens of thousands of tokens when the user may want only two of them. Error handling here is simple: an invalid prompt name or a missing required argument both use -32602, and an internal server failure uses -32603.

Two notification channels: the subscription stream for lasting changes, the response stream for this call's progress

This section differs most from the older documentation, and any material online discussing resources/subscribe can now be skipped outright.

The 2026-07-28 revision replaced the two methods resources/subscribe and resources/unsubscribe, along with that separate long-lived GET connection over HTTP, with a single subscriptions/listen. It is an ordinary request whose response happens to be a permanently open notification stream. The client explicitly opts into the categories it wants in a notifications filter, and the server must not push a type that was not opted into:

JSONJSON
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "subscriptions/listen",
  "params": {
    "notifications": {
      "toolsListChanged": true,
      "resourceSubscriptions": ["notes:///pagination"]
    }
  }
}

The four optional fields are toolsListChanged, promptsListChanged, resourcesListChanged, and resourceSubscriptions (an array of URIs). The stream's first message must be notifications/subscriptions/acknowledged, carrying the subset the server actually agreed to — unsupported types are quietly dropped, so the client should compare it against what it requested. Every notification thereafter carries io.modelcontextprotocol/subscriptionId in _meta, valued as the JSON-RPC id of that original subscriptions/listen request. Over stdio every message shares one channel, so the client must use that field to tell which subscription a notification came from.

The other channel is entirely different: an in-request notification travels only on the response stream of the request it belongs to. notifications/progress and notifications/message are of this kind, and they never appear on a subscription stream. Progress is enabled by the client putting a progressToken in the request's _meta, after which the server may send notifications carrying progress (which must increase), plus optional total and message:

JSONJSON
{
  "jsonrpc": "2.0",
  "method": "notifications/progress",
  "params": { "progressToken": "abc123", "progress": 50, "total": 100, "message": "building the index" }
}

Remember the division in one line: the subscription stream answers "has the world changed," and the response stream answers "how far along is my one call." The former spans requests and lasts; the latter is born and dies with its request.

On logging there is a change you must know: the Logging feature, along with Roots and Sampling, was marked deprecated in this revision, with a deprecation window of at least twelve months, and new implementations should not adopt it. The logging/setLevel method has been removed, the log level is now specified per request in _meta.io.modelcontextprotocol/logLevel, and a server must not send notifications/message for a request that did not carry that field. The recommended migration path is writing to stderr on stdio, or wiring up OpenTelemetry.

Client capabilities: why a server cannot assume the other side can pop a form

The last piece. A server wanting the user to fill something in, or wanting to borrow the client's model for some inference, requires that the other side has that capability. The spec is hard here: a server must not rely on a capability the client did not declare, and when it genuinely needs one that was not declared it must return -32021 (MissingRequiredClientCapability) listing what is missing in data.requiredCapabilities.

In this revision the client capabilities are mainly elicitation — popping a form to ask the user. The declaration looks like this:

JSONJSON
{
  "_meta": {
    "io.modelcontextprotocol/clientCapabilities": {
      "elicitation": { "form": {}, "url": {} }
    }
  }
}

Two modes: form collects structured data inside the client's interface, with the schema deliberately restricted to a flat object of basic types (string, number, boolean, enum) and no complex nesting, so any client can generate a form automatically. url sends the user to the server's own secure page. The spec mandates that passwords, API keys, access tokens, and payment credentials must not be collected in form mode and must go through url mode — because data collected by a form passes through the client, while in url mode sensitive information flows only between the user's browser and the server. For backward compatibility, an empty elicitation: {} is equivalent to supporting form only.

Roots and Sampling are deprecated, so in designing a server today the client capability you can count on is essentially elicitation alone. Assume by default that the other side can do nothing, make extra input a tool argument for the model to fill in, and fall back to elicitation only when that fails — that is the safest posture for writing an MCP server in 2026.

As for the general strategy when there are too many tools and resources to fit the context, that is another course's home turf; when you need it, see managing context for tool results and retrieval. This course is responsible only for covering MCP's own layer of interfaces cleanly.

Source Reading

Hands-On Lab

🧪 D3 lab: an MCP server exposing a local Markdown note library as resources

Code location: labs/mcp-7days/day-03-notes-resource-server

Acceptance criteria:

  1. resources/list can page through every note, page two does not repeat page one, and the last page carries no nextCursor
  2. An invalid cursor is rejected rather than silently returning the first page
  3. resources/templates/list returns a resource template with a uriTemplate, and resources/read can read a note's body
  4. prompts/get filters notes by tag and attaches a resource_link for each one
  5. MOCK=1 SELFTEST=1 pnpm start shows six of six green and exits 0

The lab is entirely offline over the eight Markdown files in the repository and needs no key. The starter as-is passes 1 of 6, with the four exercise points each mapping to several red items — turning the red ones green one at a time is all of today's work. After the first exercise you hit a phenomenon worth chewing on: the catalog count is right and paging spins in place. The cause is that the starter's cursor encoder returns an empty string and its decoder always returns 0, and an empty string is a valid cursor that does not mean the end — a live demonstration of this chapter's rule. When stuck, go back to the pagination section.

  1. Open the solution's server.ts, find the resources/list and resources/templates/list handlers, and see what a static catalog and a URI template each look like.
  2. Complete scanNotes in the starter's notes.ts, run the self-test once, and watch item 1's count go from 1 to the real note count.
  3. Complete the cursor encoding and decoding in pagination.ts, and run the self-test to confirm item 2 goes green along with item 6.
  4. Complete the resource template and the tag-search prompt in server.ts to turn items 4 and 5 green.
  5. Run it resident with MOCK=1 pnpm start, attach your own client or Inspector, and see ttlMs and cacheScope appear in the responses for yourself.

Interview Questions

Today's 3 questions are in the question bank below, weighted toward the boundary between resources and tools, URI design, pagination and caching, and the division between notification channels. 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

  • Judge whether a piece of data should become a resource or a tool, and state the reasoning
  • Expose a set of parameterized resources with a URI template, and implement cursor pagination for a list endpoint
  • Explain the difference between a subscription stream and an in-request notification, and give a use case for each
  • Say what ttlMs and cacheScope each govern, and how they relate to listChanged
  • Know that Roots, Sampling, and Logging are deprecated, and each one's recommended migration path
  • All 5 acceptance criteria of the lab pass
  • Answer at least 2 of the 3 interview questions without looking at the key points

Tomorrow (D4) moves the server onto the public internet. The order is deliberate: everything in the first three days ran in a local child process, where stdio blocked most of the transport potholes; switch to HTTP and authentication, replicas, origin validation, and timeouts all appear at once — and this revision happens to have removed sessions, the long-lived GET, and stream resumption, so a client has more to fill in than you expect. Today's pagination and caching show their value immediately there: statelessness plus cacheable catalogs is the precondition for a multi-replica deployment scaling sideways.

Interview questions

  • For the same data, what is the difference between exposing it as an MCP resource versus a tool, and how do you choose?同一份数据,做成 MCP 资源和做成工具有什么区别?你按什么标准选?
    Common in ChinaCommon overseasBasic#primitives#server-design

    How to reason about it · think before answering

    1. This screens for real server design experience. Saying resources are read-only and tools mutate scores a pass at best, because read-only search still belongs in a tool.
    2. Reframe it: do not ask what the data is, ask who decides to use it this time. The spec makes resources application-driven, picked by the host or the user, while tools are model-controlled. Fixing the controller also fixes who is accountable when it goes wrong.
    3. Add the practical test: enumerability. A resource has to appear in a paginated list a human can pick from, so a code search with an unbounded input space must be a tool even though it never writes anything.
    4. Conclusion: read-only, enumerable, user-selectable becomes a resource; side-effecting, model-timed, or non-enumerable becomes a tool.
    5. Bring up cost unprompted: tool definitions ship on every turn whether used or not, while an unselected resource costs zero tokens. Three thousand documents as three thousand tools blows up the context window; as resources they are pay-per-use.
    6. Likely follow-up: where do prompts fit? They are the third primitive, user-selected and usually surfaced as slash commands — the three differ only by who controls them.

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

    1. 这题在筛「有没有真的设计过服务端」。答成「资源是只读的、工具会改数据」只能算及格,因为只读的检索照样该做成工具,区分度全在这一步。
    2. 拆法:不要问「它是什么」,问「这一次由谁决定用不用它」。规范把资源定成应用驱动——由宿主应用或用户挑;工具是模型控制——模型看着描述自己调。控制方定了,出错时该找谁负责也就定了。
    3. 再补一条更实用的判据:能不能被枚举。资源要出现在一张可翻页的清单里让人挑,所以「搜索代码」这种输入空间无限的能力,哪怕完全只读也必须做成工具。
    4. 结论:只读、可枚举、希望用户在界面上挑的做成资源;有副作用、或需要模型自己判断时机、或无法枚举的做成工具。
    5. 生产视角要主动加一句成本:工具定义不管用不用,每轮都要塞进请求;资源不被选中就一个 token 都不占。三千篇文档做成三千个工具会直接撑爆上下文,做成资源则按需付费。
    6. 可预期的追问:那提示模板算第几种?答案是第三种,由用户显式选中,典型形态是斜杠命令——三种原语的差别只在控制方,不在能力。

    Key points

    • Resources are application-driven and picked by host or user; tools are model-controlled and chosen from their descriptions
    • Enumerability is the practical dividing line: unbounded-input capabilities like search stay tools even when read-only
    • Cost-wise tool definitions occupy context every turn while unselected resources cost nothing, so large corpora must be resources
    • The controller determines accountability: bad tool choice means bad descriptions, bad prompt choice means bad naming, bad resource injection is a product problem

    答题要点

    • 资源是应用驱动的,由宿主或用户挑;工具是模型控制的,由模型看描述自己调
    • 能不能枚举是最实用的分界线:搜索这类输入空间无限的能力即使只读也做成工具
    • 成本上工具定义每轮都占上下文,资源不被选中就不花钱,大规模知识库必须走资源
    • 控制方决定了出错时找谁负责:模型选错是描述问题,用户选错是命名问题,应用塞错是产品问题
  • Why must MCP pagination cursors be opaque, and what breaks if a client parses them?MCP 的分页游标为什么必须是不透明的?如果客户端去解析它,会出什么问题?
    Common in ChinaCommon overseasIntermediate#pagination#api-design

    How to reason about it · think before answering

    1. It looks like a spec-recitation question but really tests whether you have shipped a paginated public API. Quoting the rule earns nothing; naming the concrete failure does.
    2. Start from what a cursor holds. A server may encode an offset, a primary key, a timestamp, or encrypted state, and it may change that at any time. A client that parses one format breaks everywhere the day the server switches, because parsing turned an internal detail into a public contract.
    3. Second failure is forgery. A client that fabricates offset:9999 bypasses the server's control over paging range, and if the cursor encodes filters or permissions, forging it is a privilege escalation.
    4. Third and nastiest: treating an empty string as the end. The spec is explicit that only a missing nextCursor ends the sequence; an empty string is a valid cursor. Getting this wrong silently drops the last page with no error, which tests rarely catch.
    5. Conclusion: a client may make exactly one judgment about a cursor — whether nextCursor is present. Page size likewise must not be assumed fixed. Servers should reject invalid cursors with -32602 rather than silently returning page one, which would loop the client forever.
    6. Likely follow-up: what bites the server side? Offset cursors require a stable ordering, since an insertion shifts everything after it, so either sort first or encode the last item's key instead.

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

    1. 这题表面考规范条文,实际考「有没有做过带分页的对外接口」。只背出「规范说不透明」拿不到分,要能说出解析之后具体哪一步会崩。
    2. 拆法:先问游标里到底装的是什么。服务端可以装偏移量、主键、时间戳、甚至一段加密状态,而且**换实现时它随时会变**。客户端一旦按某种格式解析,服务端从偏移量换成主键那天,所有客户端一起挂——这是把服务端的内部实现变成了公开契约。
    3. 第二个坑是伪造。客户端自己造一个 offset:9999 递给服务端,等于绕过了服务端对翻页范围的控制;如果游标里编了权限或过滤条件,伪造它就是一次越权。
    4. 第三个坑最阴:把空字符串当成结束。规范写死了只有 nextCursor **缺失**才代表没有下一页,空串是完全合法的游标。判错的表现是最后一页数据被静默丢掉,而且不报错,测试也很难发现。
    5. 结论:客户端对游标只允许做一个判断——nextCursor 在不在。页大小同理不得假设固定值,服务端随时可以改。非法游标服务端应当回 -32602,而不是静默返回第一页,否则客户端会陷进死循环。
    6. 可预期的追问:那服务端这边有什么坑?偏移量式游标要求列表顺序稳定,中途插入一条会让后面全部错位,所以要么先排序、要么把游标编成上一条的主键。

    Key points

    • Cursor contents are server internals; parsing them turns an implementation detail into a public contract that breaks on any change
    • Forged cursors bypass server-side paging control, and become privilege escalation if the cursor encodes filters or permissions
    • Only a missing nextCursor ends the sequence — an empty string is valid, and getting it wrong silently drops the last page
    • Page size is server-decided and must not be assumed fixed; invalid cursors should return -32602 rather than silently resetting

    答题要点

    • 游标内容是服务端的内部实现,解析它等于把实现细节变成公开契约,服务端换实现时客户端全挂
    • 伪造游标可以绕过服务端对翻页范围的控制,游标里若编了过滤或权限条件就是越权
    • 只有 nextCursor 缺失才代表结束,空字符串是合法游标,判错会静默丢掉最后一页
    • 页大小由服务端决定不得假设固定,非法游标服务端应回 -32602 而不是静默回第一页
  • On HTTP both subscription streams and in-request progress notifications ride SSE, so why does the 2026-07-28 spec split them into two channels?订阅流和请求内的进度通知在 HTTP 上都走 SSE,为什么 2026-07-28 规范要把它们分成两个通道?
    Common in ChinaCommon overseasDeep dive#subscriptions#notifications

    How to reason about it · think before answering

    1. The discriminator is version awareness. Anyone still describing resources/subscribe and a standalone GET stream exposes themselves — both were replaced by subscriptions/listen in this revision.
    2. Get the facts straight first: subscriptions/listen is an ordinary request whose response is a stream that stays open. The client explicitly opts into toolsListChanged, promptsListChanged, resourcesListChanged, and resourceSubscriptions; the server must not push unselected types; the first message must be the acknowledgment, and every later notification carries subscriptionId in _meta.
    3. Then compare lifetimes. Progress and log notifications belong to one specific request and should stop when it ends. List changes and resource updates span the whole connection and relate to no single request. Mixing different lifetimes into one stream wrecks cancellation semantics, because closing a response stream on HTTP is the cancel signal — you do not want cancelling a tool call to kill your subscriptions.
    4. The second reason is statelessness and routability. In-request notifications naturally ride their own response stream so any replica can serve them; isolating the one genuinely long-lived connection is what lets every other request avoid sticky routing.
    5. Conclusion: the subscription stream answers has the world changed, spanning requests; the response stream answers how far along is my request, living and dying with it. The spec states outright that progress and message notifications never appear on the listen stream.
    6. Likely follow-up: how do you enable log notifications now? logging/setLevel was removed in favor of a per-request logLevel in _meta, and servers must not emit message notifications for requests that omit it. Logging is also deprecated alongside Roots and Sampling, with stderr or OpenTelemetry as the suggested migration.

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

    1. 这题的区分度在版本认知。还在讲 resources/subscribe 和一条独立 GET 长连接的人会当场暴露——这两样在这一版被合并替换成了 subscriptions/listen。
    2. 先把事实摆清:subscriptions/listen 本身是一条普通请求,只是它的响应是一条一直开着的通知流;客户端在 notifications 过滤器里显式勾选 toolsListChanged、promptsListChanged、resourcesListChanged、resourceSubscriptions,服务端不得推送没勾选的类型;第一条消息必须是 acknowledged,之后每条通知在 _meta 里带 subscriptionId。
    3. 拆法:问两类通知的生命周期一样吗。进度和日志属于某一次具体请求,请求结束它们就该停;列表变更、资源更新属于整个连接期,跟任何单次请求都无关。生命周期不同的东西混在一条流里,取消语义就说不清——HTTP 上关闭响应流就是取消该请求,你不会希望取消一次工具调用顺带把订阅也掐了。
    4. 第二个理由是无状态与可路由。请求内通知天然跟着那条请求的响应流走,任意副本都能处理;订阅是唯一一条长活连接,把它单独隔出来,剩下的请求才能真正做到无粘性路由。
    5. 结论:订阅流回答「世界变了吗」,跨请求、长期存在;响应流回答「我这一单做到哪了」,随请求生随请求死。规范明确写了进度与日志通知不在订阅流上出现。
    6. 可预期的追问:日志通知现在怎么开?logging/setLevel 已删除,改为每请求在 _meta 的 logLevel 里指定,且服务端不得对没带这个字段的请求发日志通知;而且 Logging 连同 Roots、Sampling 一起已被标记弃用,建议迁到 stderr 或 OpenTelemetry。

    Key points

    • This revision replaced resources/subscribe and the standalone GET stream with subscriptions/listen, where clients explicitly opt into notification types
    • The two kinds have different lifetimes: progress and logs live and die with a request, list and resource changes span the connection
    • Merging them breaks cancellation, since closing a response stream on HTTP cancels that request and must not kill subscriptions
    • Isolating the single long-lived stream is what lets every other request route without stickiness, enabling horizontal scaling

    答题要点

    • 这一版用 subscriptions/listen 取代了 resources/subscribe 与独立的 GET 长连接,客户端显式勾选通知类型
    • 两类通知生命周期不同:进度日志随请求生灭,列表与资源变更跨请求长期存在
    • 混在一条流里会让取消语义失效,HTTP 上关闭响应流即取消该请求,不该顺带掐掉订阅
    • 隔离出唯一的长活连接,其余请求才能无粘性路由,这是无状态设计能横向扩容的前提

Comments