Skip to content

Commit 6a9a79d

Browse files
authored
improvement(seo): list the v2 API as a catalog item and answer markdown 404s (#9088)
* improvement(seo): list the v2 API as a catalog item and answer markdown 404s * fix(seo): keep the HTML 404 for clients that refuse Markdown and answer every method
1 parent fda495d commit 6a9a79d

4 files changed

Lines changed: 168 additions & 20 deletions

File tree

‎apps/sim/app/.well-known/api-catalog/route.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,25 @@ const API_CATALOG_CONTENT_TYPE =
88
'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"'
99

1010
/**
11-
* RFC 9727 API catalog: one machine-readable entry point to the v2 REST API, linking its OpenAPI
12-
* document (`service-desc`), reference docs (`service-doc`), and health endpoint (`status`) with
13-
* the RFC 8631 relations. The anchor is this deployment's own origin, so a self-hosted instance
11+
* RFC 9727 API catalog: one machine-readable entry point to the v2 REST API. The first context
12+
* lists the API as a catalog `item` (RFC 9727 §3.1); the second links its OpenAPI document
13+
* (`service-desc`), reference docs (`service-doc`), and health endpoint (`status`) with the
14+
* RFC 8631 relations. Both anchors are this deployment's own origin, so a self-hosted instance
1415
* describes itself. The OpenAPI document is the one published contract; its `servers` names Sim
1516
* Cloud, so a client of another deployment uses the anchor as its base URL, as the API docs say.
1617
* `Link: rel="api-catalog"` makes a HEAD request discoverable too (RFC 9727 §2).
1718
*/
1819
export const GET = withRouteHandler(() => {
1920
const baseUrl = getBaseUrl()
2021
const catalogUrl = `${baseUrl}/.well-known/api-catalog`
22+
const apiUrl = `${baseUrl}/api/v2`
2123

2224
return NextResponse.json(
2325
{
2426
linkset: [
27+
{ anchor: catalogUrl, item: [{ href: apiUrl }] },
2528
{
26-
anchor: `${baseUrl}/api/v2`,
29+
anchor: apiUrl,
2730
'service-desc': [
2831
{ href: `${DOCS_URL}/openapi.json`, type: 'application/vnd.oai.openapi+json' },
2932
],
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { toSiteUrl } from '@/lib/core/utils/urls'
2+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
3+
import { DOCS_URL } from '@/lib/help-links'
4+
5+
const NOT_FOUND_MARKDOWN = `# Page not found
6+
7+
There is no page at this address. It may have moved or never existed.
8+
9+
- [Sim home](${toSiteUrl('/')})
10+
- [Documentation](${DOCS_URL})
11+
- [llms.txt](${toSiteUrl('/llms.txt')}): an index of every public page
12+
- [Sitemap](${toSiteUrl('/sitemap.xml')})
13+
`
14+
15+
/**
16+
* The 404 body for a client that asks for Markdown. `next.config.ts` rewrites an unmatched path
17+
* here, whatever its method, only when its `Accept` header names `text/markdown`, so browsers keep
18+
* the HTML `not-found` page. Uncached like that page, and `Vary: Accept` because the same URL
19+
* answers both ways.
20+
*/
21+
const notFound = withRouteHandler(
22+
() =>
23+
new Response(NOT_FOUND_MARKDOWN, {
24+
status: 404,
25+
headers: {
26+
'Content-Type': 'text/markdown; charset=utf-8',
27+
'Cache-Control': 'no-store',
28+
Vary: 'Accept',
29+
},
30+
})
31+
)
32+
33+
export const GET = notFound
34+
export const POST = notFound
35+
export const PUT = notFound
36+
export const PATCH = notFound
37+
export const DELETE = notFound

‎apps/sim/next.config.ts‎

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -668,16 +668,40 @@ const nextConfig: NextConfig = {
668668
return redirects
669669
},
670670
async rewrites() {
671-
return [
672-
{
673-
source: '/favicon.ico',
674-
destination: '/icon.svg',
675-
},
676-
{
677-
source: '/r/:shortCode',
678-
destination: 'https://go.trybeluga.ai/:shortCode',
679-
},
680-
]
671+
return {
672+
beforeFiles: [],
673+
afterFiles: [
674+
{
675+
source: '/favicon.ico',
676+
destination: '/icon.svg',
677+
},
678+
{
679+
source: '/r/:shortCode',
680+
destination: 'https://go.trybeluga.ai/:shortCode',
681+
},
682+
],
683+
/**
684+
* Fallback rewrites run only after every page and dynamic route has failed to match, so
685+
* this answers a Markdown client's 404 in Markdown without shadowing any real route.
686+
* `text/markdown` must be one of the listed media ranges, and `q=0` (RFC 9110 §12.4.2:
687+
* not acceptable) keeps the HTML page. Next anchors both patterns to the whole header.
688+
*/
689+
fallback: [
690+
{
691+
source: '/:path*',
692+
has: [{ type: 'header', key: 'accept', value: '(.*,)?\\s*text/markdown\\s*([;,].*)?' }],
693+
missing: [
694+
{
695+
type: 'header',
696+
key: 'accept',
697+
value:
698+
'(.*,)?\\s*text/markdown(\\s*;[^,]*?)?\\s*;\\s*q\\s*=\\s*0(\\.0{0,3})?\\s*(,.*)?',
699+
},
700+
],
701+
destination: '/markdown-not-found',
702+
},
703+
],
704+
}
681705
},
682706
}
683707

‎apps/sim/scripts/test-mcp-host-e2e.ts‎

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { getErrorMessage } from '@sim/utils/errors'
66
import { SIM_DOCS_URL } from '@sim/utils/site'
77

88
/**
9-
* Exercises the public ownership challenge, host isolation, the RFC 9727 API catalog, and the
10-
* app-wide JSON 404 through a running local app.
9+
* Exercises the public ownership challenge, host isolation, the RFC 9727 API catalog, the
10+
* app-wide JSON 404, and the Markdown 404 for `Accept: text/markdown` through a running local app.
1111
*/
1212
const logger = createLogger('McpHostE2E')
1313
const CHALLENGE_PATH = '/.well-known/openai-apps-challenge'
@@ -17,6 +17,8 @@ const PROTECTED_RESOURCE_PATH = '/.well-known/oauth-protected-resource'
1717
const API_CATALOG_PATH = '/.well-known/api-catalog'
1818
const API_CATALOG_CONTENT_TYPE =
1919
'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"'
20+
const MARKDOWN_ACCEPT = 'text/markdown'
21+
const BROWSER_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
2022
const startedAt = new Date().toISOString()
2123

2224
const configuredBaseUrl = process.env.MCP_HOST_E2E_BASE_URL
@@ -43,18 +45,25 @@ const requests: {
4345
path: string
4446
userAgent: string
4547
host: string
48+
accept: string
4649
status: number
4750
}[] = []
4851

49-
async function request(path: string, userAgent: string, host = MCP_HOST, method = 'GET') {
52+
async function request(
53+
path: string,
54+
userAgent: string,
55+
host = MCP_HOST,
56+
method = 'GET',
57+
accept = '*/*'
58+
) {
5059
// boundary-raw-fetch: exercise the real proxy and route over local HTTP.
5160
const response = await fetch(new URL(path, baseUrl), {
5261
method,
53-
headers: { Host: host, 'User-Agent': userAgent },
62+
headers: { Host: host, 'User-Agent': userAgent, Accept: accept },
5463
redirect: 'error',
5564
signal: AbortSignal.timeout(120_000),
5665
})
57-
requests.push({ method, path, userAgent, host, status: response.status })
66+
requests.push({ method, path, userAgent, host, accept, status: response.status })
5867
const body = await response.text()
5968
return { response, body }
6069
}
@@ -122,7 +131,11 @@ try {
122131
response.headers.get('link'),
123132
`<${baseUrl.origin}${API_CATALOG_PATH}>; rel="api-catalog"`
124133
)
125-
const [api] = JSON.parse(body).linkset
134+
const [catalog, api] = JSON.parse(body).linkset
135+
assert.deepEqual(catalog, {
136+
anchor: `${baseUrl.origin}${API_CATALOG_PATH}`,
137+
item: [{ href: `${baseUrl.origin}/api/v2` }],
138+
})
126139
assert.equal(api.anchor, `${baseUrl.origin}/api/v2`)
127140
assert.equal(api['service-desc'][0].href, `${SIM_DOCS_URL}/openapi.json`)
128141
assert.equal(api.status[0].href, `${baseUrl.origin}/api/health`)
@@ -138,6 +151,77 @@ try {
138151
assert.match(response.headers.get('link') ?? '', /rel="api-catalog"$/)
139152
})
140153

154+
for (const path of ['/not-a-real-page', '/not-a-real/nested/page']) {
155+
await check(`unknown page ${path} answers a Markdown client in Markdown`, async () => {
156+
const { response, body } = await request(
157+
path,
158+
'python-requests/2.32.3',
159+
baseUrl.host,
160+
'GET',
161+
MARKDOWN_ACCEPT
162+
)
163+
assert.equal(response.status, 404)
164+
assert.equal(response.headers.get('content-type'), 'text/markdown; charset=utf-8')
165+
assert.match(response.headers.get('vary') ?? '', /\bAccept\b/)
166+
assert.match(response.headers.get('cache-control') ?? '', /\bno-store\b/)
167+
assert.match(body, /^# Page not found\n/)
168+
assert.match(body, /\/llms\.txt\)/)
169+
})
170+
}
171+
172+
await check('an unknown page answers a Markdown POST in Markdown, not 405', async () => {
173+
const { response } = await request(
174+
'/not-a-real-page',
175+
'python-requests/2.32.3',
176+
baseUrl.host,
177+
'POST',
178+
MARKDOWN_ACCEPT
179+
)
180+
assert.equal(response.status, 404)
181+
assert.equal(response.headers.get('content-type'), 'text/markdown; charset=utf-8')
182+
})
183+
184+
await check('a client that refuses Markdown with q=0 gets the HTML 404', async () => {
185+
const { response } = await request(
186+
'/not-a-real-page',
187+
'python-requests/2.32.3',
188+
baseUrl.host,
189+
'GET',
190+
'text/html, text/markdown;q=0'
191+
)
192+
assert.equal(response.status, 404)
193+
assert.match(response.headers.get('content-type') ?? '', /^text\/html/)
194+
})
195+
196+
await check('unknown page still answers a browser with the HTML 404', async () => {
197+
const { response } = await request(
198+
'/not-a-real-page',
199+
'Mozilla/5.0',
200+
baseUrl.host,
201+
'GET',
202+
BROWSER_ACCEPT
203+
)
204+
assert.equal(response.status, 404)
205+
assert.match(response.headers.get('content-type') ?? '', /^text\/html/)
206+
})
207+
208+
await check('a real page is not shadowed by the Markdown 404', async () => {
209+
const { response } = await request('/', 'Mozilla/5.0', baseUrl.host, 'GET', MARKDOWN_ACCEPT)
210+
assert.equal(response.status, 200)
211+
})
212+
213+
await check('an unknown API path keeps its JSON 404 for a Markdown client', async () => {
214+
const { response, body } = await request(
215+
'/api/not-a-real-route',
216+
'Mozilla/5.0',
217+
baseUrl.host,
218+
'GET',
219+
MARKDOWN_ACCEPT
220+
)
221+
assert.equal(response.status, 404)
222+
assert.deepEqual(JSON.parse(body), { error: 'Not found' })
223+
})
224+
141225
await check('application host still serves health checks', async () => {
142226
const { response } = await request('/api/health', 'Mozilla/5.0', baseUrl.host)
143227
assert.equal(response.status, 200)

0 commit comments

Comments
 (0)