Skip to content

Commit 8c40182

Browse files
authored
feat: add multiple edgecase for mcp spec (#190)
* feat: add multiple edgecase for mcp spec * chore: format * chore: review
1 parent 1e07ddc commit 8c40182

8 files changed

Lines changed: 300 additions & 37 deletions

File tree

packages/docs/src/agent.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
buildDocsAgentDiscoverySpec,
4+
buildDocsMcpEndpointCandidates,
45
findDocsMarkdownPage,
56
getDocsMarkdownCanonicalLinkHeader,
67
getDocsMarkdownVaryHeader,
@@ -268,6 +269,56 @@ describe("agent route helpers", () => {
268269
).toBe(false);
269270
});
270271

272+
it("builds MCP endpoint probes for default routes, origin fallback, and MCP subdomains", () => {
273+
expect(
274+
buildDocsMcpEndpointCandidates("https://docs.example.com/docs").map(
275+
(candidate) => candidate.url,
276+
),
277+
).toEqual([
278+
"https://docs.example.com/docs/mcp",
279+
"https://docs.example.com/docs/.well-known/mcp",
280+
"https://docs.example.com/mcp",
281+
"https://docs.example.com/.well-known/mcp",
282+
"https://example.com/mcp",
283+
"https://example.com/.well-known/mcp",
284+
"https://mcp.example.com/mcp",
285+
"https://mcp.example.com/",
286+
]);
287+
288+
expect(
289+
buildDocsMcpEndpointCandidates("https://example.com/docs").map((candidate) => candidate.url),
290+
).toEqual([
291+
"https://example.com/docs/mcp",
292+
"https://example.com/docs/.well-known/mcp",
293+
"https://example.com/mcp",
294+
"https://example.com/.well-known/mcp",
295+
"https://mcp.example.com/mcp",
296+
"https://mcp.example.com/",
297+
]);
298+
299+
expect(
300+
buildDocsMcpEndpointCandidates("https://mcp.example.com").map((candidate) => candidate.url),
301+
).toEqual([
302+
"https://mcp.example.com/mcp",
303+
"https://mcp.example.com/.well-known/mcp",
304+
"https://example.com/mcp",
305+
"https://example.com/.well-known/mcp",
306+
"https://mcp.example.com/",
307+
]);
308+
309+
expect(
310+
buildDocsMcpEndpointCandidates("https://docs.example.co.uk").map(
311+
(candidate) => candidate.url,
312+
),
313+
).toContain("https://mcp.example.co.uk/mcp");
314+
315+
const koreaCandidates = buildDocsMcpEndpointCandidates("https://docs.example.co.kr").map(
316+
(candidate) => candidate.url,
317+
);
318+
expect(koreaCandidates).toContain("https://mcp.example.co.kr/mcp");
319+
expect(koreaCandidates).not.toContain("https://mcp.co.kr/mcp");
320+
});
321+
271322
it("resolves markdown route and Accept-header requests", () => {
272323
const markdownRoute = resolveDocsMarkdownRequest(
273324
"docs",

packages/docs/src/agent.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,43 @@ export const DEFAULT_AGENT_FEEDBACK_PAYLOAD_SCHEMA: Record<string, unknown> = {
8686
export const DOCS_MARKDOWN_SIGNATURE_AGENT_HEADER = "Signature-Agent";
8787
const DOCS_LLMS_TXT_DIRECTIVE_LINE = "LLM index: /llms.txt";
8888

89+
const DOCS_MCP_SERVICE_SUBDOMAIN_LABELS = new Set([
90+
"api",
91+
"developer",
92+
"developers",
93+
"dev",
94+
"docs",
95+
"help",
96+
"mcp",
97+
"reference",
98+
]);
99+
const COMMON_SECOND_LEVEL_PUBLIC_SUFFIX_LABELS = new Set([
100+
"ac",
101+
"co",
102+
"com",
103+
"edu",
104+
"go",
105+
"gov",
106+
"mil",
107+
"ne",
108+
"net",
109+
"or",
110+
"org",
111+
]);
112+
113+
export interface DocsMcpEndpointCandidate {
114+
baseUrl: string;
115+
route: string;
116+
url: string;
117+
label: string;
118+
}
119+
120+
export interface DocsMcpEndpointCandidateOptions {
121+
includeOriginFallback?: boolean;
122+
includeRootDomainFallback?: boolean;
123+
includeMcpSubdomainFallback?: boolean;
124+
}
125+
89126
export interface DocsAgentFeedbackResolvedConfig {
90127
enabled: boolean;
91128
route: string;
@@ -845,6 +882,142 @@ export function isDocsMcpRequest(url: URL): boolean {
845882
);
846883
}
847884

885+
export function buildDocsMcpEndpointCandidates(
886+
baseUrl: string,
887+
routes: readonly string[] = [DEFAULT_MCP_PUBLIC_ROUTE, DEFAULT_MCP_WELL_KNOWN_ROUTE],
888+
options: DocsMcpEndpointCandidateOptions = {},
889+
): DocsMcpEndpointCandidate[] {
890+
const includeOriginFallback = options.includeOriginFallback !== false;
891+
const includeRootDomainFallback = options.includeRootDomainFallback !== false;
892+
const includeMcpSubdomainFallback = options.includeMcpSubdomainFallback !== false;
893+
const base = new URL(baseUrl);
894+
const primaryOrigin = base.origin;
895+
const seen = new Set<string>();
896+
const candidates: DocsMcpEndpointCandidate[] = [];
897+
898+
const addCandidate = (candidateBaseUrl: string, route: string) => {
899+
const resolved = resolveDocsMcpCandidateUrl(candidateBaseUrl, route);
900+
if (seen.has(resolved.url)) return;
901+
seen.add(resolved.url);
902+
candidates.push({
903+
...resolved,
904+
label: formatDocsMcpCandidateLabel(resolved.url, primaryOrigin),
905+
});
906+
};
907+
908+
for (const route of routes) {
909+
addCandidate(baseUrl, route);
910+
}
911+
912+
const originBaseUrl = primaryOrigin;
913+
if (includeOriginFallback && originBaseUrl !== baseUrl.replace(/\/+$/, "")) {
914+
for (const route of routes) {
915+
addCandidate(originBaseUrl, route);
916+
}
917+
}
918+
919+
if (includeRootDomainFallback) {
920+
for (const rootDomainBaseUrl of toDocsRootDomainBaseUrls(base)) {
921+
for (const route of routes) {
922+
addCandidate(rootDomainBaseUrl, route);
923+
}
924+
}
925+
}
926+
927+
if (includeMcpSubdomainFallback) {
928+
for (const mcpBaseUrl of toDocsMcpSubdomainBaseUrls(base)) {
929+
addCandidate(mcpBaseUrl, DEFAULT_MCP_PUBLIC_ROUTE);
930+
addCandidate(mcpBaseUrl, "/");
931+
}
932+
}
933+
934+
return candidates;
935+
}
936+
937+
function resolveDocsMcpCandidateUrl(
938+
baseUrl: string,
939+
route: string,
940+
): { baseUrl: string; route: string; url: string } {
941+
if (/^https?:\/\//i.test(route)) {
942+
const parsed = new URL(route);
943+
const path = `${parsed.pathname || "/"}${parsed.search}`;
944+
return {
945+
baseUrl: parsed.origin,
946+
route: path,
947+
url: parsed.toString(),
948+
};
949+
}
950+
951+
const base = new URL(baseUrl);
952+
const basePath = base.pathname.replace(/\/+$/, "");
953+
const routePath = route.startsWith("/") ? route : `/${route}`;
954+
const parsed = new URL(`${basePath}${routePath}`, base.origin);
955+
956+
return {
957+
baseUrl: parsed.origin,
958+
route: `${parsed.pathname}${parsed.search}`,
959+
url: parsed.toString(),
960+
};
961+
}
962+
963+
function formatDocsMcpCandidateLabel(url: string, primaryOrigin: string): string {
964+
const parsed = new URL(url);
965+
const path = `${parsed.pathname}${parsed.search}`;
966+
return parsed.origin === primaryOrigin ? path : `${parsed.origin}${path}`;
967+
}
968+
969+
function toDocsMcpSubdomainBaseUrls(base: URL): string[] {
970+
return getDocsMcpRootDomainCandidates(base.hostname).map(
971+
(rootDomain) => `${base.protocol}//mcp.${rootDomain}${base.port ? `:${base.port}` : ""}`,
972+
);
973+
}
974+
975+
function toDocsRootDomainBaseUrls(base: URL): string[] {
976+
return getDocsMcpRootDomainCandidates(base.hostname).map(
977+
(rootDomain) => `${base.protocol}//${rootDomain}${base.port ? `:${base.port}` : ""}`,
978+
);
979+
}
980+
981+
function getDocsMcpRootDomainCandidates(hostname: string): string[] {
982+
const normalized = hostname
983+
.toLowerCase()
984+
.replace(/^\[|\]$/g, "")
985+
.replace(/\.$/, "");
986+
if (!normalized || !normalized.includes(".") || isDocsIpHostname(normalized)) return [];
987+
988+
const labels = normalized.split(".").filter(Boolean);
989+
if (labels.length < 2) return [];
990+
991+
const candidates: string[] = [];
992+
const addCandidate = (candidateLabels: string[]) => {
993+
if (candidateLabels.length < 2) return;
994+
const candidate = candidateLabels.join(".");
995+
if (!candidates.includes(candidate)) candidates.push(candidate);
996+
};
997+
998+
if (labels.length >= 3 && DOCS_MCP_SERVICE_SUBDOMAIN_LABELS.has(labels[0] ?? "")) {
999+
addCandidate(labels.slice(1));
1000+
}
1001+
1002+
const tld = labels.at(-1) ?? "";
1003+
const secondLevel = labels.at(-2) ?? "";
1004+
const shouldPreserveSecondLevelSuffix =
1005+
labels.length >= 3 &&
1006+
tld.length === 2 &&
1007+
COMMON_SECOND_LEVEL_PUBLIC_SUFFIX_LABELS.has(secondLevel);
1008+
1009+
if (shouldPreserveSecondLevelSuffix) {
1010+
addCandidate(labels.slice(-3));
1011+
} else {
1012+
addCandidate(labels.slice(-2));
1013+
}
1014+
return candidates;
1015+
}
1016+
1017+
function isDocsIpHostname(hostname: string): boolean {
1018+
return /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname) || hostname.includes(":");
1019+
}
1020+
8481021
export function isDocsSkillRequest(url: URL): boolean {
8491022
const pathname = normalizeDocsUrlPath(url.pathname);
8501023
if (pathname === DEFAULT_SKILL_MD_ROUTE || pathname === DEFAULT_SKILL_MD_WELL_KNOWN_ROUTE) {

packages/docs/src/cli/doctor.ts

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
DEFAULT_MCP_WELL_KNOWN_ROUTE,
1313
DEFAULT_SKILL_MD_ROUTE,
1414
DEFAULT_SKILL_MD_WELL_KNOWN_ROUTE,
15+
buildDocsMcpEndpointCandidates,
1516
} from "../agent.js";
1617
import { createFilesystemDocsMcpSource, resolveDocsMcpConfig } from "../server.js";
1718
import {
@@ -1519,6 +1520,27 @@ async function probeMcpRoute(
15191520
}
15201521
}
15211522

1523+
async function probeMcpRouteCandidates(
1524+
baseUrl: string,
1525+
routes: string[],
1526+
): Promise<{ labels: string[]; probes: Array<{ ok: boolean; detail: string }> }> {
1527+
const candidates = buildDocsMcpEndpointCandidates(baseUrl, routes);
1528+
const probes = await Promise.all(
1529+
candidates.map(async (candidate) => {
1530+
const probe = await probeMcpRoute(candidate.baseUrl, candidate.route);
1531+
return {
1532+
...probe,
1533+
detail: `${candidate.label}: ${probe.detail}`,
1534+
};
1535+
}),
1536+
);
1537+
1538+
return {
1539+
labels: candidates.map((candidate) => candidate.label),
1540+
probes,
1541+
};
1542+
}
1543+
15221544
function asRecord(value: unknown): Record<string, unknown> | undefined {
15231545
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
15241546
}
@@ -1562,6 +1584,25 @@ function hostedRobotsRoute(discoveryBody: unknown): { enabled: boolean; route: s
15621584
};
15631585
}
15641586

1587+
function hostedMcpRoutes(discoveryBody: unknown): string[] {
1588+
const mcp = asRecord(asRecord(discoveryBody)?.mcp);
1589+
const publicEndpoints = (mcp?.publicEndpoints ?? mcp?.endpoints) as unknown;
1590+
const declaredRoutes = Array.isArray(publicEndpoints)
1591+
? publicEndpoints.filter(
1592+
(value): value is string => typeof value === "string" && value.startsWith("/"),
1593+
)
1594+
: [];
1595+
1596+
if (declaredRoutes.length > 0) return Array.from(new Set(declaredRoutes));
1597+
1598+
return Array.from(
1599+
new Set([
1600+
readDiscoveryRoute(mcp?.publicEndpoint) ?? DEFAULT_MCP_PUBLIC_ROUTE,
1601+
readDiscoveryRoute(mcp?.wellKnownEndpoint) ?? DEFAULT_MCP_WELL_KNOWN_ROUTE,
1602+
]),
1603+
);
1604+
}
1605+
15651606
function hostedCapability(discoveryBody: unknown, key: string): boolean | undefined {
15661607
const root = asRecord(discoveryBody);
15671608
const capabilities = asRecord(root?.capabilities);
@@ -1954,22 +1995,20 @@ async function buildHostedAgentChecks(
19541995
),
19551996
);
19561997

1957-
const mcp = await Promise.all([
1958-
probeMcpRoute(baseUrl, DEFAULT_MCP_PUBLIC_ROUTE),
1959-
probeMcpRoute(baseUrl, DEFAULT_MCP_WELL_KNOWN_ROUTE),
1960-
]);
1961-
const mcpPassed = mcp.filter((result) => result.ok).length;
1998+
const mcp = await probeMcpRouteCandidates(baseUrl, hostedMcpRoutes(discovery.body));
1999+
const mcpPassed = mcp.probes.filter((result) => result.ok).length;
2000+
const mcpDetailProbes = mcpPassed > 0 ? mcp.probes.filter((result) => result.ok) : mcp.probes;
19622001
checks.push(
19632002
makeCheck(
19642003
"hosted-mcp",
19652004
"Hosted MCP handshake",
1966-
mcpPassed === mcp.length ? "pass" : mcpPassed > 0 ? "warn" : "fail",
1967-
mcpPassed === mcp.length ? 10 : mcpPassed > 0 ? 5 : 0,
2005+
mcpPassed > 0 ? "pass" : "fail",
2006+
mcpPassed > 0 ? 10 : 0,
19682007
10,
1969-
mcp.map((result) => result.detail).join(" "),
1970-
mcpPassed === mcp.length
2008+
mcpDetailProbes.map((result) => result.detail).join(" "),
2009+
mcpPassed > 0
19712010
? undefined
1972-
: `Verify deployed ${DEFAULT_MCP_PUBLIC_ROUTE} and ${DEFAULT_MCP_WELL_KNOWN_ROUTE} support Streamable HTTP initialize and tools/list.`,
2011+
: `Verify one of ${mcp.labels.join(" or ")} supports Streamable HTTP initialize and tools/list.`,
19732012
),
19742013
);
19752014

packages/docs/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export {
7878
DOCS_MARKDOWN_SIGNATURE_AGENT_HEADER,
7979
buildDocsAgentDiscoverySpec,
8080
buildDocsAgentFeedbackSchema,
81+
buildDocsMcpEndpointCandidates,
8182
findDocsMarkdownPage,
8283
getDocsMarkdownCanonicalLinkHeader,
8384
getDocsMarkdownVaryHeader,
@@ -121,6 +122,8 @@ export type {
121122
DocsLlmsTxtResolvedMaxChars,
122123
DocsLlmsTxtResolvedSection,
123124
DocsLlmsTxtSelectedContent,
125+
DocsMcpEndpointCandidate,
126+
DocsMcpEndpointCandidateOptions,
124127
DocsOpenApiDiscoveryConfig,
125128
DocsOpenApiResolvedDiscoveryConfig,
126129
} from "./agent.js";

skills/farming-labs/cli/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,8 @@ With `--url`, `docs doctor --agent` also probes the deployed public agent surfac
449449
- one representative `.md` page route, such as `/docs.md`
450450
- `/mcp`
451451
- `/.well-known/mcp`
452+
- `https://mcp.<your-domain>/mcp`
453+
- `https://mcp.<your-domain>/`
452454

453455
For hosted MCP, the command performs a Streamable HTTP initialize handshake, checks for
454456
`mcp-session-id`, calls `tools/list`, and expects `list_pages`, `get_navigation`, `search_docs`,

website/app/docs/cli/page.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,8 @@ The hosted pass probes:
566566
- sampled docs page HTML for `<link rel="alternate" type="text/markdown">`
567567
- `/mcp`
568568
- `/.well-known/mcp`
569+
- `https://mcp.<your-domain>/mcp`
570+
- `https://mcp.<your-domain>/`
569571

570572
For MCP, the doctor performs a Streamable HTTP `initialize` request, reuses `mcp-session-id` when
571573
the server returns one, sends `tools/list`, and expects the built-in docs tools:

website/app/docs/guides/agent-friendly-docs/page.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,8 @@ pnpm exec docs doctor --agent --url https://docs.example.com
431431

432432
That hosted pass checks discovery, `llms.txt`, sitemap routes, `skill.md`, representative `.md`
433433
pages, canonical markdown response headers, `robots.txt`, JSON-LD structured data, markdown
434-
alternate head links, and MCP at both `/mcp` and `/.well-known/mcp`.
434+
alternate head links, and MCP at `/mcp`, `/.well-known/mcp`,
435+
`https://mcp.<your-domain>/mcp`, or `https://mcp.<your-domain>/`.
435436

436437
If you want the same public check without leaving the browser, use the hosted
437438
[Agent readiness score](/score) page:
@@ -445,7 +446,7 @@ framework probes when the site exposes `/.well-known/agent.json`. The public sco
445446
strict `.md` route probe that samples docs page routes and verifies that appending `.md` returns
446447
markdown, so `llms.txt` markdown mirrors do not hide missing `/docs/foo.md` routes. The
447448
framework probes cover the discovery spec, full-context files, sitemap routes, `robots.txt`,
448-
`skill.md`, MCP, search, feedback, JSON-LD structured data on sampled pages, canonical `Link`
449+
`skill.md`, same-domain or MCP-subdomain MCP, search, feedback, JSON-LD structured data on sampled pages, canonical `Link`
449450
headers on markdown responses, and the `<link rel="alternate" type="text/markdown">` head links
450451
that point agents to each page's `.md` route. Existing leaderboard entries hydrate from the saved
451452
report, so a shared score URL can be reviewed without triggering a new calculation unless no saved

0 commit comments

Comments
 (0)