feat(www): cross-list openapi and mcp endpoint (#50180)

`/.well-known/ard.json` advertises the Management API OpenAPI spec and
the MCP server, but `/llms.txt` listed neither and
`/.well-known/api-catalog` listed only the Management API. I added both
resources to the two surfaces that were missing them, so an agent finds
the same spec and endpoint whichever discovery file it reads first.

**Changed:**
- **llms.txt gains an `## API and agent resources` section**: two
described links, the same-origin `/openapi.json` spec and
`https://mcp.supabase.com/mcp`, from a small list in
`lib/agent-resources.ts`. A named heading rather than `## Optional`,
since llmstxt.org defines Optional as links an agent may skip. The
descriptions restate ard.json's on purpose; ard.json is curated to the
ARD schema and stays untouched.
- **api-catalog lists the MCP endpoint**: added as a catalog `item` plus
its own linkset member carrying `service-doc` (the MCP guide) and
`service-meta` (the OAuth protected-resource metadata the endpoint's 401
response already points at).
- **Tests cover what the two files advertise**: `ard-catalog.test.ts`
now parses api-catalog, checks that its `item` list and its anchored
members agree, and runs every same-origin URL from api-catalog and the
llms.txt resource list through the existing dead-URL resolver (public
file, app route, rewrite, or docs guide). The www tests workflow now
checks out `apps/docs/content/guides` (the directory the llms.txt route
already reads at runtime) and runs on changes to it, so moving a guide
that a catalog links to fails that PR rather than the next www one.

**Note:** `/openapi.json` is an external rewrite served uncached on
every request (338 KB). I tried `Cache-Control` and then the documented
`x-vercel-enable-rewrite-caching` + `CDN-Cache-Control` pair on that
path; the preview kept returning `x-vercel-cache: MISS`, so both are
reverted. Caching the alias is a separate change.

## To test

Tested on Vercel preview:
- [x] `curl -s <preview>/llms.txt | tail -5`: expect an `## API and
agent resources` heading followed by the OpenAPI spec link and the MCP
server link; the diff against production `llms.txt` is those appended
lines only
- [x] `curl -s <preview>/.well-known/api-catalog | jq '.linkset[2]'`:
expect a member anchored at `https://mcp.supabase.com/mcp` with
`service-doc` and `service-meta`, served as `application/linkset+json`

## Linear
- fixes GROWTH-1207


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added API and agent resource links to `llms.txt`, including the
Management API specification and MCP server.
- Added the Supabase MCP server to the API catalog with service
documentation and metadata links.

- **Tests**
- Expanded catalog validation to cover API catalog entries, agent
resources, and documentation guide links.
  - Updated pull request checks to run when guide content changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Pamela Chia authored and GitHub committed 2026-09-11 16:12:44 +08:00
1 parent 4f10a55983
commit c6a1c2052c
5 files changed
+148 -17

No files matched your search

+4
View File
@@ -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
+10
View File
@@ -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, {
+103 -17
View File
@@ -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<string, string> = {
'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<string, string> = {
type ArdEntry = { identifier: string; url: string }
type LinksetLink = { href: string }
type LinksetMember = { anchor: string } & Record<string, string | LinksetLink[]>
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)
}
})
+14
View File
@@ -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',
},
]
+17
View File
@@ -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"
}
]
}
]
}