mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
4f10a55983
commit
c6a1c2052c
5 files changed
+148
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user