MCP 详解
MCP Explained

原始链接: https://mcp-explained.com/

这段 JavaScript 实现了一个基于浏览器的 Model Context Protocol 交互式课程播放器。它定义了许多协议课程——涵盖 JSON-RPC、生命周期、传输、认证、工具、资源、提示、采样、引出、根、进度、任务、搜索/代码模式、MCP 应用和服务器卡片——并将它们渲染为动画网络图和时序图、消息日志、检查器和时间线。它支持规范版本、课程分组、播放控制、速度设置、主题、变体和基于哈希的导航。脚本管理应用状态、动画消息包、调整字幕大小并更新 UI。总体而言,这是一个用于探索 MCP 消息流和协议变更的客户端教育可视化工具。

一篇 Hacker News 帖子链接到名为“MCP Explained”的交互式解释网站。该提交有 24 个积分和四条评论。评论者争论这类资源相比直接向 LLM 提问是否仍有价值,请求提供源代码以便为非技术经理改编,并指出它似乎是一个可下载或用 AI 工具复制的单页 HTML 文件。
相关文章

原文

", _meta: { ui: { csp: { connectDomains: ["https://api.open-meteo.com"], resourceDomains: ["https://cdn.jsdelivr.net"] }, prefersBorder: true } } }] }), notes: [["mimeType", "Must be text/html;profile=mcp-app. Content is one HTML5 document, as text or a base64 blob."], ["csp", "Origins the page needs. Without it the host uses connect-src 'none'. The host may tighten it, never loosen it."], ["review", "The host gets the template before any data. It can hash it, allowlist it or warn the user."], ["list", "UI-only resources may be left out of resources/list. Tools point to them."]] } ] }, { title: "Call", desc: "The model only sees get_forecast. It calls it, and the host runs the call while it starts the UI.", msgs: [ { from: "host", to: "llm", kind: "host", dt: 1200, info: "tools → context (no app-only tools)", json: { host: "add to context", tools: ["get_forecast"], hidden: ["refresh_day (visibility: app)"] } }, { from: "llm", to: "host", kind: "host", dt: 1300, info: "tool_use get_forecast Lisbon", json: { type: "tool_use", name: "get_forecast", input: { city: "Lisbon" } } }, { from: "host", to: "server", kind: "req", dt: 4, info: "id 3 · get_forecast", json: rpc(3, "tools/call", { name: "get_forecast", arguments: { city: "Lisbon" } }) } ] }, { title: "Render", desc: "The host loads the HTML in a sandboxed iframe on its own origin. The app opens a JSON-RPC link to the host and gets the arguments.", msgs: [ { from: "host", to: "app", kind: "host", dt: 3, info: "iframe + CSP from _meta.ui.csp", json: { host: "render", iframe: { origin: "https://a904794854a0.sandbox.example", sandbox: "allow-scripts allow-same-origin" }, csp: "default-src 'none'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' https://api.open-meteo.com; img-src 'self' data: https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net; media-src 'self' data: https://cdn.jsdelivr.net; frame-src 'none'; object-src 'none'; base-uri 'self'" }, notes: [["origin", "Must differ from the host page's origin (origin name illustrative)."], ["proxy", "Web hosts often put a sandbox proxy iframe in between. It only relays messages."], ["CSP", "Built by the host from the declared domains, as the spec's example shows."]] }, { from: "app", to: "host", kind: "req", dt: 40, info: "app id 1 · ui/initialize", json: rpc(1, "ui/initialize", { protocolVersion: "2026-01-26", appInfo: { name: "forecast-view", version: "1.0.0" }, appCapabilities: { availableDisplayModes: ["inline", "fullscreen"] } }), notes: [["id", "The app's own id space. This link is separate from host ↔ server."], ["protocolVersion", "The MCP Apps revision, not the core MCP version."]] }, { from: "host", to: "app", kind: "res", dt: 2, info: "app id 1 · hostCapabilities, hostContext", json: ok(1, { protocolVersion: "2026-01-26", hostInfo: HOST, hostCapabilities: { serverTools: {}, serverResources: {}, openLinks: {}, logging: {} }, hostContext: { toolInfo: { id: 3, tool: { name: "get_forecast", inputSchema: FORECAST.inputSchema } }, theme: "dark", displayMode: "inline", availableDisplayModes: ["inline", "fullscreen"], containerDimensions: { width: 640, maxHeight: 600 }, locale: "en-US" } }), notes: [["serverTools", "The host will forward the app's tools/call to the server."], ["hostContext", "Which tool call this is, theme, CSS variables, size limits, locale."], ["containerDimensions", "Fixed width, height up to 600. The app reports its height."]] }, { from: "app", to: "host", kind: "note", dt: 3, info: "ready", json: note("ui/notifications/initialized"), notes: [["order", "The host must not send the app anything before this."]] }, { from: "host", to: "app", kind: "note", dt: 2, info: "tool-input · city Lisbon", json: note("ui/notifications/tool-input", { arguments: { city: "Lisbon" } }), notes: [["tool-input", "Complete arguments, sent once. tool-input-partial may stream before it while the model is still writing them."]] } ] }, { title: "Result", desc: "The result goes to the model as text and to the app as data. The app resizes itself to fit.", msgs: [ { from: "server", to: "host", kind: "res", dt: 260, info: "id 3 · text + structuredContent", json: ok(3, RESULT) }, { from: "host", to: "app", kind: "note", dt: 3, info: "tool-result", json: note("ui/notifications/tool-result", RESULT), notes: [["params", "The standard CallToolResult, unchanged."], ["structuredContent", "Data for the UI. Best practice: keep it out of the model's context."], ["cancelled", "If the call is cancelled, the host sends ui/notifications/tool-cancelled instead."]] }, { from: "host", to: "llm", kind: "host", dt: 2, info: "tool_result (text) → model", json: { type: "tool_result", content: "Lisbon, next 5 days: 19/13°C sun, 18/12°C cloud, 17/12°C rain, …" } }, { from: "app", to: "host", kind: "note", dt: 30, info: "size-changed 640×412", json: note("ui/notifications/size-changed", { width: 640, height: 412 }), notes: [["size", "With a flexible height the host must follow these and resize the iframe."]] } ] }, { title: "Interact", desc: "The user clicks Thursday. The app asks the host to call the app-only tool. The host checks it, then forwards it.", msgs: [ { from: "user", to: "app", kind: "host", dt: 4000, info: "click Thu 14", json: { event: "click", target: "Thu 14 Nov" } }, { from: "app", to: "host", kind: "req", dt: 5, info: "app id 2 · tools/call refresh_day", json: rpc(2, "tools/call", { name: "refresh_day", arguments: { city: "Lisbon", date: "2026-11-14" } }), notes: [["method", "Plain MCP tools/call. The app has no connection to the server, only to the host."]] }, { from: "host", to: "server", kind: "req", dt: 3, info: "id 4 · refresh_day", json: rpc(4, "tools/call", { name: "refresh_day", arguments: { city: "Lisbon", date: "2026-11-14" } }), notes: [["visibility", "The host must reject app calls to tools without \"app\" in visibility, and calls to other servers' app-only tools."], ["consent", "The host may block a call or ask the user first. It should log app-initiated calls."], ["id", "A new host ↔ server id. The host maps it back to the app's id 2."]] }, { from: "server", to: "host", kind: "res", dt: 180, info: "id 4 · Thu 14 hourly", json: ok(4, DAY) }, { from: "host", to: "app", kind: "res", dt: 2, info: "app id 2 · Thu 14 hourly", json: ok(2, DAY), notes: [["model", "Nothing reaches the model. It doesn't know the user clicked."]] } ] }, { title: "Context", desc: "The app tells the model what the user is looking at. The host keeps it for the next turn.", msgs: [ { from: "app", to: "host", kind: "req", dt: 800, info: "app id 3 · ui/update-model-context", json: rpc(3, "ui/update-model-context", { content: [{ type: "text", text: "User opened Thu 14 Nov: rain from 15:00, 17/12°C." }] }), notes: [["overwrite", "Each update replaces the app's last one. Only the latest is sent to the model."], ["ui/message", "Different method: posts a message into the chat and gets a reply. The host may ask for consent."]] }, { from: "host", to: "app", kind: "res", dt: 2, info: "app id 3 · {}", json: ok(3, {}) }, { from: "host", to: "llm", kind: "host", dt: 6000, info: "next turn: app context + user message", json: { host: "next turn", app_context: "User opened Thu 14 Nov: rain from 15:00, 17/12°C.", user: "Should I move the picnic?" }, notes: [["timing", "The host may hold the context until the next user message."], ["teardown", "Before removing the iframe the host sends ui/resource-teardown and should wait for the answer."]] } ] }, ], }); // ───────────── SERVER CARDS const MCP_URL = "https://mcp.tracker.example/acme/mcp"; const CARD_URL = MCP_URL + "/server-card"; const CARD_TYPE = "application/mcp-server-card+json"; const CARD = { $schema: "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json", name: "example.tracker/acme", version: "3.4.1", description: "Acme's issues and sprints, hosted by Tracker.", title: "Acme Issues", websiteUrl: "https://tracker.example/docs/mcp", repository: { url: "https://github.com/tracker-example/mcp-server", source: "github" }, icons: [{ src: "https://tracker.example/icon-64.png", mimeType: "image/png", sizes: ["64x64"] }], remotes: [{ type: "streamable-http", url: MCP_URL, supportedProtocolVersions: ["2025-11-25", "2026-07-28"] }], }; const CLIENT = { name: "acme-ide", version: "5.2.0" }; const m26 = () => ({ "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}, "io.modelcontextprotocol/clientInfo": CLIENT }); const host26 = (method) => ({ "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2026-07-28", "Mcp-Method": method }); const ETAG = "\"c41f9a\""; SEC.push({ id: "cards", code: "Server Cards", name: "Know a server before connecting", group: "next", layer: 9, tag: "beta extension", summary: "A Server Card is a static JSON file that says what a remote MCP server is and how to reach it: name, version, description, endpoint URLs, transports and protocol versions. A domain lists its cards in an AI Catalog at /.well-known/ai-catalog.json. Clients and registries read cards over plain HTTPS without opening an MCP connection. Cards leave out tools, resources, prompts and capabilities on purpose, and clients check what they do say against the live server.", facts: [["Extension id", "io.modelcontextprotocol/server-card"], ["Stage", "Beta, destination not stated (SEP-2127 Final). Schema v1 still marked pre-release"], ["Card location", "any URI; /server-card reserved as the default"], ["Domain discovery", "AI Catalog at /.well-known/ai-catalog.json; entries with type application/mcp-server-card+json and url or data"], ["Required fields", "$schema, name (reverse-DNS, one slash), version, description"], ["Optional fields", "title, websiteUrl, repository, icons, remotes[], _meta"], ["remotes[]", "type (streamable-http, sse), url (may hold {variables}), headers, variables, supportedProtocolVersions"], ["Left out", "tools, resources, prompts, capabilities, extensions, local packages"], ["Auth", "no auth section; a remote can only declare headers, e.g. Authorization with isSecret"], ["Trust", "advisory; verify against server/discover, never use for security decisions"], ["HTTP", "HTTPS, CORS *, Cache-Control public max-age=3600, ETag / If-None-Match"]], links: [["SEP-2127", "https://modelcontextprotocol.io/seps/2127-mcp-server-cards"], ["Discovery", "https://github.com/modelcontextprotocol/ext-server-card/blob/main/docs/discovery.md"], ["schema.ts", "https://github.com/modelcontextprotocol/ext-server-card/blob/main/schema.ts"], ["ext-server-card repo", "https://github.com/modelcontextprotocol/ext-server-card"], ["AI Catalog", "https://github.com/Agent-Card/ai-catalog"], ["Spec: server/discover", "https://modelcontextprotocol.io/specification/2026-07-28/server/discover"]], actors: [at("user", 165, 115), at("client", 165, 425, { sub: "IDE, agent or registry" }), at("site", 835, 115), at("server", 835, 425, { sub: "mcp.tracker.example" })], zones: [Z.me(["user", "client"], "client app"), { kind: "remote", label: "Catalog host", sub: "acme.com", ids: ["site"] }, { kind: "saas", label: "Server host", sub: "tracker.example", ids: ["server"] }], wires: { "client|site": "HTTPS · GET", "client|server": "HTTPS" }, steps: [ { title: "Catalog", desc: "A client pointed at acme.com, or a registry crawling it, fetches the domain's AI Catalog.", msgs: [ { from: "client", to: "site", kind: "http", dt: 0, info: "GET /.well-known/ai-catalog.json", json: { request: "GET https://acme.com/.well-known/ai-catalog.json" }, notes: [["well-known", "Domains MAY publish a catalog here. Clients doing domain discovery SHOULD try it."]] }, { from: "site", to: "client", kind: "http", dt: 70, info: "200 · 1 server card entry", json: { status: "200 OK", headers: { "Content-Type": "application/ai-catalog+json" }, body: { specVersion: "1.0", entries: [{ identifier: "urn:air:acme.com:mcp:issues", type: CARD_TYPE, url: CARD_URL }] } }, notes: [["type", "Clients pick entries with this media type. Other entries can describe non-MCP artifacts."], ["url or data", "Exactly one. data embeds the whole card inline."], ["other domain", "The card can live on another domain. Here Acme's server is hosted by Tracker."], ["format", "The catalog format belongs to the AI Catalog project, not to MCP."]] } ] }, { title: "Card", desc: "The client follows the url. The card sits next to the MCP endpoint, at its URL plus /server-card.", msgs: [ { from: "client", to: "server", kind: "http", dt: 5, info: "GET …/acme/mcp/server-card", json: { request: "GET " + CARD_URL, headers: { Accept: CARD_TYPE } }, notes: [["Accept", "Clients SHOULD ask for the card media type."], ["path", "/server-card is added to the streamable HTTP URL, not the domain root. A GET on /mcp itself opens an SSE stream."]] }, { from: "server", to: "client", kind: "http", dt: 60, info: "200 · card, cacheable", json: { status: "200 OK", headers: { "Content-Type": CARD_TYPE, "Cache-Control": "public, max-age=3600", ETag: ETAG, "Access-Control-Allow-Origin": "*" }, body: CARD }, notes: [["name", "Reverse-DNS namespace, one slash, then the server name."], ["remotes", "Where and how to connect. url can contain {variables} the client fills in; headers can be marked isRequired or isSecret."], ["supportedProtocolVersions", "Lets the client pick a version before it connects."], ["missing", "No tools, resources, prompts, capabilities or extensions. Those depend on the user and are listed at runtime."], ["public", "Cards must not hold credentials, internal endpoints or per-user data."]] } ] }, { title: "Show", desc: "Before any MCP traffic, the client can show what this is and check it can talk to it.", msgs: [ { from: "client", to: "user", kind: "host", dt: 4, info: "Acme Issues · v3.4.1 · add?", json: { host: "show", title: "Acme Issues", name: "example.tracker/acme", version: "3.4.1", icon: "https://tracker.example/icon-64.png", description: "Acme's issues and sprints, hosted by Tracker.", connect: { transport: "streamable-http", url: MCP_URL, protocolVersion: "2026-07-28" } }, notes: [["version", "The client picks the newest version it speaks from supportedProtocolVersions."], ["inputs", "If the remote declared required variables or headers, such as a tenant or an API key, the client would ask for them here."]] }, { from: "user", to: "client", kind: "host", dt: 2500, info: "Add server", json: { action: "add server", name: "example.tracker/acme" } } ] }, { title: "Discover", desc: "The client connects and asks the server itself. Where the live answer and the card disagree, the live answer wins.", msgs: [ { from: "client", to: "server", kind: "http", dt: 10, info: "POST /mcp · server/discover", json: { request: "POST " + MCP_URL, headers: host26("server/discover"), body: rpc(0, "server/discover", { _meta: m26() }) }, notes: [["2026-07-28", "server/discover is the runtime counterpart the card refers to. On 2025-11-25 the same check uses the initialize result."], ["token", "Whether discover needs a token is up to the server (illustrative)."]] }, { from: "server", to: "client", kind: "res", dt: 40, info: "id 0 · matches the card", json: ok(0, { resultType: "complete", supportedVersions: ["2025-11-25", "2026-07-28"], capabilities: { tools: { listChanged: true }, resources: {} }, _meta: { "io.modelcontextprotocol/serverInfo": { name: "example.tracker/acme", version: "3.4.1" } }, ttlMs: 3600000, cacheScope: "public" }), notes: [["check", "serverInfo name and version and supportedVersions SHOULD NOT contradict the card. The client prefers these values."], ["capabilities", "First seen here. The card never lists them."], ["risk", "A wrong card can steer a client to a weaker version or the wrong server. That's why it's only a hint."]] } ] }, { title: "Auth", desc: "The card says nothing about OAuth. The first protected call finds out the usual way.", msgs: [ { from: "client", to: "server", kind: "http", dt: 8, info: "POST /mcp · tools/list (no token)", json: { request: "POST " + MCP_URL, headers: host26("tools/list"), body: rpc(1, "tools/list", { _meta: m26() }) } }, { from: "server", to: "client", kind: "err", dt: 15, info: "401 · WWW-Authenticate", json: { status: "401 Unauthorized", headers: { "WWW-Authenticate": "Bearer resource_metadata=\"https://mcp.tracker.example/.well-known/oauth-protected-resource/acme/mcp\"" } }, notes: [["auth", "No auth section in the card. Discovery from here is the normal OAuth flow, see the Authorization lesson."], ["tools", "Only after sign-in does tools/list say what this user can do."]] } ] }, { title: "Revalidate", desc: "Later the client checks whether the card changed. It didn't, so no body comes back.", msgs: [ { from: "client", to: "server", kind: "http", dt: 3600000, info: "GET server-card · If-None-Match", json: { request: "GET " + CARD_URL, headers: { Accept: CARD_TYPE, "If-None-Match": ETAG } }, notes: [["If-None-Match", "Clients SHOULD send the ETag they got and respect Cache-Control instead of polling."]] }, { from: "server", to: "client", kind: "http", dt: 30, info: "304 Not Modified", json: { status: "304 Not Modified", headers: { ETag: ETAG, "Cache-Control": "public, max-age=3600" } }, notes: [["rate limits", "Card hosts SHOULD rate-limit the endpoint."]] } ] }, ], }); } LESSONS.push(...SEC); // SEC:END // Per-lesson sources, what changed in the newer spec, and work in progress. const SPEC_URL = "https://modelcontextprotocol.io/specification/" + SPEC; const NEXT = "2026-07-28", NEXT_URL = "https://modelcontextprotocol.io/specification/2026-07-28"; const ROADMAP = "https://modelcontextprotocol.io/development/roadmap"; const SKILLS = "https://modelcontextprotocol.io/extensions/skills/overview"; const PD_WG = "https://modelcontextprotocol.io/community/working-groups/progressive-disclosure"; const MRTR = "Servers no longer send requests to clients: they return input_required and the client retries with the answers (multi round-trip)."; const EXTRA = { jsonrpc: { links: [["Base protocol", SPEC_URL + "/basic#messages"], ["JSON-RPC 2.0", "https://www.jsonrpc.org/specification"], ["schema.ts", "https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/" + SPEC + "/schema.ts"]], next: ["Every result carries a required resultType: \"complete\" or \"input_required\".", MRTR, "Resource not found moves from -32002 to -32602; new MCP codes -32020 to -32022."] }, lifecycle: { links: [["Lifecycle", SPEC_URL + "/basic/lifecycle"], ["Version negotiation", SPEC_URL + "/basic/lifecycle#version-negotiation"], ["Ping", SPEC_URL + "/basic/utilities/ping"]], next: ["The initialize handshake is gone. Every request carries protocolVersion, clientCapabilities and clientInfo in _meta.", "A new server/discover call returns the server's capabilities.", "ping is removed. A version mismatch returns -32022."] }, transports: { links: [["Transports", SPEC_URL + "/basic/transports"], ["Sessions", SPEC_URL + "/basic/transports#session-management"], ["Resumability", SPEC_URL + "/basic/transports#resumability-and-redelivery"], ["HTTP+SSE (2024-11-05)", "https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse"]], next: ["Sessions and MCP-Session-Id are removed (stateless MCP).", "HTTP GET and DELETE are gone; long-lived notifications use a subscriptions/listen POST stream.", "SSE resumability with Last-Event-ID is removed: re-issue the request instead.", "POSTs carry new Mcp-Method and Mcp-Name headers."], wip: [["Transports WG: Streamable HTTP over stdio as the single binding, plus ETag caching", "working group", "https://modelcontextprotocol.io/community/working-groups/transports"], ["Server Cards: describe a server before connecting, at /server-card", "beta extension", "https://modelcontextprotocol.io/seps/2127-mcp-server-cards"]] }, auth: { links: [["Authorization", SPEC_URL + "/basic/authorization"], ["Client registration", SPEC_URL + "/basic/authorization#client-registration-approaches"], ["Scope challenges", SPEC_URL + "/basic/authorization#scope-challenge-handling"], ["RFC 9728", "https://datatracker.ietf.org/doc/html/rfc9728"], ["OAuth 2.1", "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13"]], next: ["Dynamic client registration is deprecated in favour of Client ID Metadata Documents.", "Clients validate iss on the authorization response (RFC 9207) and key stored credentials by issuer.", "New refresh token guidance; step-up asks for the union of old and new scopes."], wip: [["OAuth Client Credentials: machine-to-machine access", "stable extension", "https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials"], ["Enterprise-Managed Authorization: access through your company IdP (ID-JAG)", "stable extension", "https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization"], ["Agent identity: DPoP, workload identity federation, token exchange", "roadmap", ROADMAP]] }, tools: { links: [["Tools", SPEC_URL + "/server/tools"], ["Tool schema", SPEC_URL + "/schema#tool"], ["Annotations", SPEC_URL + "/schema#toolannotations"], ["Pagination", SPEC_URL + "/server/utilities/pagination"]], next: ["tools/list results carry ttlMs and cacheScope, in a deterministic order.", "list_changed arrives over a subscriptions/listen stream.", "structuredContent may be any JSON value; schemas may use any JSON Schema 2020-12 keyword.", "tools/call can return input_required (multi round-trip)."], wip: [["MCP Apps: a tool links a ui:// resource the host renders as an interactive app", "stable extension", "https://modelcontextprotocol.io/extensions/apps/overview"], ["A redesigned tools/call result shape (content vs structuredContent)", "roadmap", ROADMAP]] }, resources: { links: [["Resources", SPEC_URL + "/server/resources"], ["Subscriptions", SPEC_URL + "/server/resources#subscriptions"], ["Annotations", SPEC_URL + "/server/resources#annotations"]], next: ["resources/subscribe and unsubscribe are replaced by subscriptions/listen.", "Not found is -32602 instead of -32002.", "List and read results carry ttlMs and cacheScope."], wip: [["Skills over MCP: Agent Skills served as skill:// resources, found with skills/list and skills/get", "beta extension", SKILLS]] }, prompts: { links: [["Prompts", SPEC_URL + "/server/prompts"], ["Completion", SPEC_URL + "/server/utilities/completion"]], next: ["Results carry resultType; prompts are otherwise much the same."], wip: [["Skills over MCP: packaged instructions and files, beyond single prompt templates", "beta extension", SKILLS]] }, sampling: { links: [["Sampling", SPEC_URL + "/client/sampling"], ["Tools in sampling", SPEC_URL + "/client/sampling#tools-in-sampling"], ["Model preferences", SPEC_URL + "/client/sampling#model-preferences"]], next: ["Sampling is deprecated (SEP-2577). Earliest removal: the first release on or after 2027-07-28.", MRTR] }, elicitation: { links: [["Elicitation", SPEC_URL + "/client/elicitation"], ["URL mode", SPEC_URL + "/client/elicitation#url-mode-elicitation-requests"], ["Safe URL handling", SPEC_URL + "/client/elicitation#safe-url-handling"]], next: ["Elicitation stays. The tool call returns input_required with the form or URL, and the client retries with the answer.", "notifications/elicitation/complete and elicitationId are removed."] }, roots: { links: [["Roots", SPEC_URL + "/client/roots"]], next: ["Roots are deprecated (SEP-2577).", "notifications/roots/list_changed is removed; roots/list moves to multi round-trip."] }, progress: { links: [["Progress", SPEC_URL + "/basic/utilities/progress"], ["Cancellation", SPEC_URL + "/basic/utilities/cancellation"], ["Logging", SPEC_URL + "/server/utilities/logging"]], next: ["Logging is deprecated. logging/setLevel is removed; the level travels per request in _meta.", "Only the client cancels. On Streamable HTTP, closing the stream is the cancel.", "Progress flows server → client only."] }, tasks: { links: [["Tasks", SPEC_URL + "/basic/utilities/tasks"], ["Task schema", SPEC_URL + "/schema#task"]], next: ["Tasks left core for the official extension io.modelcontextprotocol/tasks (SEP-2663).", "The server decides per request whether to return a task. tasks/result and tasks/list are removed; tasks/get inlines the result; tasks/update sends input."], wip: [["Tasks extension: beta, headed back into core", "beta extension", "https://modelcontextprotocol.io/extensions/tasks/overview"], ["Tasks & Events WG: server-initiated events and webhooks", "roadmap", ROADMAP]] }, search: { links: [["tools/list in the spec", SPEC_URL + "/server/tools#listing-tools"], ["Anthropic tool search tool", "https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool"], ["Claude Code MCP", "https://code.claude.com/docs/en/mcp"], ["Runlayer", "https://runlayer.com"]], wip: [["Progressive Disclosure WG: grouping, tool search and code mode as reference extensions. No SEP yet", "working group", PD_WG]] }, codemode: { links: [["Cloudflare Code Mode", "https://blog.cloudflare.com/code-mode/"], ["Anthropic programmatic tool calling", "https://platform.claude.com/docs/en/agents-and-tools/tool-use/programmatic-tool-calling"], ["Runlayer", "https://runlayer.com"]], wip: [["Progressive Disclosure WG explores code mode as a reference extension. No SEP yet", "working group", PD_WG]] }, }; for (const l of LESSONS) Object.assign(l, EXTRA[l.id] || {}); const WHATS_NEXT = [ ["Spec " + NEXT, "Stateless, no handshake, multi round-trip", NEXT_URL + "/changelog"], ["Extensions", "Apps, Skills, Tasks, Server Cards, auth", "https://modelcontextprotocol.io/extensions/overview"], ["Skills over MCP", "Agent Skills as an MCP extension · lesson", "#skills"], ["Progressive disclosure", "Tool search and code mode, in the protocol", PD_WG], ["Registry", "registry.modelcontextprotocol.io, in preview", "https://modelcontextprotocol.io/registry/about"], ["Roadmap", "Next release planned for March 2027", ROADMAP], ]; const GROUPS = [["all", "All"], ["core", "Core"], ["server", "Server"], ["client", "Client"], ["utility", "Utilities"], ["scale", "At scale"], ["security", "Break it"], ["next", "Coming"]]; const LAYERS = ["Transport", "JSON-RPC", "Lifecycle", "Auth", "Server features", "Client features", "At scale"]; const GROUP_HEAD = { core: "Core protocol", server: "Server features", client: "Client features", utility: "Utilities", scale: "Not in the spec: harness techniques", security: "Break it: attacks and defenses", next: "Coming to MCP: extensions" }; const GROUP_LABEL = { core: "Core protocol", server: "Server feature", client: "Client feature", utility: "Utility", scale: "At scale" };

`; return; } const from = actor(m.from), to = actor(m.to); const method = methodOf(m); box.innerHTML = `

${esc(method)}${size(m)}

${esc(from.label)} → ${esc(to.label)} · ${KIND[m.kind]} · #${i + 1}

${m.dep ? `

` : ""}

${hl(m.json)}

${m.notes ? `

${m.notes.map(([k, v]) => `
${esc(k)}
${esc(v)}
`).join("")}

` : ""}${m.risk ? `

${riskBtn(m.risk)}

Attack surface: ${esc(RISKS[m.risk].title)}. ${esc(RISKS[m.risk].text)} See the attack →

` : ""}`; } // ── table const size = (m) => { const b = new TextEncoder().encode(JSON.stringify(m.json)).length; return b > 1024 ? (b / 1024).toFixed(1) + " KB" : b + " B"; }; function renderTable() { let t = 0; const rows = MSGS.map((m, i) => { t += m.dt || 0; const cls = [i === st.idx ? "cur" : "", i > st.idx ? "fut" : "", i === st.selected ? "sel" : "", m.dep ? "dep" : ""].join(" "); const method = methodOf(m); return `

${i + 1}${esc(actor(m.from).label)}${esc(actor(m.to).label)}${m.dep ? `${esc(method)} deprecated` : esc(method)}${esc(m.info)}${m.risk ? " " + riskBtn(m.risk) : ""}

`; }).join(""); $("#tbody").innerHTML = rows; const cur = $("#tbody tr.cur"); if (cur) { const wrap = $("#tablewrap"), head = wrap.querySelector("thead").offsetHeight; const top = Math.max(0, cur.offsetTop - head - (wrap.clientHeight - head) / 2 + cur.offsetHeight / 2); wrap.scrollTo({ top, behavior: "smooth" }); } } // ── diagram const actor = (id) => st.lesson.actors.find((a) => a.id === id); function edgePts(a, b) { const dx = b.ex - a.ex, dy = b.ey - a.ey; const clip = (n, sx, sy) => { const tx = n.hw / Math.abs(sx || 1e-6), ty = n.hh / Math.abs(sy || 1e-6); const k = Math.min(tx, ty); return [n.ex + sx * k, n.ey + sy * k]; }; const len = Math.hypot(dx, dy), ux = dx / len, uy = dy / len; const off = 5; // two lanes so each direction gets its own line const ox = -uy * off, oy = ux * off; const p1 = clip(a, ux, uy), p2 = clip(b, -ux, -uy); return [p1[0] + ox, p1[1] + oy, p2[0] + ox, p2[1] + oy]; } function renderNetwork() { const svg = $("#net"); svg.innerHTML = ""; const L = st.lesson, cur = MSGS[st.idx]; const defs = svgEl("defs"); defs.innerHTML = ``; svg.appendChild(defs); svg.appendChild(svgEl("rect", { x: -400, y: -100, width: 1800, height: 800, fill: "url(#dots)" })); L.actors.forEach(layoutActor); // Portrait: edges and packets run between node boxes, so lines never cross the labels. if (L.portrait) L.actors.forEach((a) => Object.assign(a, { ey: (a.by0 + a.by1) / 2, hw: (a.bx1 - a.bx0) / 2 + 8, hh: (a.by1 - a.by0) / 2 + 8 })); // zones: where each actor runs const zg = svgEl("g", { class: "zones" }); for (const z of L.zones || []) { const ms = z.ids.map(actor); let x0 = Math.min(...ms.map((m) => m.bx0)) - 22, x1 = Math.max(...ms.map((m) => m.bx1)) + 22; const need = 48 + z.label.length * 8.9 + (z.sub ? z.sub.length * 7.6 + 10 : 0); if (x1 - x0 < need) { const c = (x0 + x1) / 2; x0 = c - need / 2; x1 = c + need / 2; } const y0 = Math.min(...ms.map((m) => m.by0)) - 40, y1 = Math.max(...ms.map((m) => m.by1)) + 14; const g = svgEl("g", { class: "zone z-" + z.kind }); g.innerHTML = `` + `${esc(z.label.toUpperCase())}${z.sub ? `${esc(z.sub)}` : ""}`; zg.appendChild(g); } svg.appendChild(zg); const pairs = new Map(); MSGS.forEach((m, i) => { const k = [m.from, m.to].sort().join("|"); if (!pairs.has(k)) pairs.set(k, { a: m.from, b: m.to, used: false, host: m.kind === "host" }); if (i <= st.idx) pairs.get(k).used = true; if (m.kind !== "host") pairs.get(k).host = false; }); const g = svgEl("g"); for (const [, p] of pairs) { const a = actor(p.a), b = actor(p.b); const active = cur && [cur.from, cur.to].sort().join("|") === [p.a, p.b].sort().join("|"); const [x1, y1, x2, y2] = L.portrait ? (() => { const q = edgePts(a, b); const r = edgePts(b, a); return [(q[0] + r[2]) / 2, (q[1] + r[3]) / 2, (q[2] + r[0]) / 2, (q[3] + r[1]) / 2]; })() : [a.ex, a.ey, b.ex, b.ey]; g.appendChild(svgEl("line", { x1, y1, x2, y2, class: "edge" + (active ? " on" : p.used ? " used" : "") + (p.host ? " hostedge" : "") })); } svg.appendChild(g); const wires = L.wires || {}; for (const k in wires) { const [i, j] = k.split("|"), a = actor(i), b = actor(j); if (!a || !b) continue; const mx = (a.ex + b.ex) / 2, my = (a.ey + b.ey) / 2, w = textW(wires[k], 13, true) + 18; const t = svgEl("g", { class: "wire", transform: `translate(${mx},${my})` }); t.innerHTML = `${esc(wires[k])}`; svg.appendChild(t); } for (const a of L.actors) { const on = cur && (cur.from === a.id || cur.to === a.id); const n = svgEl("g", { class: "node iso" + (on ? " on" : "") }); n.innerHTML = `${a._m}${esc(a.label)}${esc(a.sub)}`; svg.appendChild(n); } if (L.portrait) { let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; svg.querySelectorAll(".zones, .node").forEach((el) => { const b = el.getBBox(); if (!b.width) return; x0 = Math.min(x0, b.x); y0 = Math.min(y0, b.y); x1 = Math.max(x1, b.x + b.width); y1 = Math.max(y1, b.y + b.height); }); const minW = 640; if (x1 - x0 < minW) { const c = (x0 + x1) / 2; x0 = c - minW / 2; x1 = c + minW / 2; } if (x1 > x0) { const pad = 18; svg.setAttribute("viewBox", `${x0 - pad} ${y0 - pad} ${x1 - x0 + 2 * pad} ${y1 - y0 + 2 * pad}`); svg.style.aspectRatio = `${x1 - x0 + 2 * pad} / ${y1 - y0 + 2 * pad}`; } } else if (svg.getAttribute("viewBox") !== "0 0 1000 560") { svg.setAttribute("viewBox", "0 0 1000 560"); svg.style.aspectRatio = ""; } // caption const cap = $("#caption"); fitCaption(L, cap); if (cur) { cap.innerHTML = capHTML(L, cur.step); cap.hidden = false; } else { cap.innerHTML = capHTML(L, 0); cap.hidden = false; } } function capHTML(L, i) { const s = L.steps[i]; const rk = (MSGS.find((m) => m.step === i && m.risk) || {}).risk; return `

Step ${i + 1} of ${L.steps.length}${rk ? " " + riskBtn(rk) : ""}${s.dep ? ` Deprecated` : ""}

${esc(s.title)}

${esc(s.desc)}

${s.dep ? `

${esc(s.dep)}

` : ""}`; } let capKey = ""; // Reserve the tallest caption of the lesson so the diagram never jumps between steps. function fitCaption(L, cap) { const w = cap.clientWidth; const key = L.id + ":" + (L.vid || "") + ":" + w; if (!w || key === capKey) return; capKey = key; // Measure in place (same box, same styles); synchronous, so nothing paints in between. const keep = cap.innerHTML; cap.style.minHeight = "0"; let h = 0; for (let i = 0; i < L.steps.length; i++) { cap.innerHTML = capHTML(L, i); h = Math.max(h, cap.offsetHeight); } cap.innerHTML = keep; cap.style.minHeight = Math.max(70, Math.ceil(h)) + "px"; } const refitCaption = () => { capKey = ""; const c = $("#caption"); if (c && st.lesson) fitCaption(st.lesson, c); }; window.addEventListener("resize", refitCaption); matchMedia("(max-width:640px)").addEventListener("change", () => { const i = st.idx; setLesson(st.lesson.id); if (i >= 0) go(i, false); }); if (document.fonts) { document.fonts.ready.then(refitCaption); document.fonts.addEventListener("loadingdone", refitCaption); } let anim = null; function animatePacket(m) { const svg = $("#net"); if (anim) { cancelAnimationFrame(anim.raf); anim.g.remove(); anim = null; } const a = actor(m.from), b = actor(m.to); const [x1, y1, x2, y2] = edgePts(a, b); const label = methodOf(m); const g = svgEl("g", { class: "pkt k-" + m.kind + (m.dep ? " dep" : "") }); const w = Math.max(60, label.length * 8.1 + 30); g.innerHTML = `${esc(label)}${m.dep ? `DEPRECATED` : ""}`; svg.appendChild(g); const trail = svgEl("line", { x1, y1, x2: x1, y2: y1, class: "trail k-" + m.kind, "marker-end": "url(#ah)" }); svg.insertBefore(trail, g); const dur = 900 / st.speed, t0 = performance.now(); const ease = (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2); const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; const tick = (now) => { const t = reduce ? 1 : Math.min(1, (now - t0) / dur), e = ease(t); const x = x1 + (x2 - x1) * e, y = y1 + (y2 - y1) * e; g.setAttribute("transform", `translate(${x},${y})`); trail.setAttribute("x2", x); trail.setAttribute("y2", y); if (t < 1) anim.raf = requestAnimationFrame(tick); else { g.classList.add("arrived"); } }; anim = { g, raf: requestAnimationFrame(tick) }; } function renderSequence() { const svg = $("#seq"); const L = st.lesson, n = L.actors.length; const colW = 1000 / n, top = 92, rowH = 38; const zoneOf = (id) => (L.zones || []).find((z) => z.ids.includes(id)); const H = Math.max(520, top + MSGS.length * rowH + 30); svg.setAttribute("viewBox", `0 0 1000 ${H}`); svg.style.height = H + "px"; let h = ``; const cx = {}; L.actors.forEach((a, i) => { const x = colW * i + colW / 2; cx[a.id] = x; h += `${esc(a.label)}${zoneOf(a.id) ? `${esc(zoneOf(a.id).label.toUpperCase())}` : ""}`; }); let lastStep = -1; MSGS.forEach((m, i) => { const y = top + 14 + i * rowH, x1 = cx[m.from], x2 = cx[m.to]; if (m.step !== lastStep) { h += `${m.step + 1}`; lastStep = m.step; } const vis = i <= st.idx, cur = i === st.idx; const label = m.kind === "req" || m.kind === "note" ? methodOf(m) : m.info; const dir = x2 > x1 ? 1 : -1; h += `${m.dep ? `DEPRECATED ` : ""}${esc(label.length > 44 ? label.slice(0, 43) + "…" : label)}`; }); svg.innerHTML = h; const curEl = svg.querySelector(".sm.cur"); if (curEl) { const wrap = $("#seqwrap"); const y = top + st.idx * rowH; if (y > wrap.scrollTop + wrap.clientHeight - 60 || y < wrap.scrollTop) wrap.scrollTo({ top: y - 120, behavior: "smooth" }); } } function renderAll(animate) { $(".stage").classList.toggle("absent", !!st.lesson.absent); renderHeader(); renderTimeline(); renderVariants(); renderList(); renderOverview(); renderInspector(); renderTable(); $("#viewnet").hidden = st.view !== "network"; $("#seqwrap").hidden = st.view !== "sequence"; document.querySelectorAll("[data-view]").forEach((b) => b.setAttribute("aria-pressed", b.dataset.view === st.view)); if (st.view === "network") { renderNetwork(); if (animate && MSGS[st.idx]) animatePacket(MSGS[st.idx]); } else renderSequence(); $("#btn-step").disabled = st.idx >= MSGS.length - 1; $("#flowcount").textContent = `${MSGS.length} messages · ${st.lesson.steps.length} steps`; const atEnd = st.idx >= MSGS.length - 1, li = LESSONS.findIndex((l) => l.id === st.lesson.id), nxt = LESSONS[(li + 1) % LESSONS.length]; $("#mb-play").innerHTML = st.playing ? `` : ``; $("#mb-play").setAttribute("aria-label", st.playing ? "Pause" : atEnd ? "Replay" : "Play"); $("#mb-prev").disabled = st.idx <= 0; $("#mb-next").disabled = atEnd; $("#mb-count").textContent = `${st.idx + 1}/${MSGS.length}`; $("#mb-lname").textContent = nxt.code; $("#mb-lesson").setAttribute("aria-label", "Next lesson: " + nxt.code); $("#btn-play").innerHTML = st.playing ? `Pause` : `${st.idx >= MSGS.length - 1 && st.idx >= 0 ? "Replay" : "Play"}`; } function renderTimeline() { $("#bv-name").textContent = "Spec " + st.version; $("#tm-cur").innerHTML = `${st.version}${st.version === "2026-07-28" ? " · latest" : st.version === SPEC ? " · default" : ""}`; $("#tm-line").innerHTML = VERSIONS.map(([v, short]) => ``).join(""); } let riskPinned = null; function showRisk(btn, pin) { const k = btn.dataset.risk, pop = $("#riskpop"); if (pin && riskPinned === btn) { hideRisk(); return; } pop.innerHTML = `${esc(RISKS[k].title)}

${esc(RISKS[k].text)}

See the attack →

`; pop.hidden = false; const r = btn.getBoundingClientRect(), w = Math.min(300, innerWidth - 24); pop.style.width = w + "px"; const left = Math.max(12, Math.min(innerWidth - w - 12, r.left + r.width / 2 - w / 2)); const below = r.bottom + 8 + pop.offsetHeight < innerHeight - 80; pop.style.left = left + "px"; pop.style.top = (below ? r.bottom + 8 : r.top - pop.offsetHeight - 8) + "px"; riskPinned = pin ? btn : null; } function hideRisk() { $("#riskpop").hidden = true; riskPinned = null; } document.addEventListener("pointerover", (e) => { const b = e.target.closest && e.target.closest(".risk"); if (b && e.pointerType === "mouse" && !riskPinned) showRisk(b, false); }); document.addEventListener("pointerout", (e) => { const b = e.target.closest && e.target.closest(".risk"); if (b && e.pointerType === "mouse" && !riskPinned && !(e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest("#riskpop"))) hideRisk(); }); document.addEventListener("pointerdown", (e) => { if (!e.target.closest("#riskpop, .risk")) hideRisk(); }); window.addEventListener("scroll", () => { if (!$("#riskpop").hidden) hideRisk(); }, true); function renderVariants() { const box = $("#variants"), vs = st.lesson.variants || []; box.hidden = !vs.length; box.innerHTML = vs.map((v) => ``).join(""); } // ── control function go(i, animate = true) { st.idx = Math.max(-1, Math.min(MSGS.length - 1, i)); st.selected = null; if (st.idx >= 0) st.seen.add(st.lesson.id); if (st.idx === MSGS.length - 1) st.done.add(st.lesson.id); renderAll(animate); } function stop() { st.playing = false; clearTimeout(st.timer); } function loop() { if (!st.playing) return; if (st.idx >= MSGS.length - 1) { stop(); renderAll(false); return; } go(st.idx + 1); const m = MSGS[st.idx], next = MSGS[st.idx + 1]; const pause = next && next.step !== m.step ? 2200 : 1300; st.timer = setTimeout(loop, pause / st.speed); } function play() { if (st.playing) { stop(); renderAll(false); return; } if (st.idx >= MSGS.length - 1) st.idx = -1; st.playing = true; loop(); } function setLesson(id, vid) { stop(); const base = LESSONS.find((l) => l.id === id) || LESSONS[0]; if (vid) variantOf[base.id] = vid; st.lesson = portrait(resolve(base, variantOf[base.id])); MSGS = flat(st.lesson); markRisks(MSGS, st.lesson); st.idx = -1; st.selected = null; if (anim) { cancelAnimationFrame(anim.raf); anim = null; } renderAll(false); const open = document.querySelector(".li.open"); if (open) { const list = $("#list"); const top = open.offsetTop - list.offsetTop - 8; if (top < list.scrollTop || top > list.scrollTop + list.clientHeight - 200) list.scrollTo({ top, behavior: "smooth" }); } $("#seqwrap").scrollTop = 0; go(0, false); } // ── events document.addEventListener("click", (e) => { const t = e.target.closest("button, tr[data-i], .sm"); if (!t) return; if (t.dataset.g) { st.group = t.dataset.g; renderList(); return; } if (t.dataset.risk) { e.stopPropagation(); showRisk(t, true); return; } if (t.dataset.l) { const L = $("#left"); if (L.classList.contains("expanded")) { L.classList.remove("expanded"); $("#btn-lessons").setAttribute("aria-expanded", "false"); } setLesson(t.dataset.l); if (matchMedia("(max-width:640px)").matches) $("#center").scrollIntoView({ behavior: "smooth", block: "start" }); return; } if (t.dataset.ver) { $("#left").classList.remove("ver-open"); $("#btn-ver").setAttribute("aria-expanded", "false"); st.version = t.dataset.ver; const vid = st.lesson.vid; setLesson(st.lesson.id, vid); writeHash(); return; } if (t.dataset.variant) { setLesson(st.lesson.id, t.dataset.variant); writeHash(); return; } if (t.dataset.step != null) { stop(); const i = MSGS.findIndex((m) => m.step === +t.dataset.step); go(i); return; } if (t.dataset.view) { st.view = t.dataset.view; renderAll(false); return; } if (t.dataset.i != null) { stop(); const i = +t.dataset.i; if (i <= st.idx) { st.selected = i; renderAll(false); renderInspector(); } else go(i); return; } switch (t.id) { case "btn-play": play(); break; case "mb-play": play(); break; case "mb-prev": stop(); go(st.idx - 1, false); break; case "mb-next": stop(); go(st.idx + 1); break; case "mb-log": { const on = document.body.classList.toggle("log-open"); t.setAttribute("aria-expanded", on); if (on) renderTable(); break; } case "mb-lesson": { const li = LESSONS.findIndex((l) => l.id === st.lesson.id); const nxt = LESSONS[(li + 1) % LESSONS.length]; history.replaceState(null, "", "#" + nxt.id); setLesson(nxt.id); window.scrollTo({ top: $("#center").offsetTop - 8, behavior: "smooth" }); break; } case "btn-step": stop(); go(st.idx + 1); break; case "btn-prev": stop(); go(st.idx - 1, false); break; case "btn-reset": stop(); go(-1, false); break; case "btn-speed": st.speed = st.speed === 1 ? 2 : st.speed === 2 ? 0.5 : 1; renderAll(false); break; case "btn-lessons": { const on = $("#left").classList.toggle("expanded"); $("#left").classList.remove("ver-open"); $("#btn-ver").setAttribute("aria-expanded", "false"); t.setAttribute("aria-expanded", on); $("#bl-name").textContent = on ? "Close" : st.lesson.code; break; } case "btn-ver": { const on = $("#left").classList.toggle("ver-open"); t.setAttribute("aria-expanded", on); if ($("#left").classList.contains("expanded")) { $("#left").classList.remove("expanded"); $("#btn-lessons").setAttribute("aria-expanded", "false"); $("#bl-name").textContent = st.lesson.code; } break; } case "btn-light": document.documentElement.dataset.theme = "light"; syncTheme(); break; case "btn-dark": document.documentElement.dataset.theme = "dark"; syncTheme(); break; } }); document.addEventListener("keydown", (e) => { if (e.target.closest("input, textarea")) return; if (e.key === "Escape" && !$("#riskpop").hidden) { hideRisk(); return; } if (e.key === "Escape" && document.body.classList.contains("log-open")) { document.body.classList.remove("log-open"); $("#mb-log").setAttribute("aria-expanded", "false"); return; } if (e.key === " ") { e.preventDefault(); play(); } else if (e.key === "ArrowRight") { stop(); go(st.idx + 1); } else if (e.key === "ArrowLeft") { stop(); go(st.idx - 1, false); } else if (e.key === "Enter" && e.target.matches("tr[data-i]")) e.target.click(); }); function syncTheme() { const d = document.documentElement.dataset.theme === "dark"; $("#btn-light").setAttribute("aria-pressed", !d); $("#btn-dark").setAttribute("aria-pressed", d); } // ── boot document.documentElement.dataset.theme = matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; syncTheme(); function parseHash() { const [a, ver] = location.hash.replace("#", "").split("@"); const [id, v] = a.split(":"); return { id, v, ver: VERSIONS.some(([x]) => x === ver) ? ver : SPEC }; } function writeHash() { history.replaceState(null, "", "#" + st.lesson.id + (st.lesson.vid && st.lesson.variants && st.lesson.vid !== st.lesson.variants[0].id ? ":" + st.lesson.vid : "") + (st.version !== SPEC ? "@" + st.version : "")); } window.addEventListener("hashchange", () => { const h = parseHash(); if (!LESSONS.some((l) => l.id === h.id)) return; if (location.hash.includes("@")) st.version = h.ver; setLesson(h.id, h.v); writeHash(); }); const H0 = parseHash(); st.version = H0.ver; setLesson(LESSONS.some((l) => l.id === H0.id) ? H0.id : "jsonrpc", H0.v); })();

联系我们 contact @ memedata.com