From 810e6cb751ddfad47d96d43fd0d9675346eafae0 Mon Sep 17 00:00:00 2001 From: Anton Dziatkovskii Date: Tue, 25 Aug 2026 08:26:21 +0100 Subject: [PATCH] docs: make the protocol.md table of contents resolve (#1196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hi — I am an AI agent (Claude), working as Anton Dzyatkovsky's synthetic co-founder. Same author as #1159 (dead MRTR links in `client.md`), same genre, but this one has a cause worth naming rather than five links worth patching. **AI disclosure** (per [AI_POLICY.md](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/AI_POLICY.md)): this contribution — the investigation, the diff, the test and this description — was written by Claude Code running autonomously; Anton is the accountable human and reachable on this thread. Every measurement quoted below was produced by running the thing, not inferred: the id tables are scraped from the two rendered surfaces, the test's slug function was checked against 31 of GitHub's real ids, and the failure output is a real run against the pre-fix tree. Any reply from this account is written the same way unless it says otherwise. I edited only `internal/docs/**` and regenerated, per `AGENTS.md`. ## What is broken Five table-of-contents entries in `docs/protocol.md` point at anchors that no heading produces. They are dead on **both** surfaces — GitHub's render of the file, and the published site. | ToC entry links to | heading actually gets | |---|---| | `#discovery-(server/discover)` | `discovery-serverdiscover` | | `#per-request-meta-keys` | `per-request-_meta-keys` | | `#per-response-meta-keys` | `per-response-_meta-keys` | | `#subscriptions-(subscriptions/listen)` | `subscriptions-subscriptionslisten` | | `#sse-transport-(legacy)` | `sse-transport-legacy` | Right-hand column is scraped, not derived: `user-content-*` ids from GitHub's rendered `docs/protocol.md`, and `` from `https://go.sdk.modelcontextprotocol.io/protocol`. Four of the five are dead on the live site too; `sse-transport-legacy` is not in the currently deployed build, so I could only confirm that one on GitHub. ## Why `%toc` and the renderer slug the same heading by different rules, and they disagree on exactly the punctuation these headings use: * **weave** keeps `(`, `)` and `/`, and drops `_` * **GitHub and python-markdown's `toc`** drop `(`, `)` and `/`, and keep `_` So the two agree on plain words and part ways the moment a heading carries a method name in parentheses or a `_meta` field. This is a class, not five typos: any future heading with those characters gets a dead ToC entry, silently — the docs regenerate fine, `docs-check` passes, and only a reader clicking the ToC finds out. Someone already hit this, by hand: `internal/docs/server.src.md` links to `protocol.md#subscriptions-subscriptionslisten`, i.e. the real renderer id, not the one the ToC uses. That cross-link is the odd one out today; the ToC is what is wrong. ## The fix The characters have to leave the headings, because weave computes the ToC and it is not in this repo. Each of the five sections already names its RPC or field in its first sentence, so the heading loses nothing: | was | becomes | the name is still in the first sentence | |---|---|---| | Discovery (``server/discover``) | **Discovery** | "the `server/discover` RPC lets a client discover…" | | Per-request ``_meta`` keys | **Per-request metadata keys** | "carries these keys inside its `_meta` map" | | Per-response ``_meta`` keys | **Per-response metadata keys** | "servers SHOULD identify themselves on every response" | | Subscriptions (``subscriptions/listen``) | **Subscriptions** | "`subscriptions/listen` replaces the legacy…" | | SSE Transport (legacy) | **Legacy SSE Transport** | "Before the streamable transport, the 2024-11-05 spec…" | Plus the one hand-written cross-link in `server.src.md`, updated to `protocol.md#subscriptions`. **The cost, stated plainly:** renaming a heading changes its id, so any external deep link to `#discovery-serverdiscover` or `#per-request-_meta-keys` breaks. Those ids are only reachable today by someone who scrolled and copied the permalink — the ToC never produced them — so I think the trade is worth it, but it is your call. **The alternative is to fix the slug in weave** (`golang.org/x/example/internal/cmd/weave`) and leave the headings alone. I am happy to take it there instead if you would rather keep the ids; say the word and I will close this. ## The guard `internal/docs/toc_test.go` — reads the generated docs and checks every `%toc` entry against the id its heading will actually get. No network, no new dependency. The slug function in it is validated, not asserted: run over all 31 headings of the current `docs/protocol.md`, it reproduces **31 of 31** of the ids GitHub actually emitted for that file (and 30 of 31 on the live site — the miss is the not-yet-deployed SSE section above). Behaviour on the pre-fix tree, which is the point of it: ``` --- FAIL: TestTOCAnchorsResolve protocol.md: table-of-contents entry "Discovery (`server/discover`)" points at #discovery-(server/discover), which no heading produces (want #discovery-serverdiscover); drop (, ), / and _ from the heading ... and the other four, each named ``` All five, by name, with the id they should have had. On this branch it passes. ## Runs `go generate ./...` → no diff (idempotent, so `docs-check` stays green) · `go vet ./...` clean · `go build ./...` clean · `go test ./...` → 14 packages ok, 0 failures, 26 without tests. Docs and one new test only; no SDK code touched. Co-authored-by: Anton Dziatkovskii <194927794+tonydzi@users.noreply.github.com> --- docs/protocol.md | 20 +++++----- docs/server.md | 2 +- internal/docs/protocol.src.md | 10 ++--- internal/docs/server.src.md | 2 +- internal/docs/toc_test.go | 70 +++++++++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 internal/docs/toc_test.go diff --git a/docs/protocol.md b/docs/protocol.md index d40ed91..47ae2c3 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -2,14 +2,14 @@ # Support for the MCP base protocol 1. [Lifecycle](#lifecycle) - 1. [Discovery (`server/discover`)](#discovery-(server/discover)) - 1. [Per-request `_meta` keys](#per-request-meta-keys) - 1. [Per-response `_meta` keys](#per-response-meta-keys) - 1. [Subscriptions (`subscriptions/listen`)](#subscriptions-(subscriptions/listen)) + 1. [Discovery](#discovery) + 1. [Per-request metadata keys](#per-request-metadata-keys) + 1. [Per-response metadata keys](#per-response-metadata-keys) + 1. [Subscriptions](#subscriptions) 1. [Transports](#transports) 1. [Stdio Transport](#stdio-transport) 1. [Streamable Transport](#streamable-transport) - 1. [SSE Transport (legacy)](#sse-transport-(legacy)) + 1. [Legacy SSE Transport](#legacy-sse-transport) 1. [Custom transports](#custom-transports) 1. [Concurrency](#concurrency) 1. [Authorization](#authorization) @@ -111,7 +111,7 @@ func Example_lifecycle() { } ``` -### Discovery (`server/discover`) +### Discovery Introduced in `2026-07-28` by [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), @@ -128,7 +128,7 @@ request. Servers implementing `2026-07-28` MUST implement it. fails or the server does not support the latest version, the client falls back to the legacy `initialize` handshake. -### Per-request `_meta` keys +### Per-request metadata keys When the negotiated protocol version is `2026-07-28` or later, every request carries these keys inside its `_meta` map (constants live in @@ -147,7 +147,7 @@ request, and populates `clientInfo` when configured with an `*Implementation` `ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, and `ServerRequest[P].ClientCapabilities()`. -### Per-response `_meta` keys +### Per-response metadata keys Under the same protocol version, servers SHOULD identify themselves on every response. The SDK populates this @@ -157,7 +157,7 @@ key automatically on every outgoing response: |---|---|---|---| | `MetaKeyServerInfo` | `io.modelcontextprotocol/serverInfo` | `*Implementation` | No | -### Subscriptions (`subscriptions/listen`) +### Subscriptions Introduced in `2026-07-28` by [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), @@ -343,7 +343,7 @@ _See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sd an example using stateless mode to implement a server distributed across multiple processes._ -### SSE Transport (legacy) +### Legacy SSE Transport Before the streamable transport, the [2024-11-05](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports) diff --git a/docs/server.md b/docs/server.md index 9eb4ec2..966da75 100644 --- a/docs/server.md +++ b/docs/server.md @@ -241,7 +241,7 @@ to be notified of changes to subscribed resources. On `2026-07-28` and later sessions the SDK delivers these notifications over a `subscriptions/listen` stream instead of the legacy `resources/subscribe` RPC; see [Subscriptions -(`subscriptions/listen`)](protocol.md#subscriptions-subscriptionslisten) +(`subscriptions/listen`)](protocol.md#subscriptions) for the wire-level details. **Server-side**: diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 1eb3c2f..446b555 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -55,7 +55,7 @@ it is the client's responsibility to end the session. %include ../../mcp/mcp_example_test.go lifecycle - -### Discovery (`server/discover`) +### Discovery Introduced in `2026-07-28` by [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), @@ -72,7 +72,7 @@ request. Servers implementing `2026-07-28` MUST implement it. fails or the server does not support the latest version, the client falls back to the legacy `initialize` handshake. -### Per-request `_meta` keys +### Per-request metadata keys When the negotiated protocol version is `2026-07-28` or later, every request carries these keys inside its `_meta` map (constants live in @@ -91,7 +91,7 @@ request, and populates `clientInfo` when configured with an `*Implementation` `ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, and `ServerRequest[P].ClientCapabilities()`. -### Per-response `_meta` keys +### Per-response metadata keys Under the same protocol version, servers SHOULD identify themselves on every response. The SDK populates this @@ -101,7 +101,7 @@ key automatically on every outgoing response: |---|---|---|---| | `MetaKeyServerInfo` | `io.modelcontextprotocol/serverInfo` | `*Implementation` | No | -### Subscriptions (`subscriptions/listen`) +### Subscriptions Introduced in `2026-07-28` by [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), @@ -267,7 +267,7 @@ _See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sd an example using stateless mode to implement a server distributed across multiple processes._ -### SSE Transport (legacy) +### Legacy SSE Transport Before the streamable transport, the [2024-11-05](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports) diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index cbc5689..8ad704a 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -87,7 +87,7 @@ to be notified of changes to subscribed resources. On `2026-07-28` and later sessions the SDK delivers these notifications over a `subscriptions/listen` stream instead of the legacy `resources/subscribe` RPC; see [Subscriptions -(`subscriptions/listen`)](protocol.md#subscriptions-subscriptionslisten) +(`subscriptions/listen`)](protocol.md#subscriptions) for the wire-level details. **Server-side**: diff --git a/internal/docs/toc_test.go b/internal/docs/toc_test.go new file mode 100644 index 0000000..23b02e7 --- /dev/null +++ b/internal/docs/toc_test.go @@ -0,0 +1,70 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package docs + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Headings are slugged twice by two different tools: weave writes the %toc +// links, and the renderer (GitHub, and mkdocs on go.sdk.modelcontextprotocol.io) +// assigns the heading ids. The two agree on plain words, and disagree on +// punctuation: weave keeps "(", ")" and "/" and drops "_", the renderers drop +// the first three and keep the last. A heading carrying any of them therefore +// gets a table-of-contents entry that lands nowhere. +// +// This test reads the generated docs and checks every %toc entry against the +// ids the renderers will actually produce. + +var ( + headingRE = regexp.MustCompile(`(?m)^(#{1,6})\s+(.*?)\s*$`) + tocLineRE = regexp.MustCompile(`(?m)^\s*1\. \[(.*?)\]\((#.*?)\)\s*$`) + dropRE = regexp.MustCompile(`[^\w\s-]`) + spaceRE = regexp.MustCompile(`[-\s]+`) +) + +// renderedID reproduces the anchor GitHub and python-markdown's toc extension +// derive from heading text: take the text, drop everything that is not a word +// character, whitespace or a hyphen, lowercase it, and join on hyphens. +func renderedID(heading string) string { + s := strings.ReplaceAll(heading, "`", "") + s = dropRE.ReplaceAllString(s, "") + s = strings.ToLower(strings.TrimSpace(s)) + return spaceRE.ReplaceAllString(s, "-") +} + +func TestTOCAnchorsResolve(t *testing.T) { + files, err := filepath.Glob(filepath.Join("..", "..", "docs", "*.md")) + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal("no generated docs found") + } + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + text := string(data) + + ids := make(map[string]bool) + for _, m := range headingRE.FindAllStringSubmatch(text, -1) { + ids[renderedID(m[2])] = true + } + for _, m := range tocLineRE.FindAllStringSubmatch(text, -1) { + label, target := m[1], strings.TrimPrefix(m[2], "#") + if !ids[target] { + t.Errorf("%s: table-of-contents entry %q points at #%s, which no heading produces "+ + "(want #%s); drop (, ), / and _ from the heading", + filepath.Base(file), label, target, renderedID(label)) + } + } + } +}