diff --git a/customHttp.yml b/customHttp.yml index 9e866dc7f7f..3601f0c5980 100644 --- a/customHttp.yml +++ b/customHttp.yml @@ -15,3 +15,34 @@ 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, 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 + # 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' + # 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/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/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/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index e1bc3a8744a..f2235e4e745 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -44,7 +44,8 @@ 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'; +import { WebMcp } from '@/components/WebMcp'; export const Layout = ({ children, @@ -167,9 +168,17 @@ 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 + // 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 +276,14 @@ export const Layout = ({ <> {`${title}`} + {markdownUrl && ( + + )} + {markdownUrl && } { + 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 daceaa3ef68..7c029a1b738 100644 --- a/src/components/MarkdownMenu/index.ts +++ b/src/components/MarkdownMenu/index.ts @@ -1 +1 @@ -export { MarkdownMenu } from './MarkdownMenu'; +export { MarkdownMenu, getMarkdownUrl, fetchPageMarkdown } from './MarkdownMenu'; diff --git a/src/components/WebMcp/WebMcp.tsx b/src/components/WebMcp/WebMcp.tsx new file mode 100644 index 00000000000..9d21c367d5d --- /dev/null +++ b/src/components/WebMcp/WebMcp.tsx @@ -0,0 +1,113 @@ +import { useEffect } from 'react'; +import { getMarkdownUrl, fetchPageMarkdown } from '@/components/MarkdownMenu'; + +/** + * Minimal shape of the WebMCP API we depend on. The spec exposes + * `document.modelContext.registerTool`; some early implementations also alias + * it on `navigator.modelContext`. We feature-detect both and treat the API as + * entirely optional so this is a silent no-op in browsers without WebMCP. + * + * See https://webmachinelearning.github.io/webmcp/ + */ +interface ModelContextTool { + name: string; + description: string; + inputSchema?: object; + annotations?: { readOnlyHint?: boolean }; + execute: (input: Record) => 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; +} + +/** + * 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 safeRegister( + modelContext: ModelContextLike, + tool: ModelContextTool, + signal: AbortSignal +): Promise { + try { + await modelContext.registerTool(tool, { signal }); + } catch { + // Registration is best-effort; ignore failures for this tool. + } +} + +/** + * Registers read-only WebMCP tools that expose this documentation site's key + * actions to in-browser AI agents. Every tool is backed by content the site + * already generates (the per-page markdown twins under /ai/pages and the + * llms.txt index), so the tools return real data rather than stubs. + * + * Renders nothing; the registration happens as a side effect on mount and is + * torn down via an AbortSignal on unmount. + */ +export function WebMcp({ route }: { route: string }) { + useEffect(() => { + const modelContext = getModelContext(); + if (!modelContext) return; + + const controller = new AbortController(); + const { signal } = controller; + const origin = window.location.origin; + + // 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 + ); + + 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]); + + 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..ecc4743c48b --- /dev/null +++ b/src/components/WebMcp/__tests__/WebMcp.test.tsx @@ -0,0 +1,94 @@ +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.' + }); + }); + + 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/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'; 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/__tests__/generate-wellknown.test.ts b/tasks/__tests__/generate-wellknown.test.ts new file mode 100644 index 00000000000..9dede4903d2 --- /dev/null +++ b/tasks/__tests__/generate-wellknown.test.ts @@ -0,0 +1,132 @@ +import { + generateApiCatalog, + generateMcpServerCard +} from '../generate-wellknown.mjs'; +import redirects from '../../redirects.json'; + +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 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'); + }); + + it('should advertise the sitemap as a service-meta link', () => { + const parsed = JSON.parse(generateApiCatalog('https://docs.amplify.aws')); + 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')); + + 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' + ]) + ); + }); + }); + + // 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'); + }); + + 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 new file mode 100644 index 00000000000..d272c93a428 --- /dev/null +++ b/tasks/generate-agent-skills.mjs @@ -0,0 +1,67 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +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'; +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}/${CANONICAL_PLATFORM}/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) { + // 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 4a6c02402a3..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; @@ -257,8 +250,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..7d20a7d9a0c --- /dev/null +++ b/tasks/generate-wellknown.mjs @@ -0,0 +1,144 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { DOMAIN, ROOT_PATH } from './build-constants.mjs'; + +/** + * 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 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 + */ +export function generateApiCatalog(domain = DOMAIN) { + const linkset = { + 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)' + } + ], + 'service-meta': [ + { + href: `${domain}/sitemap.xml`, + type: 'application/xml', + title: 'Sitemap' + } + ] + } + ] + }; + + return JSON.stringify(linkset, null, 2); +} + +/** + * 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.json'); + + try { + await fs.mkdir(wellKnownDir, { recursive: true }); + 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; + } +} + +// 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) { + // 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; + } +} diff --git a/tasks/postBuildTasks.mjs b/tasks/postBuildTasks.mjs index d3875a8751b..039305a9306 100644 --- a/tasks/postBuildTasks.mjs +++ b/tasks/postBuildTasks.mjs @@ -1,4 +1,9 @@ import { writeSitemap, writeRobots } from './generate-sitemap.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(); 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":