Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions packages/docs/src/agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
buildDocsAgentDiscoverySpec,
buildDocsMcpEndpointCandidates,
findDocsMarkdownPage,
getDocsMarkdownCanonicalLinkHeader,
getDocsMarkdownVaryHeader,
Expand Down Expand Up @@ -268,6 +269,50 @@ describe("agent route helpers", () => {
).toBe(false);
});

it("builds MCP endpoint probes for default routes, origin fallback, and MCP subdomains", () => {
expect(
buildDocsMcpEndpointCandidates("https://docs.example.com/docs").map(
(candidate) => candidate.url,
),
).toEqual([
"https://docs.example.com/docs/mcp",
"https://docs.example.com/docs/.well-known/mcp",
"https://docs.example.com/mcp",
"https://docs.example.com/.well-known/mcp",
"https://example.com/mcp",
"https://example.com/.well-known/mcp",
"https://mcp.example.com/mcp",
"https://mcp.example.com/",
]);

expect(
buildDocsMcpEndpointCandidates("https://example.com/docs").map((candidate) => candidate.url),
).toEqual([
"https://example.com/docs/mcp",
"https://example.com/docs/.well-known/mcp",
"https://example.com/mcp",
"https://example.com/.well-known/mcp",
"https://mcp.example.com/mcp",
"https://mcp.example.com/",
]);

expect(
buildDocsMcpEndpointCandidates("https://mcp.example.com").map((candidate) => candidate.url),
).toEqual([
"https://mcp.example.com/mcp",
"https://mcp.example.com/.well-known/mcp",
"https://example.com/mcp",
"https://example.com/.well-known/mcp",
"https://mcp.example.com/",
]);

expect(
buildDocsMcpEndpointCandidates("https://docs.example.co.uk").map(
(candidate) => candidate.url,
),
).toContain("https://mcp.example.co.uk/mcp");
});

it("resolves markdown route and Accept-header requests", () => {
const markdownRoute = resolveDocsMarkdownRequest(
"docs",
Expand Down
153 changes: 153 additions & 0 deletions packages/docs/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,44 @@ export const DEFAULT_AGENT_FEEDBACK_PAYLOAD_SCHEMA: Record<string, unknown> = {
export const DOCS_MARKDOWN_SIGNATURE_AGENT_HEADER = "Signature-Agent";
const DOCS_LLMS_TXT_DIRECTIVE_LINE = "LLM index: /llms.txt";

const COMMON_MULTI_PART_PUBLIC_SUFFIXES = new Set([
"ac.uk",
"co.in",
"co.jp",
"co.nz",
"co.uk",
"com.au",
"com.br",
"com.cn",
"com.mx",
"com.sg",
"com.tr",
"com.tw",
"gov.uk",
"net.au",
"net.br",
"net.cn",
"net.nz",
"org.au",
"org.br",
"org.cn",
"org.nz",
"org.uk",
]);

export interface DocsMcpEndpointCandidate {
baseUrl: string;
route: string;
url: string;
label: string;
}

export interface DocsMcpEndpointCandidateOptions {
includeOriginFallback?: boolean;
includeRootDomainFallback?: boolean;
includeMcpSubdomainFallback?: boolean;
}

export interface DocsAgentFeedbackResolvedConfig {
enabled: boolean;
route: string;
Expand Down Expand Up @@ -845,6 +883,121 @@ export function isDocsMcpRequest(url: URL): boolean {
);
}

export function buildDocsMcpEndpointCandidates(
baseUrl: string,
routes: readonly string[] = [DEFAULT_MCP_PUBLIC_ROUTE, DEFAULT_MCP_WELL_KNOWN_ROUTE],
options: DocsMcpEndpointCandidateOptions = {},
): DocsMcpEndpointCandidate[] {
const includeOriginFallback = options.includeOriginFallback !== false;
const includeRootDomainFallback = options.includeRootDomainFallback !== false;
const includeMcpSubdomainFallback = options.includeMcpSubdomainFallback !== false;
const base = new URL(baseUrl);
const primaryOrigin = base.origin;
const seen = new Set<string>();
const candidates: DocsMcpEndpointCandidate[] = [];

const addCandidate = (candidateBaseUrl: string, route: string) => {
const resolved = resolveDocsMcpCandidateUrl(candidateBaseUrl, route);
if (seen.has(resolved.url)) return;
seen.add(resolved.url);
candidates.push({
...resolved,
label: formatDocsMcpCandidateLabel(resolved.url, primaryOrigin),
});
};

for (const route of routes) {
addCandidate(baseUrl, route);
}

const originBaseUrl = primaryOrigin;
if (includeOriginFallback && originBaseUrl !== baseUrl.replace(/\/+$/, "")) {
for (const route of routes) {
addCandidate(originBaseUrl, route);
}
}

const rootDomainBaseUrl = toDocsRootDomainBaseUrl(base);
if (includeRootDomainFallback && rootDomainBaseUrl) {
for (const route of routes) {
addCandidate(rootDomainBaseUrl, route);
}
}

if (includeMcpSubdomainFallback) {
const mcpBaseUrl = toDocsMcpSubdomainBaseUrl(base);
if (mcpBaseUrl) {
addCandidate(mcpBaseUrl, DEFAULT_MCP_PUBLIC_ROUTE);
addCandidate(mcpBaseUrl, "/");
}
}

return candidates;
}

function resolveDocsMcpCandidateUrl(
baseUrl: string,
route: string,
): { baseUrl: string; route: string; url: string } {
if (/^https?:\/\//i.test(route)) {
const parsed = new URL(route);
const path = `${parsed.pathname || "/"}${parsed.search}`;
return {
baseUrl: parsed.origin,
route: path,
url: parsed.toString(),
};
}

const base = new URL(baseUrl);
const basePath = base.pathname.replace(/\/+$/, "");
const routePath = route.startsWith("/") ? route : `/${route}`;
const parsed = new URL(`${basePath}${routePath}`, base.origin);

return {
baseUrl: parsed.origin,
route: `${parsed.pathname}${parsed.search}`,
url: parsed.toString(),
};
}

function formatDocsMcpCandidateLabel(url: string, primaryOrigin: string): string {
const parsed = new URL(url);
const path = `${parsed.pathname}${parsed.search}`;
return parsed.origin === primaryOrigin ? path : `${parsed.origin}${path}`;
}

function toDocsMcpSubdomainBaseUrl(base: URL): string | undefined {
const rootDomain = getDocsMcpRegistrableDomain(base.hostname);
if (!rootDomain) return undefined;
return `${base.protocol}//mcp.${rootDomain}${base.port ? `:${base.port}` : ""}`;
}

function toDocsRootDomainBaseUrl(base: URL): string | undefined {
const rootDomain = getDocsMcpRegistrableDomain(base.hostname);
if (!rootDomain) return undefined;
return `${base.protocol}//${rootDomain}${base.port ? `:${base.port}` : ""}`;
}

function getDocsMcpRegistrableDomain(hostname: string): string | undefined {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
if (!normalized || !normalized.includes(".") || isDocsIpHostname(normalized)) return undefined;

const labels = normalized.split(".").filter(Boolean);
if (labels.length < 2) return undefined;

const publicSuffix = labels.slice(-2).join(".");
if (COMMON_MULTI_PART_PUBLIC_SUFFIXES.has(publicSuffix) && labels.length >= 3) {
return labels.slice(-3).join(".");
}

return labels.slice(-2).join(".");
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}

function isDocsIpHostname(hostname: string): boolean {
return /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname) || hostname.includes(":");
}

export function isDocsSkillRequest(url: URL): boolean {
const pathname = normalizeDocsUrlPath(url.pathname);
if (pathname === DEFAULT_SKILL_MD_ROUTE || pathname === DEFAULT_SKILL_MD_WELL_KNOWN_ROUTE) {
Expand Down
59 changes: 49 additions & 10 deletions packages/docs/src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
DEFAULT_MCP_WELL_KNOWN_ROUTE,
DEFAULT_SKILL_MD_ROUTE,
DEFAULT_SKILL_MD_WELL_KNOWN_ROUTE,
buildDocsMcpEndpointCandidates,
} from "../agent.js";
import { createFilesystemDocsMcpSource, resolveDocsMcpConfig } from "../server.js";
import {
Expand Down Expand Up @@ -1519,6 +1520,27 @@ async function probeMcpRoute(
}
}

async function probeMcpRouteCandidates(
baseUrl: string,
routes: string[],
): Promise<{ labels: string[]; probes: Array<{ ok: boolean; detail: string }> }> {
const candidates = buildDocsMcpEndpointCandidates(baseUrl, routes);
const probes = await Promise.all(
candidates.map(async (candidate) => {
const probe = await probeMcpRoute(candidate.baseUrl, candidate.route);
return {
...probe,
detail: `${candidate.label}: ${probe.detail}`,
};
}),
);

return {
labels: candidates.map((candidate) => candidate.label),
probes,
};
}

function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
Expand Down Expand Up @@ -1562,6 +1584,25 @@ function hostedRobotsRoute(discoveryBody: unknown): { enabled: boolean; route: s
};
}

function hostedMcpRoutes(discoveryBody: unknown): string[] {
const mcp = asRecord(asRecord(discoveryBody)?.mcp);
const publicEndpoints = (mcp?.publicEndpoints ?? mcp?.endpoints) as unknown;
const declaredRoutes = Array.isArray(publicEndpoints)
? publicEndpoints.filter(
(value): value is string => typeof value === "string" && value.startsWith("/"),
)
: [];

if (declaredRoutes.length > 0) return Array.from(new Set(declaredRoutes));

return Array.from(
new Set([
readDiscoveryRoute(mcp?.publicEndpoint) ?? DEFAULT_MCP_PUBLIC_ROUTE,
readDiscoveryRoute(mcp?.wellKnownEndpoint) ?? DEFAULT_MCP_WELL_KNOWN_ROUTE,
]),
);
}

function hostedCapability(discoveryBody: unknown, key: string): boolean | undefined {
const root = asRecord(discoveryBody);
const capabilities = asRecord(root?.capabilities);
Expand Down Expand Up @@ -1954,22 +1995,20 @@ async function buildHostedAgentChecks(
),
);

const mcp = await Promise.all([
probeMcpRoute(baseUrl, DEFAULT_MCP_PUBLIC_ROUTE),
probeMcpRoute(baseUrl, DEFAULT_MCP_WELL_KNOWN_ROUTE),
]);
const mcpPassed = mcp.filter((result) => result.ok).length;
const mcp = await probeMcpRouteCandidates(baseUrl, hostedMcpRoutes(discovery.body));
const mcpPassed = mcp.probes.filter((result) => result.ok).length;
const mcpDetailProbes = mcpPassed > 0 ? mcp.probes.filter((result) => result.ok) : mcp.probes;
checks.push(
makeCheck(
"hosted-mcp",
"Hosted MCP handshake",
mcpPassed === mcp.length ? "pass" : mcpPassed > 0 ? "warn" : "fail",
mcpPassed === mcp.length ? 10 : mcpPassed > 0 ? 5 : 0,
mcpPassed > 0 ? "pass" : "fail",
mcpPassed > 0 ? 10 : 0,
10,
mcp.map((result) => result.detail).join(" "),
mcpPassed === mcp.length
mcpDetailProbes.map((result) => result.detail).join(" "),
mcpPassed > 0
? undefined
: `Verify deployed ${DEFAULT_MCP_PUBLIC_ROUTE} and ${DEFAULT_MCP_WELL_KNOWN_ROUTE} support Streamable HTTP initialize and tools/list.`,
: `Verify one of ${mcp.labels.join(" or ")} supports Streamable HTTP initialize and tools/list.`,
),
);

Expand Down
3 changes: 3 additions & 0 deletions packages/docs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export {
DOCS_MARKDOWN_SIGNATURE_AGENT_HEADER,
buildDocsAgentDiscoverySpec,
buildDocsAgentFeedbackSchema,
buildDocsMcpEndpointCandidates,
findDocsMarkdownPage,
getDocsMarkdownCanonicalLinkHeader,
getDocsMarkdownVaryHeader,
Expand Down Expand Up @@ -121,6 +122,8 @@ export type {
DocsLlmsTxtResolvedMaxChars,
DocsLlmsTxtResolvedSection,
DocsLlmsTxtSelectedContent,
DocsMcpEndpointCandidate,
DocsMcpEndpointCandidateOptions,
DocsOpenApiDiscoveryConfig,
DocsOpenApiResolvedDiscoveryConfig,
} from "./agent.js";
Expand Down
2 changes: 2 additions & 0 deletions skills/farming-labs/cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,8 @@ With `--url`, `docs doctor --agent` also probes the deployed public agent surfac
- one representative `.md` page route, such as `/docs.md`
- `/mcp`
- `/.well-known/mcp`
- `mcp.<your-domain>/mcp`
- `mcp.<your-domain>/`
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

For hosted MCP, the command performs a Streamable HTTP initialize handshake, checks for
`mcp-session-id`, calls `tools/list`, and expects `list_pages`, `get_navigation`, `search_docs`,
Expand Down
2 changes: 2 additions & 0 deletions website/app/docs/cli/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,8 @@ The hosted pass probes:
- sampled docs page HTML for `<link rel="alternate" type="text/markdown">`
- `/mcp`
- `/.well-known/mcp`
- `mcp.<your-domain>/mcp`
- `mcp.<your-domain>/`

For MCP, the doctor performs a Streamable HTTP `initialize` request, reuses `mcp-session-id` when
the server returns one, sends `tools/list`, and expects the built-in docs tools:
Expand Down
5 changes: 3 additions & 2 deletions website/app/docs/guides/agent-friendly-docs/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,8 @@ pnpm exec docs doctor --agent --url https://docs.example.com

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

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