Skip to content

Commit 2423e96

Browse files
committed
improvement(api): publish an RFC 9727 API catalog and compact indented tables in docs markdown
1 parent 02cd2f6 commit 2423e96

4 files changed

Lines changed: 89 additions & 14 deletions

File tree

‎apps/docs/lib/llms.ts‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,32 @@ import { getIntegrationLandingPage, INTEGRATION_LANDING_LABEL } from '@/lib/inte
44
import { getApiSpecContent } from '@/lib/openapi'
55
import type { PageData, source } from '@/lib/source'
66

7-
const TABLE_ROW = /^\|.*\|$/
7+
/** A table row, captured as its indentation (tables nest under list items) and its cells. */
8+
const TABLE_ROW = /^(\s*)\|(.*)\|$/
89
const TABLE_DELIMITER_CELL = /^\s*(:?)-+(:?)\s*$/
910
const UNESCAPED_PIPE = /(?<!\\)\|/
1011

1112
/**
1213
* The processed-markdown serializer pads every table cell to its column's widest cell, so one long
1314
* enum (a parameter listing every timezone) pads every row of its table and roughly doubles the
1415
* integration reference. Drop that trailing padding; a cell's own leading indentation (the `↳`
15-
* nesting in output tables) and inline code are kept, and the tables stay valid GFM.
16+
* nesting in output tables) and inline code are kept, and the tables stay valid GFM. Tables and
17+
* fences nested under a list item are indented, so both are matched after their indentation.
1618
*/
1719
function compactTables(markdown: string): string {
1820
let inFence = false
1921
return markdown
2022
.split('\n')
2123
.map((line) => {
22-
if (line.startsWith('```')) inFence = !inFence
23-
if (inFence || !TABLE_ROW.test(line)) return line
24-
const cells = line.slice(1, -1).split(UNESCAPED_PIPE)
24+
if (line.trimStart().startsWith('```')) inFence = !inFence
25+
const row = inFence ? null : TABLE_ROW.exec(line)
26+
if (!row) return line
27+
const [, indent, inner] = row
28+
const cells = inner.split(UNESCAPED_PIPE)
2529
if (cells.every((cell) => TABLE_DELIMITER_CELL.test(cell))) {
26-
return `|${cells.map((cell) => cell.replace(TABLE_DELIMITER_CELL, ' $1---$2 ')).join('|')}|`
30+
return `${indent}|${cells.map((cell) => cell.replace(TABLE_DELIMITER_CELL, ' $1---$2 ')).join('|')}|`
2731
}
28-
return `|${cells.map((cell) => ` ${cell.replace(/^ /, '').trimEnd()} `).join('|')}|`
32+
return `${indent}|${cells.map((cell) => ` ${cell.replace(/^ /, '').trimEnd()} `).join('|')}|`
2933
})
3034
.join('\n')
3135
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { NextResponse } from 'next/server'
2+
import { getBaseUrl } from '@/lib/core/utils/urls'
3+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
4+
import { DOCS_URL } from '@/lib/help-links'
5+
6+
/** RFC 9727 §4.2: the catalog is a Linkset carrying the RFC's profile. */
7+
const API_CATALOG_CONTENT_TYPE =
8+
'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"'
9+
10+
/**
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
14+
* describes itself; the OpenAPI document is the published contract every deployment serves.
15+
* `Link: rel="api-catalog"` makes a HEAD request discoverable too (RFC 9727 §2).
16+
*/
17+
export const GET = withRouteHandler(() => {
18+
const baseUrl = getBaseUrl()
19+
const catalogUrl = `${baseUrl}/.well-known/api-catalog`
20+
21+
return NextResponse.json(
22+
{
23+
linkset: [
24+
{
25+
anchor: `${baseUrl}/api/v2`,
26+
'service-desc': [
27+
{ href: `${DOCS_URL}/openapi.json`, type: 'application/vnd.oai.openapi+json' },
28+
],
29+
'service-doc': [{ href: `${DOCS_URL}/api-reference/getting-started`, type: 'text/html' }],
30+
status: [{ href: `${baseUrl}/api/health`, type: 'application/json' }],
31+
},
32+
],
33+
},
34+
{
35+
headers: {
36+
'Content-Type': API_CATALOG_CONTENT_TYPE,
37+
'Cache-Control': 'public, max-age=86400',
38+
Link: `<${catalogUrl}>; rel="api-catalog"`,
39+
},
40+
}
41+
)
42+
})

‎apps/sim/proxy.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -295,15 +295,16 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null {
295295
pathname.startsWith('/api/webhooks/tiktok') ||
296296
pathname.startsWith('/api/webhooks/agentmail')
297297
const isMcpEndpoint = pathname.startsWith('/api/mcp/')
298-
const isMcpDiscoveryEndpoint =
298+
const isDiscoveryEndpoint =
299299
pathname.startsWith('/.well-known/oauth-authorization-server') ||
300300
pathname.startsWith('/.well-known/oauth-protected-resource') ||
301-
pathname === '/.well-known/openai-apps-challenge'
301+
pathname === '/.well-known/openai-apps-challenge' ||
302+
pathname === '/.well-known/api-catalog'
302303
const isSuspicious = SUSPICIOUS_UA_PATTERNS.some((pattern) => pattern.test(userAgent))
303304

304305
// Block suspicious requests, but exempt machine-to-machine endpoints that may
305-
// legitimately omit User-Agent headers (webhooks and MCP protocol discovery/calls).
306-
if (isSuspicious && !isWebhookEndpoint && !isMcpEndpoint && !isMcpDiscoveryEndpoint) {
306+
// legitimately omit User-Agent headers (webhooks, MCP calls, and protocol discovery).
307+
if (isSuspicious && !isWebhookEndpoint && !isMcpEndpoint && !isDiscoveryEndpoint) {
307308
// Scanner traffic (empty or tool user agents probing /admin.php etc.); the 403 is the response.
308309
logger.debug('Blocked suspicious request', {
309310
userAgent,

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

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ import { createLogger } from '@sim/logger'
55
import { getErrorMessage } from '@sim/utils/errors'
66

77
/**
8-
* Exercises the public ownership challenge, host isolation, and the app-wide JSON 404 through a
9-
* running local app.
8+
* Exercises the public ownership challenge, host isolation, the RFC 9727 API catalog, and the
9+
* app-wide JSON 404 through a running local app.
1010
*/
1111
const logger = createLogger('McpHostE2E')
1212
const CHALLENGE_PATH = '/.well-known/openai-apps-challenge'
1313
const EXPECTED_CHALLENGE = 'lFJ1-XIWpHGRNcgzTPl2Y_yYCTWLvlIbAvNRD08EvLI'
1414
const MCP_HOST = 'mcp.sim.test'
1515
const PROTECTED_RESOURCE_PATH = '/.well-known/oauth-protected-resource'
16+
const API_CATALOG_PATH = '/.well-known/api-catalog'
17+
const API_CATALOG_CONTENT_TYPE =
18+
'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"'
1619
const startedAt = new Date().toISOString()
1720

1821
const configuredBaseUrl = process.env.MCP_HOST_E2E_BASE_URL
@@ -83,7 +86,7 @@ try {
8386
})
8487
}
8588

86-
for (const path of [`${CHALLENGE_PATH}/extra`, '/login', '/api/health']) {
89+
for (const path of [`${CHALLENGE_PATH}/extra`, '/login', '/api/health', API_CATALOG_PATH]) {
8790
await check(`dedicated MCP host rejects ${path}`, async () => {
8891
const { response } = await request(path, 'Mozilla/5.0')
8992
assert.equal(response.status, 404)
@@ -109,6 +112,31 @@ try {
109112
})
110113
}
111114

115+
for (const userAgent of ['', 'python-requests/2.32.3']) {
116+
await check(`API catalog for ${userAgent || 'an empty User-Agent'}`, async () => {
117+
const { response, body } = await request(API_CATALOG_PATH, userAgent, baseUrl.host)
118+
assert.equal(response.status, 200)
119+
assert.equal(response.headers.get('content-type'), API_CATALOG_CONTENT_TYPE)
120+
assert.match(
121+
response.headers.get('link') ?? '',
122+
/\/\.well-known\/api-catalog>; rel="api-catalog"$/
123+
)
124+
const [api] = JSON.parse(body).linkset
125+
assert.equal(new URL(api.anchor).pathname, '/api/v2')
126+
assert.equal(new URL(api['service-desc'][0].href).pathname, '/openapi.json')
127+
const status = new URL(api.status[0].href)
128+
const health = await request(status.pathname, 'Mozilla/5.0', baseUrl.host)
129+
assert.equal(health.response.status, 200, 'The catalog status link must resolve')
130+
})
131+
}
132+
133+
await check('API catalog answers HEAD with its Link header (RFC 9727 §2)', async () => {
134+
const { response, body } = await request(API_CATALOG_PATH, '', baseUrl.host, 'HEAD')
135+
assert.equal(response.status, 200)
136+
assert.equal(body, '')
137+
assert.match(response.headers.get('link') ?? '', /rel="api-catalog"$/)
138+
})
139+
112140
await check('application host still serves health checks', async () => {
113141
const { response } = await request('/api/health', 'Mozilla/5.0', baseUrl.host)
114142
assert.equal(response.status, 200)

0 commit comments

Comments
 (0)