@@ -3,16 +3,20 @@ import { mkdir, writeFile } from 'node:fs/promises'
33import { dirname } from 'node:path'
44import { createLogger } from '@sim/logger'
55import { 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 */
1112const logger = createLogger ( 'McpHostE2E' )
1213const CHALLENGE_PATH = '/.well-known/openai-apps-challenge'
1314const EXPECTED_CHALLENGE = 'lFJ1-XIWpHGRNcgzTPl2Y_yYCTWLvlIbAvNRD08EvLI'
1415const MCP_HOST = 'mcp.sim.test'
1516const 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"'
1620const startedAt = new Date ( ) . toISOString ( )
1721
1822const configuredBaseUrl = process . env . MCP_HOST_E2E_BASE_URL
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' ) ?? '' , / r e l = " a p i - c a t a l o g " $ / )
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