diff --git a/.github/workflows/www-tests.yml b/.github/workflows/www-tests.yml index 96f2737cccf..3e9b8dc1dbb 100644 --- a/.github/workflows/www-tests.yml +++ b/.github/workflows/www-tests.yml @@ -11,6 +11,9 @@ on: - 'apps/www/content/md/**' - 'apps/www/scripts/**/*.mjs' - 'apps/www/public/.well-known/**' + # www catalog tests check that linked docs guides exist, so guide changes + # must trigger these tests and their sources must be included in checkout. + - 'apps/docs/content/guides/**' # Cancel old builds on new commit for same workflow + branch/PR concurrency: @@ -33,6 +36,7 @@ jobs: persist-credentials: false sparse-checkout: | apps/www + apps/docs/content/guides packages supabase patches diff --git a/apps/www/app/llms.txt/route.ts b/apps/www/app/llms.txt/route.ts index 230a36ddf04..5773b04cc77 100644 --- a/apps/www/app/llms.txt/route.ts +++ b/apps/www/app/llms.txt/route.ts @@ -3,6 +3,8 @@ import path from 'node:path' import { isFeatureEnabled } from 'common/enabled-features' import matter from 'gray-matter' +import { AGENT_RESOURCES } from '@/lib/agent-resources' + export const dynamic = 'force-dynamic' interface Source { @@ -81,6 +83,10 @@ export async function GET() { .map((source) => `- [${source.title}](https://supabase.com/${source.relPath})`) .join('\n') + const agentResourceLinks = AGENT_RESOURCES.map( + (resource) => `- [${resource.title}](${resource.url}): ${resource.description}` + ).join('\n') + const content = [ '# Supabase Docs', '', @@ -93,6 +99,10 @@ export async function GET() { '## Pricing', '', '- [Supabase Pricing](https://supabase.com/pricing.md)', + '', + '## API and agent resources', + '', + agentResourceLinks, ].join('\n') return new Response(content, { diff --git a/apps/www/ard-catalog.test.ts b/apps/www/ard-catalog.test.ts index 92ef005e68a..0f11be14d2f 100644 --- a/apps/www/ard-catalog.test.ts +++ b/apps/www/ard-catalog.test.ts @@ -2,6 +2,7 @@ import { existsSync, promises as fs } from 'node:fs' import path from 'node:path' import { describe, expect, it } from 'vitest' +import { AGENT_RESOURCES } from './lib/agent-resources' import rewrites from './lib/rewrites' const CANONICAL_ORIGIN = 'https://supabase.com' @@ -9,7 +10,7 @@ const CANONICAL_ORIGIN = 'https://supabase.com' const NOT_AGENT_RESOURCES: Record = { 'ard.json': 'the catalog itself', 'api-catalog': - 'peer catalog (RFC 9727); its resource, the Management API spec, has its own entry', + 'peer catalog (RFC 9727); its resources, the Management API spec and the MCP server, have their own entries', 'ai-catalog.json': 'legacy ARD alias, rewritten to ard.json', 'mcp-registry-auth': 'domain-ownership verification token', 'openai-apps-challenge': 'domain-ownership verification token', @@ -19,6 +20,11 @@ const NOT_AGENT_RESOURCES: Record = { type ArdEntry = { identifier: string; url: string } +type LinksetLink = { href: string } +type LinksetMember = { anchor: string } & Record + +const rewriteSources: string[] = rewrites.map((rewrite: { source: string }) => rewrite.source) + async function loadArdCatalog(): Promise<{ entries: ArdEntry[] }> { const raw = await fs.readFile( path.join(process.cwd(), 'public', '.well-known', 'ard.json'), @@ -27,13 +33,52 @@ async function loadArdCatalog(): Promise<{ entries: ArdEntry[] }> { return JSON.parse(raw) } -function wellKnownRewriteSources(): string[] { - return rewrites - .map((rewrite: { source: string }) => rewrite.source) - .filter((source: string) => source.startsWith('/.well-known/')) - .map((source: string) => source.replace('/.well-known/', '')) +async function loadApiCatalog(): Promise<{ linkset: LinksetMember[] }> { + const raw = await fs.readFile( + path.join(process.cwd(), 'public', '.well-known', 'api-catalog'), + 'utf-8' + ) + return JSON.parse(raw) } +function wellKnownRewriteSources(): string[] { + return rewriteSources + .filter((source) => source.startsWith('/.well-known/')) + .map((source) => source.replace('/.well-known/', '')) +} + +function sameOriginPathname(url: string): string | null { + const parsed = new URL(url) + return parsed.origin === CANONICAL_ORIGIN ? parsed.pathname : null +} + +function docsGuideSource(pathname: string): string | null { + const prefix = '/docs/guides/' + if (!pathname.startsWith(prefix)) return null + return path.join( + process.cwd(), + '..', + 'docs', + 'content', + 'guides', + `${pathname.slice(prefix.length)}.mdx` + ) +} + +function resolvesLocally(pathname: string): boolean { + const publicFile = path.join(process.cwd(), 'public', pathname) + const appRoute = path.join(process.cwd(), 'app', pathname, 'route.ts') + const docsGuide = docsGuideSource(pathname) + return ( + existsSync(publicFile) || + existsSync(appRoute) || + rewriteSources.includes(pathname) || + (docsGuide !== null && existsSync(docsGuide)) + ) +} + +const RESOLVES_TO = 'a file in public/, an app route, a rewrite source, or a docs guide' + describe('agent discovery catalog (.well-known/ard.json)', () => { it('every .well-known surface is cataloged or explicitly marked as not an agent resource', async () => { const { entries } = await loadArdCatalog() @@ -61,19 +106,60 @@ describe('agent discovery catalog (.well-known/ard.json)', () => { const { entries } = await loadArdCatalog() expect(entries.length).toBeGreaterThan(0) - const rewriteSources = rewrites.map((rewrite: { source: string }) => rewrite.source) - for (const entry of entries) { - const url = new URL(entry.url) - if (url.origin !== CANONICAL_ORIGIN) continue - - const publicFile = path.join(process.cwd(), 'public', url.pathname) - const appRoute = path.join(process.cwd(), 'app', url.pathname, 'route.ts') - const resolves = - existsSync(publicFile) || existsSync(appRoute) || rewriteSources.includes(url.pathname) + const pathname = sameOriginPathname(entry.url) + if (pathname === null) continue expect( - resolves, - `ard.json entry "${entry.identifier}" points at ${entry.url}, but ${url.pathname} is not a file in public/, an app route, or a rewrite source — the catalog is advertising a dead URL` + resolvesLocally(pathname), + `ard.json entry "${entry.identifier}" points at ${entry.url}, but ${pathname} is not ${RESOLVES_TO}: the catalog is advertising a dead URL` + ).toBe(true) + } + }) +}) + +describe('API catalog (.well-known/api-catalog)', () => { + it('lists every described API as a catalog item and describes every listed item', async () => { + const [catalog, ...apis] = (await loadApiCatalog()).linkset + expect(catalog.anchor).toBe(`${CANONICAL_ORIGIN}/.well-known/api-catalog`) + + const itemHrefs = (catalog.item as LinksetLink[]).map((link) => link.href).sort() + const apiAnchors = apis.map((api) => api.anchor).sort() + expect(apiAnchors.length).toBeGreaterThan(0) + expect(itemHrefs).toEqual(apiAnchors) + }) + + it('every same-origin link resolves to a public file, app route, rewrite, or docs guide', async () => { + const { linkset } = await loadApiCatalog() + const hrefs = linkset.flatMap((member) => + Object.entries(member).flatMap(([relation, value]) => + relation === 'anchor' + ? [value as string] + : (value as LinksetLink[]).map((link) => link.href) + ) + ) + expect(hrefs.length).toBeGreaterThan(0) + + for (const href of hrefs) { + const pathname = sameOriginPathname(href) + if (pathname === null) continue + expect( + resolvesLocally(pathname), + `api-catalog links to ${href}, but ${pathname} is not ${RESOLVES_TO}` + ).toBe(true) + } + }) +}) + +describe('llms.txt agent resources', () => { + it('every same-origin resource resolves to a public file, app route, rewrite, or docs guide', () => { + expect(AGENT_RESOURCES.length).toBeGreaterThan(0) + + for (const resource of AGENT_RESOURCES) { + const pathname = sameOriginPathname(resource.url) + if (pathname === null) continue + expect( + resolvesLocally(pathname), + `llms.txt lists ${resource.url}, but ${pathname} is not ${RESOLVES_TO}` ).toBe(true) } }) diff --git a/apps/www/lib/agent-resources.ts b/apps/www/lib/agent-resources.ts new file mode 100644 index 00000000000..3e829428d7c --- /dev/null +++ b/apps/www/lib/agent-resources.ts @@ -0,0 +1,14 @@ +export const AGENT_RESOURCES: ReadonlyArray<{ title: string; url: string; description: string }> = [ + { + title: 'Supabase Management API OpenAPI spec', + url: 'https://supabase.com/openapi.json', + description: + 'OpenAPI 3.0 description of the Management API for managing organizations, projects, branches, and configuration', + }, + { + title: 'Supabase MCP server', + url: 'https://mcp.supabase.com/mcp', + description: + 'Streamable HTTP MCP endpoint, OAuth-protected, for managing projects, database schema, and queries from MCP clients', + }, +] diff --git a/apps/www/public/.well-known/api-catalog b/apps/www/public/.well-known/api-catalog index 7fda0319c61..35c3af8f5a2 100644 --- a/apps/www/public/.well-known/api-catalog +++ b/apps/www/public/.well-known/api-catalog @@ -5,6 +5,9 @@ "item": [ { "href": "https://api.supabase.com/v1" + }, + { + "href": "https://mcp.supabase.com/mcp" } ] }, @@ -26,6 +29,20 @@ "href": "https://status.supabase.com" } ] + }, + { + "anchor": "https://mcp.supabase.com/mcp", + "service-doc": [ + { + "href": "https://supabase.com/docs/guides/ai-tools/mcp" + } + ], + "service-meta": [ + { + "href": "https://mcp.supabase.com/.well-known/oauth-protected-resource/mcp", + "type": "application/json" + } + ] } ] }