diff --git a/docs/.vitepress/authInfoLinks.test.ts b/docs/.vitepress/authInfoLinks.test.ts
new file mode 100644
index 0000000000..a185cb9ba6
--- /dev/null
+++ b/docs/.vitepress/authInfoLinks.test.ts
@@ -0,0 +1,32 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { AUTH_INFO_INTERFACE_ANCHOR, correctAuthInfoTypeHref, isAuthInfoInterfacePage } from './authInfoLinks.ts';
+
+const server = 'api/index/@modelcontextprotocol/server/index.md';
+const client = 'api/index/@modelcontextprotocol/client/index.md';
+const middleware = 'api/@modelcontextprotocol/server/server/middleware/bearerAuth.md';
+
+test('public client/server interface pages receive a stable target', () => {
+ assert.equal(AUTH_INFO_INTERFACE_ANCHOR, 'authinfo-interface');
+ assert.equal(isAuthInfoInterfacePage(server), true);
+ assert.equal(isAuthInfoInterfacePage(client), true);
+ assert.equal(isAuthInfoInterfacePage(middleware), false);
+});
+
+test('correct AuthInfo type links but never member links', () => {
+ const href = '../../../../index/@modelcontextprotocol/server/index.md#authinfo';
+ assert.equal(correctAuthInfoTypeHref(href, 'AuthInfo', middleware), href.replace('#authinfo', '#authinfo-interface'));
+ assert.equal(correctAuthInfoTypeHref(href, 'authInfo', middleware), href);
+ const sibling = '../../../../index/@modelcontextprotocol/server/index.md#authinfo-1';
+ assert.equal(correctAuthInfoTypeHref(sibling, 'AuthInfo', middleware), sibling);
+});
+
+test('preserve unrelated local anchors and targets', () => {
+ assert.equal(correctAuthInfoTypeHref('#authinfo', 'AuthInfo', server), '#authinfo-interface');
+ assert.equal(correctAuthInfoTypeHref('#authinfo', 'authInfo', server), '#authinfo');
+ assert.equal(correctAuthInfoTypeHref('#authinfo', 'AuthInfo', middleware), '#authinfo');
+ const unrelated = '../../../../auth/index.md#authinfo';
+ assert.equal(correctAuthInfoTypeHref(unrelated, 'AuthInfo', middleware), unrelated);
+ const clientHref = '../../../index/@modelcontextprotocol/client/index.md#authinfo';
+ assert.equal(correctAuthInfoTypeHref(clientHref, 'AuthInfo', middleware), clientHref.replace('#authinfo', '#authinfo-interface'));
+});
diff --git a/docs/.vitepress/authInfoLinks.ts b/docs/.vitepress/authInfoLinks.ts
new file mode 100644
index 0000000000..2e7b526db4
--- /dev/null
+++ b/docs/.vitepress/authInfoLinks.ts
@@ -0,0 +1,17 @@
+/** Stable anchor for the public AuthInfo interface, distinct from authInfo members. */
+export const AUTH_INFO_INTERFACE_ANCHOR = 'authinfo-interface';
+
+const typePage = /^api\/index\/@modelcontextprotocol\/(?:client|server)\/index\.md$/;
+const typeTarget = /(?:^|\/)index\/@modelcontextprotocol\/(?:client|server)\/index\.md$/;
+
+export function isAuthInfoInterfacePage(page: string): boolean {
+ return typePage.test(page);
+}
+
+/** Rewrite only links whose *text* names the AuthInfo type, never member links. */
+export function correctAuthInfoTypeHref(href: string, label: string | undefined, page: string): string {
+ if (label !== 'AuthInfo' || !href.endsWith('#authinfo')) return href;
+ const path = href.slice(0, -'#authinfo'.length);
+ if (path ? !typeTarget.test(path) : !isAuthInfoInterfacePage(page)) return href;
+ return `${path}#${AUTH_INFO_INTERFACE_ANCHOR}`;
+}
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index a027bfaafb..9ab11932b9 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -7,6 +7,7 @@ import { defineConfig, type DefaultTheme } from 'vitepress';
import { generateLlmsArtifacts } from './llms';
import { guideSidebar } from './nav';
import { githubSlug, rewriteSkillLink } from './skill';
+import { AUTH_INFO_INTERFACE_ANCHOR, correctAuthInfoTypeHref, isAuthInfoInterfacePage } from './authInfoLinks';
const docsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -44,11 +45,29 @@ export default defineConfig({
const orig = md.renderer.rules.link_open ?? ((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options));
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const href = tokens[idx].attrGet('href');
+ if (href) {
+ const corrected = correctAuthInfoTypeHref(href, tokens[idx + 1]?.content, env.relativePath);
+ if (corrected !== href) tokens[idx].attrSet('href', corrected);
+ }
if (href?.startsWith('/specification/')) {
tokens[idx].attrSet('href', `https://modelcontextprotocol.io${href}`);
}
return orig(tokens, idx, options, env, self);
};
+ // AuthInfo is a public interface, but earlier authInfo member headings win the
+ // auto-generated #authinfo slug. Keep all existing headings/links intact and
+ // give only the interface a stable second anchor.
+ md.core.ruler.push('authinfo_interface_anchor', state => {
+ if (!isAuthInfoInterfacePage(state.env.relativePath)) return;
+ const i = state.tokens.findIndex(
+ (token, index) => token.type === 'heading_open' && state.tokens[index + 1]?.content === 'AuthInfo'
+ );
+ if (i < 0) return;
+ const anchor = new state.Token('html_block', '', 0);
+ anchor.content = `\n`;
+ state.tokens.splice(i, 0, anchor);
+ });
+
// Resolve skill links on the assembled page, then GitHub-style anchors to VitePress's heading ids.
md.core.ruler.push('page_links', state => {
const ids = new Set();
diff --git a/package.json b/package.json
index 0be02df4ee..cdf17cdccd 100644
--- a/package.json
+++ b/package.json
@@ -33,7 +33,7 @@
"docs:dev": "vitepress dev docs",
"docs:build": "pnpm docs:api && vitepress build docs",
"docs:multi": "bash scripts/build-docs-site.sh",
- "docs:check": "pnpm docs:build",
+ "docs:check": "tsx --test docs/.vitepress/authInfoLinks.test.ts && pnpm docs:build",
"typecheck:all": "pnpm -r typecheck",
"build:all": "pnpm -r build",
"ci:publish": "pnpm run build:all && pnpm changeset publish",