Skip to content

Commit 21c4f73

Browse files
authored
improvement(api): publish an RFC 9727 API catalog and stop padding tables in docs markdown (#9066)
* improvement(api): publish an RFC 9727 API catalog and compact indented tables in docs markdown * fix(docs): turn off table padding in the markdown serializer and assert exact catalog URLs
1 parent 2e98120 commit 21c4f73

5 files changed

Lines changed: 98 additions & 34 deletions

File tree

‎apps/docs/lib/llms.ts‎

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,6 @@ 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 = /^\|.*\|$/
8-
const TABLE_DELIMITER_CELL = /^\s*(:?)-+(:?)\s*$/
9-
const UNESCAPED_PIPE = /(?<!\\)\|/
10-
11-
/**
12-
* The processed-markdown serializer pads every table cell to its column's widest cell, so one long
13-
* enum (a parameter listing every timezone) pads every row of its table and roughly doubles the
14-
* 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-
*/
17-
function compactTables(markdown: string): string {
18-
let inFence = false
19-
return markdown
20-
.split('\n')
21-
.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)
25-
if (cells.every((cell) => TABLE_DELIMITER_CELL.test(cell))) {
26-
return `|${cells.map((cell) => cell.replace(TABLE_DELIMITER_CELL, ' $1---$2 ')).join('|')}|`
27-
}
28-
return `|${cells.map((cell) => ` ${cell.replace(/^ /, '').trimEnd()} `).join('|')}|`
29-
})
30-
.join('\n')
31-
}
32-
337
type LLMPageData = PageData & { getAPIPageProps?: () => ApiPageProps }
348

359
/**
@@ -62,5 +36,5 @@ export async function getLLMText(page: InferPageType<typeof source>) {
6236
: ''
6337
return `${heading}
6438
65-
${landingLink}${compactTables(processed)}`
39+
${landingLink}${processed}`
6640
}

‎apps/docs/source.config.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { execFileSync } from 'node:child_process'
22
import path from 'node:path'
3+
import { remarkGfm } from 'fumadocs-core/mdx-plugins'
34
import { defineConfig, defineDocs, frontmatterSchema } from 'fumadocs-mdx/config'
45
import lastModified from 'fumadocs-mdx/plugins/last-modified'
56
import { curlJsonBodyGrammar } from './lib/shiki-curl-json'
@@ -66,6 +67,22 @@ export default defineConfig({
6667
lastModified({ versionControl: async (file) => gitLastModified?.get(path.resolve(file)) }),
6768
],
6869
mdxOptions: {
70+
/**
71+
* The processed markdown behind `.md` pages, `llms-full.txt`, and Copy page is serialized by
72+
* remark-gfm, which by default pads every table cell to its column's widest cell — one long
73+
* enum pads a whole table. `tablePipeAlign: false` keeps the table and drops the padding; it
74+
* only affects serializing to markdown, never the rendered page.
75+
*/
76+
remarkPlugins: (plugins) => {
77+
if (!plugins.includes(remarkGfm)) {
78+
throw new Error(
79+
'The fumadocs-mdx preset no longer registers remarkGfm; reapply tablePipeAlign'
80+
)
81+
}
82+
return plugins.map((plugin) =>
83+
plugin === remarkGfm ? [remarkGfm, { tablePipeAlign: false }] : plugin
84+
)
85+
},
6986
/**
7087
* Shiki defaults to `github-light` / `github-dark`, whose blues and purples appear nowhere
7188
* in the product. These themes carry the platform's own token colors instead — see
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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 one published contract; its `servers` names Sim
15+
* Cloud, so a client of another deployment uses the anchor as its base URL, as the API docs say.
16+
* `Link: rel="api-catalog"` makes a HEAD request discoverable too (RFC 9727 §2).
17+
*/
18+
export const GET = withRouteHandler(() => {
19+
const baseUrl = getBaseUrl()
20+
const catalogUrl = `${baseUrl}/.well-known/api-catalog`
21+
22+
return NextResponse.json(
23+
{
24+
linkset: [
25+
{
26+
anchor: `${baseUrl}/api/v2`,
27+
'service-desc': [
28+
{ href: `${DOCS_URL}/openapi.json`, type: 'application/vnd.oai.openapi+json' },
29+
],
30+
'service-doc': [{ href: `${DOCS_URL}/api-reference/getting-started`, type: 'text/html' }],
31+
status: [{ href: `${baseUrl}/api/health`, type: 'application/json' }],
32+
},
33+
],
34+
},
35+
{
36+
headers: {
37+
'Content-Type': API_CATALOG_CONTENT_TYPE,
38+
'Cache-Control': 'public, max-age=86400',
39+
Link: `<${catalogUrl}>; rel="api-catalog"`,
40+
},
41+
}
42+
)
43+
})

‎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: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@ import { mkdir, writeFile } from 'node:fs/promises'
33
import { dirname } from 'node:path'
44
import { createLogger } from '@sim/logger'
55
import { getErrorMessage } from '@sim/utils/errors'
6+
import { SIM_DOCS_URL } from '@sim/utils/site'
67

78
/**
8-
* Exercises the public ownership challenge, host isolation, and the app-wide JSON 404 through a
9-
* running local app.
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.
1011
*/
1112
const logger = createLogger('McpHostE2E')
1213
const CHALLENGE_PATH = '/.well-known/openai-apps-challenge'
1314
const EXPECTED_CHALLENGE = 'lFJ1-XIWpHGRNcgzTPl2Y_yYCTWLvlIbAvNRD08EvLI'
1415
const MCP_HOST = 'mcp.sim.test'
1516
const PROTECTED_RESOURCE_PATH = '/.well-known/oauth-protected-resource'
17+
const API_CATALOG_PATH = '/.well-known/api-catalog'
18+
const API_CATALOG_CONTENT_TYPE =
19+
'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"'
1620
const startedAt = new Date().toISOString()
1721

1822
const configuredBaseUrl = process.env.MCP_HOST_E2E_BASE_URL
@@ -83,7 +87,7 @@ try {
8387
})
8488
}
8589

86-
for (const path of [`${CHALLENGE_PATH}/extra`, '/login', '/api/health']) {
90+
for (const path of [`${CHALLENGE_PATH}/extra`, '/login', '/api/health', API_CATALOG_PATH]) {
8791
await check(`dedicated MCP host rejects ${path}`, async () => {
8892
const { response } = await request(path, 'Mozilla/5.0')
8993
assert.equal(response.status, 404)
@@ -109,6 +113,31 @@ try {
109113
})
110114
}
111115

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

0 commit comments

Comments
 (0)