From 4c7041ba5204dee8b2b56f5c18fd0a0596b35876 Mon Sep 17 00:00:00 2001 From: Harsh <6162866+harsh62@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:02:55 -0400 Subject: [PATCH 1/8] feat: improve AI agent discoverability Add agent-readiness signals that the static docs build can legitimately serve: - robots.txt: add Content-Signal directive (search/ai-input/ai-train=yes) declaring AI content-usage preferences (contentsignals.org) - customHttp.yml: add RFC 8288 Link headers advertising the API catalog and the existing llms.txt documentation index; set linkset media type - /.well-known/api-catalog: new RFC 9727 linkset pointing agents at the llms.txt / llms-full.txt exports and sitemap (generated at build time, mirroring how robots.txt and sitemap.xml are emitted in postBuildTasks) - add unit tests for the API catalog generator --- customHttp.yml | 10 ++++ tasks/__tests__/generate-wellknown.test.ts | 40 +++++++++++++ tasks/generate-sitemap.mjs | 8 ++- tasks/generate-wellknown.mjs | 70 ++++++++++++++++++++++ tasks/postBuildTasks.mjs | 2 + 5 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 tasks/__tests__/generate-wellknown.test.ts create mode 100644 tasks/generate-wellknown.mjs diff --git a/customHttp.yml b/customHttp.yml index 9e866dc7f7f..bac5bb357e2 100644 --- a/customHttp.yml +++ b/customHttp.yml @@ -15,3 +15,13 @@ customHeaders: - key: 'Content-Security-Policy' value: 'upgrade-insecure-requests;' # CSP also set in _document.tsx meta tag + # Link headers advertise agent-discovery resources (RFC 8288 / RFC 9727): + # the API catalog and the LLM-friendly documentation index. + - key: 'Link' + value: '; rel="api-catalog", ; rel="service-doc"; type="text/plain"' + # The API catalog has no file extension, so set its media type explicitly + # (RFC 9727 / RFC 9264). + - pattern: '.well-known/api-catalog' + headers: + - key: 'Content-Type' + value: 'application/linkset+json' diff --git a/tasks/__tests__/generate-wellknown.test.ts b/tasks/__tests__/generate-wellknown.test.ts new file mode 100644 index 00000000000..dae7dbb484f --- /dev/null +++ b/tasks/__tests__/generate-wellknown.test.ts @@ -0,0 +1,40 @@ +import { generateApiCatalog } from '../generate-wellknown.mjs'; + +describe('generate-wellknown', () => { + describe('generateApiCatalog', () => { + it('should return a valid linkset JSON document', () => { + const result = generateApiCatalog('https://docs.amplify.aws'); + const parsed = JSON.parse(result); + + expect(Array.isArray(parsed.linkset)).toBe(true); + expect(parsed.linkset).toHaveLength(1); + }); + + it('should anchor the catalog to the site root', () => { + const parsed = JSON.parse(generateApiCatalog('https://docs.amplify.aws')); + + expect(parsed.linkset[0].anchor).toBe('https://docs.amplify.aws/'); + }); + + it('should advertise the llms.txt resources as service-doc links', () => { + const parsed = JSON.parse(generateApiCatalog('https://docs.amplify.aws')); + const hrefs = parsed.linkset[0]['service-doc'].map((l: any) => l.href); + + expect(hrefs).toContain('https://docs.amplify.aws/ai/llms.txt'); + expect(hrefs).toContain('https://docs.amplify.aws/ai/llms-full.txt'); + }); + + it('should advertise the sitemap as a related link', () => { + const parsed = JSON.parse(generateApiCatalog('https://docs.amplify.aws')); + const hrefs = parsed.linkset[0].related.map((l: any) => l.href); + + expect(hrefs).toContain('https://docs.amplify.aws/sitemap.xml'); + }); + + it('should honor the provided domain', () => { + const parsed = JSON.parse(generateApiCatalog('https://example.com')); + + expect(parsed.linkset[0].anchor).toBe('https://example.com/'); + }); + }); +}); diff --git a/tasks/generate-sitemap.mjs b/tasks/generate-sitemap.mjs index 4a6c02402a3..abc00bae039 100644 --- a/tasks/generate-sitemap.mjs +++ b/tasks/generate-sitemap.mjs @@ -257,8 +257,14 @@ export async function writeSitemap() { } export const writeRobots = async () => { - let robotsContent = `User-agent: *\nDisallow:\n`; + // Content Signals declare how crawlers may use this content once fetched. + // We allow search indexing, AI answer-input (assistants reading pages to + // answer questions), and AI training. See https://contentsignals.org/ + const contentSignal = `Content-Signal: search=yes, ai-input=yes, ai-train=yes\n`; + + let robotsContent = `User-agent: *\n${contentSignal}Disallow:\n`; if (typeof process.env.ALLOW_ROBOTS === 'undefined') { + // Non-crawlable preview/build: block everything and omit content signals. robotsContent = `User-agent: *\nDisallow: /\n`; } if (process.env.BUILD_ENV === 'production') { diff --git a/tasks/generate-wellknown.mjs b/tasks/generate-wellknown.mjs new file mode 100644 index 00000000000..a8a0527b7c5 --- /dev/null +++ b/tasks/generate-wellknown.mjs @@ -0,0 +1,70 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import dotenv from 'dotenv'; + +dotenv.config({ path: './.env.custom' }); + +const DOMAIN = process.env.SITEMAP_DOMAIN + ? process.env.SITEMAP_DOMAIN + : 'https://docs.amplify.aws'; + +// Path of the Next.js static HTML build output (same target used for +// robots.txt and sitemap.xml in postBuildTasks). +const ROOT_PATH = './client/www/next-build'; + +/** + * Build the API catalog linkset document (RFC 9727 / RFC 9264). + * + * This is a documentation site rather than a hosted API, so the catalog + * advertises the machine-readable documentation resources the build already + * produces (the llms.txt index, the full export, and the sitemap) instead of + * an OpenAPI service description, which does not exist for this site. + * + * @returns {string} Pretty-printed application/linkset+json document + */ +export function generateApiCatalog(domain = DOMAIN) { + const linkset = { + linkset: [ + { + anchor: `${domain}/`, + 'service-doc': [ + { + href: `${domain}/ai/llms.txt`, + type: 'text/plain', + title: 'AWS Amplify documentation index for LLMs (llms.txt)' + }, + { + href: `${domain}/ai/llms-full.txt`, + type: 'text/plain', + title: 'AWS Amplify documentation full export for LLMs' + } + ], + related: [ + { + href: `${domain}/sitemap.xml`, + type: 'text/xml', + title: 'Sitemap' + } + ] + } + ] + }; + + return JSON.stringify(linkset, null, 2); +} + +/** + * Writes the API catalog to /.well-known/api-catalog in the build output. + */ +export async function writeApiCatalog() { + const wellKnownDir = path.join(ROOT_PATH, '.well-known'); + const catalogPath = path.join(wellKnownDir, 'api-catalog'); + + try { + await fs.mkdir(wellKnownDir, { recursive: true }); + await fs.writeFile(catalogPath, generateApiCatalog()); + console.log(`api-catalog written to ${catalogPath}`); + } catch (error) { + console.error(`Error writing api-catalog to ${catalogPath}:`, error); + } +} diff --git a/tasks/postBuildTasks.mjs b/tasks/postBuildTasks.mjs index d3875a8751b..0970452b1b6 100644 --- a/tasks/postBuildTasks.mjs +++ b/tasks/postBuildTasks.mjs @@ -1,4 +1,6 @@ import { writeSitemap, writeRobots } from './generate-sitemap.mjs'; +import { writeApiCatalog } from './generate-wellknown.mjs'; await writeSitemap(); await writeRobots(); +await writeApiCatalog(); From f5874cd2b2fbff91d1920ce23219738fe93f2372 Mon Sep 17 00:00:00 2001 From: Harsh <6162866+harsh62@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:10:21 -0400 Subject: [PATCH 2/8] feat: publish agent skills discovery index Add /.well-known/agent-skills/index.json (Agent Skills Discovery RFC v0.2.0) advertising the real amplify-workflow skill from awslabs/agent-plugins. - generate-agent-skills.mjs: build-time generator emitting the index, sourcing name/description from the upstream SKILL.md frontmatter; url points at the agent-plugins docs/marketplace install page, so no sha256 digest is published (the page is discovery/install guidance, not a downloadable artifact) - wire writeAgentSkillsIndex into postBuildTasks (mirrors robots/sitemap/catalog) - customHttp.yml: advertise the index via an additional Link relation - add unit tests for the index generator --- customHttp.yml | 4 +- tasks/__tests__/generate-agent-skills.test.ts | 45 +++++++++++ tasks/generate-agent-skills.mjs | 74 +++++++++++++++++++ tasks/postBuildTasks.mjs | 2 + 4 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tasks/__tests__/generate-agent-skills.test.ts create mode 100644 tasks/generate-agent-skills.mjs diff --git a/customHttp.yml b/customHttp.yml index bac5bb357e2..ec1fa831421 100644 --- a/customHttp.yml +++ b/customHttp.yml @@ -16,9 +16,9 @@ customHeaders: value: 'upgrade-insecure-requests;' # CSP also set in _document.tsx meta tag # Link headers advertise agent-discovery resources (RFC 8288 / RFC 9727): - # the API catalog and the LLM-friendly documentation index. + # the API catalog, the agent skills index, and the LLM-friendly docs index. - key: 'Link' - value: '; rel="api-catalog", ; rel="service-doc"; type="text/plain"' + value: '; rel="api-catalog", ; rel="agent-skills"; type="application/json", ; rel="service-doc"; type="text/plain"' # The API catalog has no file extension, so set its media type explicitly # (RFC 9727 / RFC 9264). - pattern: '.well-known/api-catalog' diff --git a/tasks/__tests__/generate-agent-skills.test.ts b/tasks/__tests__/generate-agent-skills.test.ts new file mode 100644 index 00000000000..f83bcb359e5 --- /dev/null +++ b/tasks/__tests__/generate-agent-skills.test.ts @@ -0,0 +1,45 @@ +import { generateAgentSkillsIndex } from '../generate-agent-skills.mjs'; + +describe('generate-agent-skills', () => { + describe('generateAgentSkillsIndex', () => { + it('should return a valid index JSON document with a $schema', () => { + const parsed = JSON.parse( + generateAgentSkillsIndex('https://docs.amplify.aws') + ); + + expect(typeof parsed.$schema).toBe('string'); + expect(Array.isArray(parsed.skills)).toBe(true); + expect(parsed.skills.length).toBeGreaterThan(0); + }); + + it('should include the amplify-workflow skill with required fields', () => { + const parsed = JSON.parse( + generateAgentSkillsIndex('https://docs.amplify.aws') + ); + const skill = parsed.skills.find((s: any) => s.name === 'amplify-workflow'); + + expect(skill).toBeDefined(); + expect(skill.type).toBeTruthy(); + expect(skill.description).toBeTruthy(); + expect(skill.url).toBe( + 'https://docs.amplify.aws/react/develop-with-ai/agent-plugins/' + ); + }); + + it('should not publish a sha256 digest for doc-pointed skills', () => { + const parsed = JSON.parse( + generateAgentSkillsIndex('https://docs.amplify.aws') + ); + + for (const skill of parsed.skills) { + expect(skill.sha256).toBeUndefined(); + } + }); + + it('should honor the provided domain', () => { + const parsed = JSON.parse(generateAgentSkillsIndex('https://example.com')); + + expect(parsed.skills[0].url.startsWith('https://example.com/')).toBe(true); + }); + }); +}); diff --git a/tasks/generate-agent-skills.mjs b/tasks/generate-agent-skills.mjs new file mode 100644 index 00000000000..cd4eacaeefa --- /dev/null +++ b/tasks/generate-agent-skills.mjs @@ -0,0 +1,74 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import dotenv from 'dotenv'; + +dotenv.config({ path: './.env.custom' }); + +const DOMAIN = process.env.SITEMAP_DOMAIN + ? process.env.SITEMAP_DOMAIN + : 'https://docs.amplify.aws'; + +// Path of the Next.js static HTML build output (same target used for +// robots.txt, sitemap.xml, and the API catalog in postBuildTasks). +const ROOT_PATH = './client/www/next-build'; + +// Agent Skills Discovery RFC v0.2.0 well-known location. +const INDEX_SUBPATH = '.well-known/agent-skills/index.json'; +const SCHEMA_URL = 'https://agentskills.io/schema/v0.2.0/index.json'; + +/** + * The agent skills AWS Amplify publishes. These are real, maintained artifacts + * in the awslabs/agent-plugins repository, surfaced to users through the + * Claude Code and Cursor plugin marketplaces and documented under + * /develop-with-ai/agent-plugins. + * + * `name` and `description` mirror the upstream SKILL.md frontmatter so the + * index stays consistent with the source of truth. `url` points at the docs + * page that explains how to install and use the skill rather than at a raw + * artifact, so no sha256 digest is published (the docs page is discovery and + * install guidance, not the downloadable artifact itself). + * + * @param {string} domain Site origin to build doc URLs against + * @returns {Array} Skill entries for the discovery index + */ +function getSkills(domain) { + return [ + { + name: 'amplify-workflow', + type: 'claude-skill', + description: + 'Build and deploy full-stack web and mobile apps with AWS Amplify Gen2 (TypeScript code-first). Covers auth (Cognito), data (AppSync/DynamoDB), storage (S3), functions, APIs, and AI (Amplify AI Kit with Bedrock) across React, Next.js, Vue, Angular, React Native, Flutter, Swift, and Android.', + url: `${domain}/react/develop-with-ai/agent-plugins/` + } + ]; +} + +/** + * Build the Agent Skills discovery index (Agent Skills Discovery RFC v0.2.0). + * + * @returns {string} Pretty-printed index.json document + */ +export function generateAgentSkillsIndex(domain = DOMAIN) { + const index = { + $schema: SCHEMA_URL, + skills: getSkills(domain) + }; + + return JSON.stringify(index, null, 2); +} + +/** + * Writes the Agent Skills index to /.well-known/agent-skills/index.json in the + * build output. + */ +export async function writeAgentSkillsIndex() { + const indexPath = path.join(ROOT_PATH, INDEX_SUBPATH); + + try { + await fs.mkdir(path.dirname(indexPath), { recursive: true }); + await fs.writeFile(indexPath, generateAgentSkillsIndex()); + console.log(`agent-skills index written to ${indexPath}`); + } catch (error) { + console.error(`Error writing agent-skills index to ${indexPath}:`, error); + } +} diff --git a/tasks/postBuildTasks.mjs b/tasks/postBuildTasks.mjs index 0970452b1b6..6d62782c7a8 100644 --- a/tasks/postBuildTasks.mjs +++ b/tasks/postBuildTasks.mjs @@ -1,6 +1,8 @@ import { writeSitemap, writeRobots } from './generate-sitemap.mjs'; import { writeApiCatalog } from './generate-wellknown.mjs'; +import { writeAgentSkillsIndex } from './generate-agent-skills.mjs'; await writeSitemap(); await writeRobots(); await writeApiCatalog(); +await writeAgentSkillsIndex(); From 9e8a542e2f7a19a86dbaf1248cdc420db0e20d24 Mon Sep 17 00:00:00 2001 From: Harsh <6162866+harsh62@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:09:29 -0400 Subject: [PATCH 3/8] feat: point agents at AWS Knowledge MCP server and vend markdown Surface the real AWS Knowledge MCP server and make the generated per-page markdown twins discoverable and correctly typed. MCP server card: - generate-wellknown.mjs: emit /.well-known/mcp/server-card.json describing the public, no-auth AWS Knowledge MCP server (https://knowledge-mcp.global.api.aws, HTTP transport, tools) which authoritatively indexes Amplify docs. The card is an honest pointer to AWS's managed server, not a claim that docs.amplify.aws is itself an MCP endpoint. - wire writeMcpServerCard into postBuildTasks; advertise via a Link rel Markdown vending: - customHttp.yml: serve /ai/**/*.md as text/markdown; charset=utf-8 - Layout: inject into each Gen2 content page's for automatic per-page discovery, reusing MarkdownMenu's getMarkdownUrl mapping (now exported) and mirroring its gate (skip gen1/home/ overview pages that have no .md twin) - extend generate-wellknown tests for the server card --- customHttp.yml | 11 +++- src/components/Layout/Layout.tsx | 18 +++++- src/components/MarkdownMenu/MarkdownMenu.tsx | 2 +- src/components/MarkdownMenu/index.ts | 2 +- tasks/__tests__/generate-wellknown.test.ts | 41 ++++++++++++- tasks/generate-wellknown.mjs | 62 ++++++++++++++++++++ tasks/postBuildTasks.mjs | 3 +- 7 files changed, 132 insertions(+), 7 deletions(-) diff --git a/customHttp.yml b/customHttp.yml index ec1fa831421..f0d0f65e8b1 100644 --- a/customHttp.yml +++ b/customHttp.yml @@ -16,12 +16,19 @@ customHeaders: value: 'upgrade-insecure-requests;' # CSP also set in _document.tsx meta tag # Link headers advertise agent-discovery resources (RFC 8288 / RFC 9727): - # the API catalog, the agent skills index, and the LLM-friendly docs index. + # the API catalog, the agent skills index, the MCP server card, and the + # LLM-friendly documentation index. - key: 'Link' - value: '; rel="api-catalog", ; rel="agent-skills"; type="application/json", ; rel="service-doc"; type="text/plain"' + value: '; rel="api-catalog", ; rel="agent-skills"; type="application/json", ; rel="mcp-server"; type="application/json", ; rel="service-doc"; type="text/plain"' # The API catalog has no file extension, so set its media type explicitly # (RFC 9727 / RFC 9264). - pattern: '.well-known/api-catalog' headers: - key: 'Content-Type' value: 'application/linkset+json' + # Serve the generated agent-facing markdown twins as markdown so agents that + # request them get the correct media type (these live under /ai/**). + - pattern: '/ai/**/*.md' + headers: + - key: 'Content-Type' + value: 'text/markdown; charset=utf-8' diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index e1bc3a8744a..7fe81b921f9 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -44,7 +44,7 @@ import { getPageSection } from '@/utils/getPageSection'; import { PinpointEOLBanner } from '@/components/PinpointEOLBanner'; import { LexV1EOLBanner } from '../LexV1EOLBanner'; import { ApiModalProvider } from '../ApiDocs/ApiModalProvider'; -import { MarkdownMenu } from '@/components/MarkdownMenu'; +import { MarkdownMenu, getMarkdownUrl } from '@/components/MarkdownMenu'; export const Layout = ({ children, @@ -170,6 +170,14 @@ export const Layout = ({ children?.props?.childPageNodes?.length != 'undefined' && children?.props?.childPageNodes?.length > 0; + // Per-page markdown alternate for agent autodiscovery. Only Gen2 content + // pages have a generated /ai/pages/*.md twin — mirror the MarkdownMenu gate + // (skip Gen1, home, and overview pages) so we never advertise a missing file. + const markdownUrl = + !isGen1 && !isHome && !isOverview + ? getMarkdownUrl(asPathWithNoHash) + : null; + const showNextPrev = NEXT_PREVIOUS_SECTIONS.some( (section) => pathname.includes(section) && @@ -267,6 +275,14 @@ export const Layout = ({ <> {`${title}`} + {markdownUrl && ( + + )} { describe('generateApiCatalog', () => { @@ -37,4 +40,40 @@ describe('generate-wellknown', () => { expect(parsed.linkset[0].anchor).toBe('https://example.com/'); }); }); + + describe('generateMcpServerCard', () => { + it('should return a valid server card JSON document', () => { + const parsed = JSON.parse(generateMcpServerCard()); + + expect(parsed.serverInfo).toBeDefined(); + expect(parsed.serverInfo.name).toBe('aws-knowledge-mcp-server'); + }); + + it('should point at the public AWS Knowledge MCP endpoint over HTTP', () => { + const parsed = JSON.parse(generateMcpServerCard()); + + expect(parsed.transport.type).toBe('http'); + expect(parsed.transport.endpoint).toBe( + 'https://knowledge-mcp.global.api.aws' + ); + }); + + it('should declare that no authentication is required', () => { + const parsed = JSON.parse(generateMcpServerCard()); + + expect(parsed.authentication.required).toBe(false); + }); + + it('should advertise the documentation and skill tools', () => { + const parsed = JSON.parse(generateMcpServerCard()); + + expect(parsed.capabilities.tools).toEqual( + expect.arrayContaining([ + 'search_documentation', + 'read_documentation', + 'retrieve_skill' + ]) + ); + }); + }); }); diff --git a/tasks/generate-wellknown.mjs b/tasks/generate-wellknown.mjs index a8a0527b7c5..d9f55a4cbe4 100644 --- a/tasks/generate-wellknown.mjs +++ b/tasks/generate-wellknown.mjs @@ -68,3 +68,65 @@ export async function writeApiCatalog() { console.error(`Error writing api-catalog to ${catalogPath}:`, error); } } + +// The AWS Knowledge MCP Server is a fully managed, public (no-auth) remote MCP +// server that AWS hosts and that authoritatively indexes AWS Amplify +// documentation. See https://github.com/awslabs/mcp (aws-knowledge-mcp-server). +const AWS_KNOWLEDGE_MCP_ENDPOINT = 'https://knowledge-mcp.global.api.aws'; + +/** + * Build the MCP Server Card (SEP-1649 style) for agent discovery. + * + * This documentation site does not run its own MCP server, so the card points + * at the official AWS Knowledge MCP Server, which is AWS-managed, requires no + * authentication, and indexes this site's content (AWS Amplify documentation). + * It is a truthful pointer to the real server agents should connect to rather + * than a claim that docs.amplify.aws is itself an MCP endpoint. + * + * @returns {string} Pretty-printed server card JSON document + */ +export function generateMcpServerCard() { + const card = { + serverInfo: { + name: 'aws-knowledge-mcp-server', + description: + 'Fully managed, public AWS Knowledge MCP Server hosted by AWS. Provides search and retrieval over the latest AWS documentation, including AWS Amplify documentation, plus AWS agent skills. This site (docs.amplify.aws) does not host its own MCP server; connect to the AWS-managed server below.' + }, + transport: { + type: 'http', + endpoint: AWS_KNOWLEDGE_MCP_ENDPOINT + }, + authentication: { + required: false + }, + capabilities: { + tools: [ + 'search_documentation', + 'read_documentation', + 'list_regions', + 'get_regional_availability', + 'retrieve_skill' + ] + }, + documentation: 'https://github.com/awslabs/mcp' + }; + + return JSON.stringify(card, null, 2); +} + +/** + * Writes the MCP server card to /.well-known/mcp/server-card.json in the build + * output. + */ +export async function writeMcpServerCard() { + const mcpDir = path.join(ROOT_PATH, '.well-known', 'mcp'); + const cardPath = path.join(mcpDir, 'server-card.json'); + + try { + await fs.mkdir(mcpDir, { recursive: true }); + await fs.writeFile(cardPath, generateMcpServerCard()); + console.log(`mcp server-card written to ${cardPath}`); + } catch (error) { + console.error(`Error writing mcp server-card to ${cardPath}:`, error); + } +} diff --git a/tasks/postBuildTasks.mjs b/tasks/postBuildTasks.mjs index 6d62782c7a8..039305a9306 100644 --- a/tasks/postBuildTasks.mjs +++ b/tasks/postBuildTasks.mjs @@ -1,8 +1,9 @@ import { writeSitemap, writeRobots } from './generate-sitemap.mjs'; -import { writeApiCatalog } from './generate-wellknown.mjs'; +import { writeApiCatalog, writeMcpServerCard } from './generate-wellknown.mjs'; import { writeAgentSkillsIndex } from './generate-agent-skills.mjs'; await writeSitemap(); await writeRobots(); await writeApiCatalog(); +await writeMcpServerCard(); await writeAgentSkillsIndex(); From a73e0f25eb3ad2b420f4d402aa294b28d3d8d3e9 Mon Sep 17 00:00:00 2001 From: Harsh <6162866+harsh62@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:11:53 -0400 Subject: [PATCH 4/8] chore: update yarn.lock to v10 format for Yarn 4.17 --- package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index ccf8e47ce8d..585d9c203a3 100644 --- a/package.json +++ b/package.json @@ -129,5 +129,5 @@ "overrides": { "tmp": "^0.2.4" }, - "packageManager": "yarn@4.14.1" + "packageManager": "yarn@4.17.0" } diff --git a/yarn.lock b/yarn.lock index 46888782deb..fb38a2cca04 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,7 +2,7 @@ # Manual changes might be lost - proceed with caution! __metadata: - version: 9 + version: 10 cacheKey: 10c0 "@aashutoshrathi/word-wrap@npm:^1.2.3": From d3524069a2dda8604f3a3345c86143fcc28b0f54 Mon Sep 17 00:00:00 2001 From: Harsh <6162866+harsh62@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:45:54 -0400 Subject: [PATCH 5/8] fix: add service-desc relation to API catalog (RFC 9727) The api-catalog linkset entry was missing the required service-desc relation, so validators could not recognize a machine-readable service description. Map the relations per RFC 9727: - service-desc: llms-full.txt (complete machine-readable export) - service-doc: llms.txt (documentation index) - service-meta: sitemap.xml Each relation is now an array of { href, type } objects per Appendix A. --- tasks/__tests__/generate-wellknown.test.ts | 28 ++++++++++++++++++---- tasks/generate-wellknown.mjs | 26 +++++++++++++------- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/tasks/__tests__/generate-wellknown.test.ts b/tasks/__tests__/generate-wellknown.test.ts index f4973de0b07..1273964706f 100644 --- a/tasks/__tests__/generate-wellknown.test.ts +++ b/tasks/__tests__/generate-wellknown.test.ts @@ -19,21 +19,41 @@ describe('generate-wellknown', () => { expect(parsed.linkset[0].anchor).toBe('https://docs.amplify.aws/'); }); - it('should advertise the llms.txt resources as service-doc links', () => { + it('should advertise the full export as a service-desc link (RFC 9727)', () => { + const parsed = JSON.parse(generateApiCatalog('https://docs.amplify.aws')); + const entry = parsed.linkset[0]; + + expect(Array.isArray(entry['service-desc'])).toBe(true); + const hrefs = entry['service-desc'].map((l: any) => l.href); + expect(hrefs).toContain('https://docs.amplify.aws/ai/llms-full.txt'); + }); + + it('should advertise the llms.txt index as a service-doc link', () => { const parsed = JSON.parse(generateApiCatalog('https://docs.amplify.aws')); const hrefs = parsed.linkset[0]['service-doc'].map((l: any) => l.href); expect(hrefs).toContain('https://docs.amplify.aws/ai/llms.txt'); - expect(hrefs).toContain('https://docs.amplify.aws/ai/llms-full.txt'); }); - it('should advertise the sitemap as a related link', () => { + it('should advertise the sitemap as a service-meta link', () => { const parsed = JSON.parse(generateApiCatalog('https://docs.amplify.aws')); - const hrefs = parsed.linkset[0].related.map((l: any) => l.href); + const hrefs = parsed.linkset[0]['service-meta'].map((l: any) => l.href); expect(hrefs).toContain('https://docs.amplify.aws/sitemap.xml'); }); + it('should represent each relation as an array of href/type objects', () => { + const parsed = JSON.parse(generateApiCatalog('https://docs.amplify.aws')); + const entry = parsed.linkset[0]; + + for (const rel of ['service-desc', 'service-doc', 'service-meta']) { + for (const link of entry[rel]) { + expect(typeof link.href).toBe('string'); + expect(typeof link.type).toBe('string'); + } + } + }); + it('should honor the provided domain', () => { const parsed = JSON.parse(generateApiCatalog('https://example.com')); diff --git a/tasks/generate-wellknown.mjs b/tasks/generate-wellknown.mjs index d9f55a4cbe4..2007da66953 100644 --- a/tasks/generate-wellknown.mjs +++ b/tasks/generate-wellknown.mjs @@ -17,8 +17,14 @@ const ROOT_PATH = './client/www/next-build'; * * This is a documentation site rather than a hosted API, so the catalog * advertises the machine-readable documentation resources the build already - * produces (the llms.txt index, the full export, and the sitemap) instead of - * an OpenAPI service description, which does not exist for this site. + * produces instead of an OpenAPI service description, which does not exist for + * this site. The relations map to the RFC 9727 link relations: + * - service-desc: llms-full.txt, the complete machine-readable export that + * best stands in for a service description for an agent. + * - service-doc: llms.txt, the human/agent-readable documentation index. + * - service-meta: the sitemap, which enumerates the catalog's pages. + * + * Each relation is an array of { href, type } objects per RFC 9727 Appendix A. * * @returns {string} Pretty-printed application/linkset+json document */ @@ -27,22 +33,24 @@ export function generateApiCatalog(domain = DOMAIN) { linkset: [ { anchor: `${domain}/`, + 'service-desc': [ + { + href: `${domain}/ai/llms-full.txt`, + type: 'text/plain', + title: 'AWS Amplify documentation full export for LLMs' + } + ], 'service-doc': [ { href: `${domain}/ai/llms.txt`, type: 'text/plain', title: 'AWS Amplify documentation index for LLMs (llms.txt)' - }, - { - href: `${domain}/ai/llms-full.txt`, - type: 'text/plain', - title: 'AWS Amplify documentation full export for LLMs' } ], - related: [ + 'service-meta': [ { href: `${domain}/sitemap.xml`, - type: 'text/xml', + type: 'application/xml', title: 'Sitemap' } ] From affd3349b9fd09115dbfa87bc9018c28a69c2e7d Mon Sep 17 00:00:00 2001 From: Harsh <6162866+harsh62@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:54:43 -0400 Subject: [PATCH 6/8] feat: expose read-only WebMCP tools to in-browser agents Register WebMCP tools via document.modelContext (with navigator.modelContext fallback) so in-browser AI agents can call the docs site's key read actions: - get_current_page_markdown: returns the current page's generated Markdown twin - get_documentation_index: returns the llms.txt documentation index Both tools are read-only and backed by content the build already produces, so they return real data rather than stubs. The API is feature-detected and the component renders nothing, making it a silent no-op in browsers without WebMCP. Tools are torn down on unmount via an AbortSignal. Mounted from Layout on the same Gen2 content pages that have a Markdown twin. --- src/components/Layout/Layout.tsx | 2 + src/components/WebMcp/WebMcp.tsx | 120 ++++++++++++++++++ .../WebMcp/__tests__/WebMcp.test.tsx | 74 +++++++++++ src/components/WebMcp/index.ts | 1 + 4 files changed, 197 insertions(+) create mode 100644 src/components/WebMcp/WebMcp.tsx create mode 100644 src/components/WebMcp/__tests__/WebMcp.test.tsx create mode 100644 src/components/WebMcp/index.ts diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index 7fe81b921f9..e2508856828 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -45,6 +45,7 @@ import { PinpointEOLBanner } from '@/components/PinpointEOLBanner'; import { LexV1EOLBanner } from '../LexV1EOLBanner'; import { ApiModalProvider } from '../ApiDocs/ApiModalProvider'; import { MarkdownMenu, getMarkdownUrl } from '@/components/MarkdownMenu'; +import { WebMcp } from '@/components/WebMcp'; export const Layout = ({ children, @@ -314,6 +315,7 @@ export const Layout = ({ key="twitter:image" /> + {markdownUrl && } ) => Promise; +} + +interface ModelContextLike { + registerTool: ( + tool: ModelContextTool, + options?: { signal?: AbortSignal } + ) => Promise; +} + +function getModelContext(): ModelContextLike | null { + if (typeof document !== 'undefined') { + const fromDoc = (document as unknown as { modelContext?: ModelContextLike }) + .modelContext; + if (fromDoc?.registerTool) return fromDoc; + } + if (typeof navigator !== 'undefined') { + const fromNav = (navigator as unknown as { modelContext?: ModelContextLike }) + .modelContext; + if (fromNav?.registerTool) return fromNav; + } + return null; +} + +/** + * Fetch a markdown document and return its text, guarding against the SPA + * fallback returning an HTML page (e.g. a 404) instead of markdown. + */ +async function fetchMarkdown(url: string): Promise { + const response = await fetch(url, { + headers: { accept: 'text/markdown, text/plain' } + }); + if (!response.ok) { + throw new Error(`Request for ${url} failed with status ${response.status}`); + } + const text = await response.text(); + if (/^\s* { + const modelContext = getModelContext(); + if (!modelContext) return; + + const controller = new AbortController(); + const { signal } = controller; + const origin = window.location.origin; + + const register = async () => { + try { + await modelContext.registerTool( + { + name: 'get_current_page_markdown', + description: + 'Return the current AWS Amplify documentation page as clean Markdown, ideal for reading or summarizing without HTML chrome.', + inputSchema: { type: 'object', properties: {} }, + annotations: { readOnlyHint: true }, + execute: async () => { + const markdown = await fetchMarkdown(origin + getMarkdownUrl(route)); + return { markdown }; + } + }, + { signal } + ); + + await modelContext.registerTool( + { + name: 'get_documentation_index', + description: + 'Return the AWS Amplify documentation index (llms.txt), a Markdown list of all documentation pages with descriptions and links to their Markdown versions.', + inputSchema: { type: 'object', properties: {} }, + annotations: { readOnlyHint: true }, + execute: async () => { + const index = await fetchMarkdown(origin + '/ai/llms.txt'); + return { index }; + } + }, + { signal } + ); + } catch { + // Registration is best-effort; ignore failures (e.g. duplicate names + // during fast client-side navigations or unsupported environments). + } + }; + + register(); + + return () => controller.abort(); + }, [route]); + + return null; +} diff --git a/src/components/WebMcp/__tests__/WebMcp.test.tsx b/src/components/WebMcp/__tests__/WebMcp.test.tsx new file mode 100644 index 00000000000..22be1bca9e1 --- /dev/null +++ b/src/components/WebMcp/__tests__/WebMcp.test.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { render, waitFor, act } from '@testing-library/react'; +import { WebMcp } from '../WebMcp'; + +describe('WebMcp', () => { + afterEach(() => { + // Remove any modelContext shim we attached during a test. + delete (document as unknown as { modelContext?: unknown }).modelContext; + jest.restoreAllMocks(); + }); + + it('is a no-op when the WebMCP API is unavailable', () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(''); + }); + + it('registers read-only tools when document.modelContext exists', async () => { + const registerTool = jest.fn().mockResolvedValue(undefined); + (document as unknown as { modelContext: unknown }).modelContext = { + registerTool + }; + + await act(async () => { + render(); + }); + + await waitFor(() => expect(registerTool).toHaveBeenCalled()); + + const toolNames = registerTool.mock.calls.map((c) => c[0].name); + expect(toolNames).toContain('get_current_page_markdown'); + expect(toolNames).toContain('get_documentation_index'); + + // Each registered tool must have the WebMCP-required fields. + for (const [tool] of registerTool.mock.calls) { + expect(typeof tool.name).toBe('string'); + expect(typeof tool.description).toBe('string'); + expect(typeof tool.execute).toBe('function'); + expect(tool.annotations.readOnlyHint).toBe(true); + } + }); + + it('fetches the current page markdown when its tool is executed', async () => { + const registerTool = jest.fn().mockResolvedValue(undefined); + (document as unknown as { modelContext: unknown }).modelContext = { + registerTool + }; + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => '# Set up auth\n\nReal markdown content.' + }); + global.fetch = fetchMock as unknown as typeof fetch; + + await act(async () => { + render(); + }); + await waitFor(() => expect(registerTool).toHaveBeenCalled()); + + const pageTool = registerTool.mock.calls + .map((c) => c[0]) + .find((t) => t.name === 'get_current_page_markdown'); + const result = await pageTool.execute({}); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/ai/pages/build-a-backend/auth/set-up-auth.md'), + expect.anything() + ); + expect(result).toEqual({ + markdown: '# Set up auth\n\nReal markdown content.' + }); + }); +}); diff --git a/src/components/WebMcp/index.ts b/src/components/WebMcp/index.ts new file mode 100644 index 00000000000..de110a15a1c --- /dev/null +++ b/src/components/WebMcp/index.ts @@ -0,0 +1 @@ +export { WebMcp } from './WebMcp'; From 428c602eae1add4eba0f696f79e4bf9e891d9ea5 Mon Sep 17 00:00:00 2001 From: Harsh <6162866+harsh62@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:57:53 -0400 Subject: [PATCH 7/8] fix: serve API catalog at canonical path with a 200 (trailing-slash) With trailingSlash: true, Amplify Hosting 301-redirects the extensionless path /.well-known/api-catalog to /.well-known/api-catalog/, which has no file and returns 404 -- so the RFC 9727 catalog was unreachable at its canonical path. Files with an extension are served directly with a 200. - Write the catalog as api-catalog.json (extensioned, served as 200) - Add a 200-rewrite in redirects.json mapping /.well-known/api-catalog to that file so the canonical path resolves in place without a redirect - Set application/linkset+json on both paths in customHttp.yml - Add a test asserting the 200-rewrite contract --- customHttp.yml | 11 ++++++++--- redirects.json | 5 +++++ tasks/__tests__/generate-wellknown.test.ts | 17 +++++++++++++++++ tasks/generate-wellknown.mjs | 12 ++++++++++-- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/customHttp.yml b/customHttp.yml index f0d0f65e8b1..7ffe66711d5 100644 --- a/customHttp.yml +++ b/customHttp.yml @@ -20,9 +20,14 @@ customHeaders: # LLM-friendly documentation index. - key: 'Link' value: '; rel="api-catalog", ; rel="agent-skills"; type="application/json", ; rel="mcp-server"; type="application/json", ; rel="service-doc"; type="text/plain"' - # The API catalog has no file extension, so set its media type explicitly - # (RFC 9727 / RFC 9264). - - pattern: '.well-known/api-catalog' + # Serve the API catalog as a linkset (RFC 9727 / RFC 9264) at both the + # canonical extensionless path (served via a 200-rewrite in redirects.json) + # and the underlying .json file. + - pattern: '/.well-known/api-catalog' + headers: + - key: 'Content-Type' + value: 'application/linkset+json' + - pattern: '/.well-known/api-catalog.json' headers: - key: 'Content-Type' value: 'application/linkset+json' diff --git a/redirects.json b/redirects.json index 876cd77c5d8..9fbc2da6f97 100644 --- a/redirects.json +++ b/redirects.json @@ -1,4 +1,9 @@ [ + { + "source": "/.well-known/api-catalog", + "target": "/.well-known/api-catalog.json", + "status": "200" + }, { "source": "/lib/ssr/ssr/q/platform/js/", "target": "/gen1/javascript/prev/build-a-backend/server-side-rendering/", diff --git a/tasks/__tests__/generate-wellknown.test.ts b/tasks/__tests__/generate-wellknown.test.ts index 1273964706f..94e983e2f44 100644 --- a/tasks/__tests__/generate-wellknown.test.ts +++ b/tasks/__tests__/generate-wellknown.test.ts @@ -2,6 +2,7 @@ import { generateApiCatalog, generateMcpServerCard } from '../generate-wellknown.mjs'; +import redirects from '../../redirects.json'; describe('generate-wellknown', () => { describe('generateApiCatalog', () => { @@ -96,4 +97,20 @@ describe('generate-wellknown', () => { ); }); }); + + // The site builds with trailingSlash: true, so the extensionless canonical + // path /.well-known/api-catalog is 301-redirected to a trailing-slash URL + // that 404s. A 200-rewrite to the .json file keeps the canonical path + // resolving with a 200 (the status the RFC 9727 scanner requires). + describe('api-catalog routing', () => { + it('rewrites the canonical path to the .json file with status 200', () => { + const rule = (redirects as any[]).find( + (r) => r.source === '/.well-known/api-catalog' + ); + + expect(rule).toBeDefined(); + expect(rule.target).toBe('/.well-known/api-catalog.json'); + expect(rule.status).toBe('200'); + }); + }); }); diff --git a/tasks/generate-wellknown.mjs b/tasks/generate-wellknown.mjs index 2007da66953..87c66979c3f 100644 --- a/tasks/generate-wellknown.mjs +++ b/tasks/generate-wellknown.mjs @@ -62,11 +62,19 @@ export function generateApiCatalog(domain = DOMAIN) { } /** - * Writes the API catalog to /.well-known/api-catalog in the build output. + * Writes the API catalog to the build output. + * + * The file is written with a `.json` extension (api-catalog.json) because the + * site builds with `trailingSlash: true`: Amplify Hosting 301-redirects + * extensionless paths (e.g. /.well-known/api-catalog) to a trailing-slash URL + * that has no corresponding file, returning 404. Files with an extension are + * served directly with a 200. A 200-rewrite in redirects.json maps the + * RFC 9727 canonical path /.well-known/api-catalog to this file so the + * extensionless path resolves with a 200 in place. */ export async function writeApiCatalog() { const wellKnownDir = path.join(ROOT_PATH, '.well-known'); - const catalogPath = path.join(wellKnownDir, 'api-catalog'); + const catalogPath = path.join(wellKnownDir, 'api-catalog.json'); try { await fs.mkdir(wellKnownDir, { recursive: true }); From 4c196e141d905fd9155fc7940787354e400e8d9e Mon Sep 17 00:00:00 2001 From: Harsh <6162866+harsh62@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:37:45 -0400 Subject: [PATCH 8/8] refactor: address PR review feedback on agent-readiness changes - getMarkdownUrl: strip query string and hash before building the .md URL, so all three consumers (link rel=alternate, WebMcp, copy menu) get a valid URL for routes with ?query or #hash - generators: rethrow write errors in the api-catalog, MCP server card, and agent-skills writers so a failed write fails the build instead of shipping a green build whose global Link header advertises a missing file - WebMcp: register each tool in its own try so one rejected registration can't block the others; reuse the shared fetchPageMarkdown helper - MarkdownMenu: extract shared fetchPageMarkdown (used by copy menu and WebMcp) so the SPA-HTML fallback guard lives in one place - Layout: fix inert isOverview guard (typeof x !== 'undefined', not x != 'undefined') - tasks: extract shared build-constants.mjs (DOMAIN, ROOT_PATH) and a CANONICAL_PLATFORM constant used for the agent-skills URL - customHttp.yml: document the deliberate choice to keep the Link header on the global block (Amplify patterns are positive-match only; trailingSlash makes pages extensionless, so an html-only pattern would miss real page loads) - tests: query/hash stripping, fetchPageMarkdown, isolated tool registration, and redirect-ordering coverage --- customHttp.yml | 9 ++ src/components/Layout/Layout.tsx | 2 +- src/components/MarkdownMenu/MarkdownMenu.tsx | 36 +++++-- .../__tests__/MarkdownMenu.test.tsx | 66 ++++++++++++- src/components/MarkdownMenu/index.ts | 2 +- src/components/WebMcp/WebMcp.tsx | 95 +++++++++---------- .../WebMcp/__tests__/WebMcp.test.tsx | 20 ++++ tasks/__tests__/generate-wellknown.test.ts | 16 ++++ tasks/build-constants.mjs | 18 ++++ tasks/generate-agent-skills.mjs | 17 +--- tasks/generate-sitemap.mjs | 11 +-- tasks/generate-wellknown.mjs | 18 ++-- 12 files changed, 213 insertions(+), 97 deletions(-) create mode 100644 tasks/build-constants.mjs diff --git a/customHttp.yml b/customHttp.yml index 7ffe66711d5..3601f0c5980 100644 --- a/customHttp.yml +++ b/customHttp.yml @@ -18,6 +18,15 @@ customHeaders: # Link headers advertise agent-discovery resources (RFC 8288 / RFC 9727): # the API catalog, the agent skills index, the MCP server card, and the # LLM-friendly documentation index. + # + # Deliberately kept on the global block. Amplify header patterns are + # positive-match only (no exclusion), and with trailingSlash: true the + # actual page requests are extensionless directory URLs (e.g. + # /react/build-a-backend/), so an `**/*.html` pattern would miss real + # page loads and drop the header where agents need it most. The header + # therefore also rides asset responses (a few extra bytes); the + # missing-file risk this could have amplified is eliminated because the + # generators now fail the build if a linked file is not written. - key: 'Link' value: '; rel="api-catalog", ; rel="agent-skills"; type="application/json", ; rel="mcp-server"; type="application/json", ; rel="service-doc"; type="text/plain"' # Serve the API catalog as a linkset (RFC 9727 / RFC 9264) at both the diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index e2508856828..f2235e4e745 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -168,7 +168,7 @@ export const Layout = ({ }; const isOverview = - children?.props?.childPageNodes?.length != 'undefined' && + typeof children?.props?.childPageNodes?.length !== 'undefined' && children?.props?.childPageNodes?.length > 0; // Per-page markdown alternate for agent autodiscovery. Only Gen2 content diff --git a/src/components/MarkdownMenu/MarkdownMenu.tsx b/src/components/MarkdownMenu/MarkdownMenu.tsx index 355ecd96d14..8e552d25d5b 100644 --- a/src/components/MarkdownMenu/MarkdownMenu.tsx +++ b/src/components/MarkdownMenu/MarkdownMenu.tsx @@ -9,13 +9,35 @@ interface MarkdownMenuProps { } export function getMarkdownUrl(route: string): string { - // Strip platform prefix and trailing slash - // e.g. /react/build-a-backend/auth/set-up-auth/ → build-a-backend/auth/set-up-auth - const parts = route.replace(/^\//, '').replace(/\/$/, '').split('/'); + // Strip any query string / hash, then the platform prefix and trailing slash. + // e.g. /react/build-a-backend/auth/set-up-auth/?foo=bar#x + // → build-a-backend/auth/set-up-auth + const pathOnly = route.replace(/[?#].*$/, ''); + const parts = pathOnly.replace(/^\//, '').replace(/\/$/, '').split('/'); const withoutPlatform = parts.slice(1).join('/'); return `/ai/pages/${withoutPlatform}.md`; } +/** + * Fetch a page's generated Markdown, rejecting the SPA HTML fallback (e.g. a + * 404 page) that Amplify serves when the .md file is missing. Shared by the + * copy/open menu and the WebMCP tools so the fallback detection lives in one + * place. + */ +export async function fetchPageMarkdown(url: string): Promise { + const response = await fetch(url, { + headers: { accept: 'text/markdown, text/plain' } + }); + if (!response.ok) { + throw new Error(`Request for ${url} failed with status ${response.status}`); + } + const text = await response.text(); + if (/^\s* { try { - const response = await fetch(mdUrl); - if (!response.ok) return; - const text = await response.text(); - // Guard against accidentally copying HTML (e.g. 404 page) - if (/^\s* setCopied(false), 2000); } catch { - // Silently fail if clipboard not available + // Silently fail if the markdown is unavailable or clipboard is blocked } }, [mdUrl]); diff --git a/src/components/MarkdownMenu/__tests__/MarkdownMenu.test.tsx b/src/components/MarkdownMenu/__tests__/MarkdownMenu.test.tsx index 6b4e314aaaa..73ceb904a27 100644 --- a/src/components/MarkdownMenu/__tests__/MarkdownMenu.test.tsx +++ b/src/components/MarkdownMenu/__tests__/MarkdownMenu.test.tsx @@ -1,7 +1,11 @@ import * as React from 'react'; import { render, screen, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MarkdownMenu } from '../MarkdownMenu'; +import { + MarkdownMenu, + getMarkdownUrl, + fetchPageMarkdown +} from '../MarkdownMenu'; // Mock fetch and clipboard const mockFetch = jest.fn(); @@ -75,7 +79,10 @@ describe('MarkdownMenu', () => { await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith( - '/ai/pages/build-a-backend/auth/set-up-auth.md' + '/ai/pages/build-a-backend/auth/set-up-auth.md', + expect.objectContaining({ + headers: expect.objectContaining({ accept: expect.any(String) }) + }) ); }); expect(mockWriteText).toHaveBeenCalledWith(mdContent); @@ -119,7 +126,10 @@ describe('MarkdownMenu', () => { await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith( - '/ai/pages/build-a-backend/auth/set-up-auth.md' + '/ai/pages/build-a-backend/auth/set-up-auth.md', + expect.objectContaining({ + headers: expect.objectContaining({ accept: expect.any(String) }) + }) ); }); }); @@ -147,3 +157,53 @@ describe('MarkdownMenu', () => { }); }); }); + +describe('getMarkdownUrl', () => { + it('maps a platform route to its markdown twin', () => { + expect(getMarkdownUrl('/react/build-a-backend/auth/set-up-auth/')).toBe( + '/ai/pages/build-a-backend/auth/set-up-auth.md' + ); + }); + + it('strips a query string before building the URL', () => { + expect( + getMarkdownUrl('/react/build-a-backend/auth/set-up-auth/?foo=bar') + ).toBe('/ai/pages/build-a-backend/auth/set-up-auth.md'); + }); + + it('strips a hash fragment before building the URL', () => { + expect( + getMarkdownUrl('/react/build-a-backend/auth/set-up-auth/#section') + ).toBe('/ai/pages/build-a-backend/auth/set-up-auth.md'); + }); +}); + +describe('fetchPageMarkdown', () => { + beforeEach(() => { + global.fetch = mockFetch; + mockFetch.mockReset(); + }); + + it('returns the markdown text on a successful response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve('# Title\n\nBody') + }); + await expect(fetchPageMarkdown('/ai/pages/x.md')).resolves.toBe( + '# Title\n\nBody' + ); + }); + + it('throws when the response is not ok', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + await expect(fetchPageMarkdown('/ai/pages/missing.md')).rejects.toThrow(); + }); + + it('throws when the SPA HTML fallback is served instead of markdown', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve('404') + }); + await expect(fetchPageMarkdown('/ai/pages/x.md')).rejects.toThrow(); + }); +}); diff --git a/src/components/MarkdownMenu/index.ts b/src/components/MarkdownMenu/index.ts index 17b9c7b3c9e..7c029a1b738 100644 --- a/src/components/MarkdownMenu/index.ts +++ b/src/components/MarkdownMenu/index.ts @@ -1 +1 @@ -export { MarkdownMenu, getMarkdownUrl } from './MarkdownMenu'; +export { MarkdownMenu, getMarkdownUrl, fetchPageMarkdown } from './MarkdownMenu'; diff --git a/src/components/WebMcp/WebMcp.tsx b/src/components/WebMcp/WebMcp.tsx index 853df5750a0..9d21c367d5d 100644 --- a/src/components/WebMcp/WebMcp.tsx +++ b/src/components/WebMcp/WebMcp.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { getMarkdownUrl } from '@/components/MarkdownMenu'; +import { getMarkdownUrl, fetchPageMarkdown } from '@/components/MarkdownMenu'; /** * Minimal shape of the WebMCP API we depend on. The spec exposes @@ -39,21 +39,20 @@ function getModelContext(): ModelContextLike | null { } /** - * Fetch a markdown document and return its text, guarding against the SPA - * fallback returning an HTML page (e.g. a 404) instead of markdown. + * Register a single tool, isolating failures so one rejected registration + * (e.g. a transient duplicate-name error during abort/re-register on fast + * client-side navigation) can't prevent the others from registering. */ -async function fetchMarkdown(url: string): Promise { - const response = await fetch(url, { - headers: { accept: 'text/markdown, text/plain' } - }); - if (!response.ok) { - throw new Error(`Request for ${url} failed with status ${response.status}`); +async function safeRegister( + modelContext: ModelContextLike, + tool: ModelContextTool, + signal: AbortSignal +): Promise { + try { + await modelContext.registerTool(tool, { signal }); + } catch { + // Registration is best-effort; ignore failures for this tool. } - const text = await response.text(); - if (/^\s* { - try { - await modelContext.registerTool( - { - name: 'get_current_page_markdown', - description: - 'Return the current AWS Amplify documentation page as clean Markdown, ideal for reading or summarizing without HTML chrome.', - inputSchema: { type: 'object', properties: {} }, - annotations: { readOnlyHint: true }, - execute: async () => { - const markdown = await fetchMarkdown(origin + getMarkdownUrl(route)); - return { markdown }; - } - }, - { signal } - ); - - await modelContext.registerTool( - { - name: 'get_documentation_index', - description: - 'Return the AWS Amplify documentation index (llms.txt), a Markdown list of all documentation pages with descriptions and links to their Markdown versions.', - inputSchema: { type: 'object', properties: {} }, - annotations: { readOnlyHint: true }, - execute: async () => { - const index = await fetchMarkdown(origin + '/ai/llms.txt'); - return { index }; - } - }, - { signal } - ); - } catch { - // Registration is best-effort; ignore failures (e.g. duplicate names - // during fast client-side navigations or unsupported environments). - } - }; + // Each tool is registered independently so one failure can't block the rest. + safeRegister( + modelContext, + { + name: 'get_current_page_markdown', + description: + 'Return the current AWS Amplify documentation page as clean Markdown, ideal for reading or summarizing without HTML chrome.', + inputSchema: { type: 'object', properties: {} }, + annotations: { readOnlyHint: true }, + execute: async () => { + const markdown = await fetchPageMarkdown(origin + getMarkdownUrl(route)); + return { markdown }; + } + }, + signal + ); - register(); + safeRegister( + modelContext, + { + name: 'get_documentation_index', + description: + 'Return the AWS Amplify documentation index (llms.txt), a Markdown list of all documentation pages with descriptions and links to their Markdown versions.', + inputSchema: { type: 'object', properties: {} }, + annotations: { readOnlyHint: true }, + execute: async () => { + const index = await fetchPageMarkdown(origin + '/ai/llms.txt'); + return { index }; + } + }, + signal + ); return () => controller.abort(); }, [route]); diff --git a/src/components/WebMcp/__tests__/WebMcp.test.tsx b/src/components/WebMcp/__tests__/WebMcp.test.tsx index 22be1bca9e1..ecc4743c48b 100644 --- a/src/components/WebMcp/__tests__/WebMcp.test.tsx +++ b/src/components/WebMcp/__tests__/WebMcp.test.tsx @@ -71,4 +71,24 @@ describe('WebMcp', () => { markdown: '# Set up auth\n\nReal markdown content.' }); }); + + it('registers the second tool even if the first registration rejects', async () => { + const registerTool = jest + .fn() + .mockRejectedValueOnce(new Error('duplicate tool name')) + .mockResolvedValueOnce(undefined); + (document as unknown as { modelContext: unknown }).modelContext = { + registerTool + }; + + await act(async () => { + render(); + }); + + await waitFor(() => expect(registerTool).toHaveBeenCalledTimes(2)); + + const toolNames = registerTool.mock.calls.map((c) => c[0].name); + expect(toolNames).toContain('get_current_page_markdown'); + expect(toolNames).toContain('get_documentation_index'); + }); }); diff --git a/tasks/__tests__/generate-wellknown.test.ts b/tasks/__tests__/generate-wellknown.test.ts index 94e983e2f44..9dede4903d2 100644 --- a/tasks/__tests__/generate-wellknown.test.ts +++ b/tasks/__tests__/generate-wellknown.test.ts @@ -112,5 +112,21 @@ describe('generate-wellknown', () => { expect(rule.target).toBe('/.well-known/api-catalog.json'); expect(rule.status).toBe('200'); }); + + it('orders the rewrite before any catch-all that could shadow it', () => { + const rules = redirects as any[]; + const catalogIndex = rules.findIndex( + (r) => r.source === '/.well-known/api-catalog' + ); + // Redirects apply top-down; a broad wildcard placed earlier would win. + const firstCatchAllIndex = rules.findIndex((r) => + /<\*>|\/\*/.test(r.source) + ); + + expect(catalogIndex).toBeGreaterThanOrEqual(0); + if (firstCatchAllIndex !== -1) { + expect(catalogIndex).toBeLessThan(firstCatchAllIndex); + } + }); }); }); diff --git a/tasks/build-constants.mjs b/tasks/build-constants.mjs new file mode 100644 index 00000000000..761dba63301 --- /dev/null +++ b/tasks/build-constants.mjs @@ -0,0 +1,18 @@ +import dotenv from 'dotenv'; + +dotenv.config({ path: './.env.custom' }); + +// Canonical site origin used to build absolute URLs in generated artifacts +// (sitemap, robots.txt, and the .well-known discovery files). +export const DOMAIN = process.env.SITEMAP_DOMAIN + ? process.env.SITEMAP_DOMAIN + : 'https://docs.amplify.aws'; + +// Path of the Next.js static HTML build output that postBuildTasks writes into. +export const ROOT_PATH = './client/www/next-build'; + +// Canonical platform used when building absolute doc URLs for platform-agnostic +// pages. The site requires a platform segment (a platform-neutral path 404s), +// and the existing llms.txt generator canonicalizes on this same platform. +// Defined once here so a canonical-platform change is a single edit. +export const CANONICAL_PLATFORM = 'react'; diff --git a/tasks/generate-agent-skills.mjs b/tasks/generate-agent-skills.mjs index cd4eacaeefa..d272c93a428 100644 --- a/tasks/generate-agent-skills.mjs +++ b/tasks/generate-agent-skills.mjs @@ -1,16 +1,6 @@ import { promises as fs } from 'fs'; import path from 'path'; -import dotenv from 'dotenv'; - -dotenv.config({ path: './.env.custom' }); - -const DOMAIN = process.env.SITEMAP_DOMAIN - ? process.env.SITEMAP_DOMAIN - : 'https://docs.amplify.aws'; - -// Path of the Next.js static HTML build output (same target used for -// robots.txt, sitemap.xml, and the API catalog in postBuildTasks). -const ROOT_PATH = './client/www/next-build'; +import { DOMAIN, ROOT_PATH, CANONICAL_PLATFORM } from './build-constants.mjs'; // Agent Skills Discovery RFC v0.2.0 well-known location. const INDEX_SUBPATH = '.well-known/agent-skills/index.json'; @@ -38,7 +28,7 @@ function getSkills(domain) { type: 'claude-skill', description: 'Build and deploy full-stack web and mobile apps with AWS Amplify Gen2 (TypeScript code-first). Covers auth (Cognito), data (AppSync/DynamoDB), storage (S3), functions, APIs, and AI (Amplify AI Kit with Bedrock) across React, Next.js, Vue, Angular, React Native, Flutter, Swift, and Android.', - url: `${domain}/react/develop-with-ai/agent-plugins/` + url: `${domain}/${CANONICAL_PLATFORM}/develop-with-ai/agent-plugins/` } ]; } @@ -69,6 +59,9 @@ export async function writeAgentSkillsIndex() { await fs.writeFile(indexPath, generateAgentSkillsIndex()); console.log(`agent-skills index written to ${indexPath}`); } catch (error) { + // Fail the build: the global Link header advertises this file, so shipping + // without it would point agents at a 404. console.error(`Error writing agent-skills index to ${indexPath}:`, error); + throw error; } } diff --git a/tasks/generate-sitemap.mjs b/tasks/generate-sitemap.mjs index abc00bae039..64a011f97e9 100644 --- a/tasks/generate-sitemap.mjs +++ b/tasks/generate-sitemap.mjs @@ -2,17 +2,10 @@ import { promises as fs } from 'fs'; import { execSync } from 'child_process'; import crypto from 'node:crypto'; import * as cheerio from 'cheerio'; -import dotenv from 'dotenv'; import flatDirectory from '../src/directory/flatDirectory.json' with { type: 'json' }; +import { DOMAIN, ROOT_PATH } from './build-constants.mjs'; -dotenv.config({ path: './.env.custom' }); - -const SITEMAP_DOMAIN = process.env.SITEMAP_DOMAIN - ? process.env.SITEMAP_DOMAIN - : 'https://docs.amplify.aws'; - -// Path of the Next.js static HTML build output -const ROOT_PATH = './client/www/next-build'; +const SITEMAP_DOMAIN = DOMAIN; const formatDate = (date) => date.toISOString(); const getPriority = () => 0.5; diff --git a/tasks/generate-wellknown.mjs b/tasks/generate-wellknown.mjs index 87c66979c3f..7d20a7d9a0c 100644 --- a/tasks/generate-wellknown.mjs +++ b/tasks/generate-wellknown.mjs @@ -1,16 +1,6 @@ import { promises as fs } from 'fs'; import path from 'path'; -import dotenv from 'dotenv'; - -dotenv.config({ path: './.env.custom' }); - -const DOMAIN = process.env.SITEMAP_DOMAIN - ? process.env.SITEMAP_DOMAIN - : 'https://docs.amplify.aws'; - -// Path of the Next.js static HTML build output (same target used for -// robots.txt and sitemap.xml in postBuildTasks). -const ROOT_PATH = './client/www/next-build'; +import { DOMAIN, ROOT_PATH } from './build-constants.mjs'; /** * Build the API catalog linkset document (RFC 9727 / RFC 9264). @@ -81,7 +71,10 @@ export async function writeApiCatalog() { await fs.writeFile(catalogPath, generateApiCatalog()); console.log(`api-catalog written to ${catalogPath}`); } catch (error) { + // Fail the build: the global Link header advertises this file, so shipping + // without it would point agents at a 404. console.error(`Error writing api-catalog to ${catalogPath}:`, error); + throw error; } } @@ -143,6 +136,9 @@ export async function writeMcpServerCard() { await fs.writeFile(cardPath, generateMcpServerCard()); console.log(`mcp server-card written to ${cardPath}`); } catch (error) { + // Fail the build: the global Link header advertises this file, so shipping + // without it would point agents at a 404. console.error(`Error writing mcp server-card to ${cardPath}:`, error); + throw error; } }