@@ -48,6 +48,7 @@ function makeKernel(opts: { withMcp?: boolean; recordedContexts?: any[] } = {})
4848 const mcpService : any = {
4949 lastOpts : undefined ,
5050 lastReq : undefined ,
51+ renderSkill : ( o : any ) => `---\nname: objectstack\n---\n\n# ObjectStack\n\nMCP: ${ o ?. mcpUrl ?? '<YOUR_ENV_MCP_URL>' } \n` ,
5152 handleHttpRequest : async ( _req : Request , o : any ) => {
5253 mcpService . lastOpts = o ;
5354 mcpService . lastReq = _req ;
@@ -171,3 +172,86 @@ describe('HttpDispatcher.handleMcp', () => {
171172 } ) ;
172173 } ) ;
173174} ) ;
175+
176+ describe ( 'HttpDispatcher.handleMcpSkill (GET /mcp/skill)' , ( ) => {
177+ const prev = process . env . OS_MCP_SERVER_ENABLED ;
178+ afterEach ( ( ) => {
179+ if ( prev === undefined ) delete process . env . OS_MCP_SERVER_ENABLED ;
180+ else process . env . OS_MCP_SERVER_ENABLED = prev ;
181+ } ) ;
182+
183+ const ctx = ( overrides : any = { } ) =>
184+ makeContext ( {
185+ request : new Request ( 'http://acme.example.com/api/v1/mcp/skill' , {
186+ method : 'GET' ,
187+ headers : { host : 'acme.example.com' , 'x-forwarded-proto' : 'https' } ,
188+ } ) ,
189+ // Anonymous on purpose: the skill is public like /discovery.
190+ executionContext : undefined ,
191+ ...overrides ,
192+ } ) ;
193+
194+ /** Drain the single-chunk markdown "stream" the endpoint returns. */
195+ async function drainSkill ( res : any ) : Promise < { status : number ; headers : any ; text : string } > {
196+ const r = res . result ;
197+ expect ( r ?. type ) . toBe ( 'stream' ) ;
198+ let text = '' ;
199+ for await ( const chunk of r . events ) text += chunk ;
200+ return { status : r . status , headers : r . headers , text } ;
201+ }
202+
203+ it ( 'serves the env-customized SKILL.md as text/markdown, anonymously' , async ( ) => {
204+ delete process . env . OS_MCP_SERVER_ENABLED ;
205+ const { kernel } = makeKernel ( { withMcp : true } ) ;
206+ const d = new HttpDispatcher ( kernel , undefined , { enforceProjectMembership : false } ) ;
207+ const { status, headers, text } = await drainSkill ( await d . handleMcpSkill ( 'GET' , ctx ( ) ) ) ;
208+ expect ( status ) . toBe ( 200 ) ;
209+ expect ( headers ?. [ 'content-type' ] ) . toContain ( 'text/markdown' ) ;
210+ expect ( headers ?. [ 'cache-control' ] ) . toBe ( 'no-store' ) ;
211+ // No auth service in the fake kernel → URL derived from the request host.
212+ expect ( text ) . toContain ( 'https://acme.example.com/api/v1/mcp' ) ;
213+ } ) ;
214+
215+ it ( '404s when the MCP surface is opted out (nothing advertised)' , async ( ) => {
216+ process . env . OS_MCP_SERVER_ENABLED = 'false' ;
217+ const { kernel } = makeKernel ( { withMcp : true } ) ;
218+ const d = new HttpDispatcher ( kernel , undefined , { enforceProjectMembership : false } ) ;
219+ const res = await d . handleMcpSkill ( 'GET' , ctx ( ) ) ;
220+ expect ( res . response . status ) . toBe ( 404 ) ;
221+ } ) ;
222+
223+ it ( '501s when the MCP service is not loaded' , async ( ) => {
224+ delete process . env . OS_MCP_SERVER_ENABLED ;
225+ const { kernel } = makeKernel ( { withMcp : false } ) ;
226+ const d = new HttpDispatcher ( kernel , undefined , { enforceProjectMembership : false } ) ;
227+ const res = await d . handleMcpSkill ( 'GET' , ctx ( ) ) ;
228+ expect ( res . response . status ) . toBe ( 501 ) ;
229+ } ) ;
230+
231+ it ( '405s non-GET with an Allow header' , async ( ) => {
232+ delete process . env . OS_MCP_SERVER_ENABLED ;
233+ const { kernel } = makeKernel ( { withMcp : true } ) ;
234+ const d = new HttpDispatcher ( kernel , undefined , { enforceProjectMembership : false } ) ;
235+ const res = await d . handleMcpSkill ( 'POST' , ctx ( ) ) ;
236+ expect ( res . response . status ) . toBe ( 405 ) ;
237+ expect ( res . response . headers ?. Allow ) . toBe ( 'GET' ) ;
238+ } ) ;
239+
240+ it ( 'prefers the auth service canonical URL over host derivation' , async ( ) => {
241+ delete process . env . OS_MCP_SERVER_ENABLED ;
242+ const { kernel } = makeKernel ( { withMcp : true } ) ;
243+ const services = ( kernel as any ) . __services ?? null ;
244+ // makeKernel exposes getService via closure; extend by wrapping.
245+ const origGet = kernel . getServiceAsync ;
246+ ( kernel as any ) . getServiceAsync = async ( n : string ) =>
247+ n === 'auth'
248+ ? { getMcpResourceUrl : ( ) => 'https://canonical.example.com/api/v1/mcp' }
249+ : origGet ( n ) ;
250+ const d = new HttpDispatcher ( kernel , undefined , { enforceProjectMembership : false } ) ;
251+ const { status, text } = await drainSkill ( await d . handleMcpSkill ( 'GET' , ctx ( ) ) ) ;
252+ expect ( status ) . toBe ( 200 ) ;
253+ expect ( text ) . toContain ( 'https://canonical.example.com/api/v1/mcp' ) ;
254+ expect ( text ) . not . toContain ( 'acme.example.com' ) ;
255+ void services ;
256+ } ) ;
257+ } ) ;
0 commit comments