Skip to content

Commit bc26360

Browse files
os-zhuangclaude
andauthored
feat(mcp): GET /api/v1/mcp/skill — env-customized Agent Skill download (#2714) (#2728)
renderSkillMarkdown had no HTTP outlet. Serve it from the runtime dispatcher as text/markdown via the raw stream channel (the response channel JSON-encodes bodies), public like /discovery, 404 when the MCP surface is opted out, 501 without the mcp service; URL from auth getMcpResourceUrl() with request-host fallback. Live-verified on showcase (raw markdown + headers + URL injection). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e2fa074 commit bc26360

5 files changed

Lines changed: 214 additions & 0 deletions

File tree

.changeset/mcp-skill-endpoint.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/mcp': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
feat(mcp): `GET /api/v1/mcp/skill` — download the environment-customized Agent Skill
7+
8+
`renderSkillMarkdown()` was export-only; nothing served it over HTTP, so the
9+
"one generic skill" distributable (ADR-0036 Amendment C) had no self-serve
10+
outlet. The runtime dispatcher now serves it at `GET /api/v1/mcp/skill` as
11+
`text/markdown` — public like `/discovery` (generic agent instructions plus a
12+
URL the caller already knows; no schema, no tenant data), gated on the same
13+
default-on MCP switch (404 when opted out), 501 when the MCP plugin isn't
14+
loaded. The environment URL comes from the auth service's canonical
15+
`getMcpResourceUrl()` with a request-host fallback. `MCPServerRuntime` gains
16+
`renderSkill()` so hosts reach the renderer via the registered `'mcp'`
17+
service without a package dependency. Feeds the Setup "Connect an agent"
18+
page (objectui#2363) and the distribution shells (#2714).

packages/mcp/src/mcp-server-runtime.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
RegisterObjectToolsOptions,
1414
RegisterActionToolsOptions,
1515
} from './mcp-http-tools.js';
16+
import { renderSkillMarkdown, type RenderSkillOptions } from './skill.js';
1617
import { z } from 'zod';
1718

1819
/**
@@ -503,6 +504,19 @@ export class MCPServerRuntime {
503504
this.config.logger?.info('[MCP] Server stopped');
504505
}
505506

507+
/**
508+
* Render the portable Agent Skill (`SKILL.md`) for this environment
509+
* (ADR-0036 Amendment C: ONE generic skill, schema discovered live).
510+
*
511+
* Exposed on the runtime so HTTP hosts can serve it (`GET /api/v1/mcp/skill`
512+
* in the runtime dispatcher) without depending on `@objectstack/mcp` —
513+
* they duck-call this through the registered `'mcp'` service, mirroring
514+
* how `handleHttpRequest` is reached.
515+
*/
516+
renderSkill(options?: RenderSkillOptions): string {
517+
return renderSkillMarkdown(options);
518+
}
519+
506520
// ── HTTP (Streamable HTTP) transport ───────────────────────────
507521

508522
/**

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,18 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
591591
mountMcp('GET');
592592
mountMcp('DELETE');
593593

594+
// Public SKILL.md download (env-customized portable Agent Skill).
595+
// Separate registration: `/mcp` above is an exact-path mount, so
596+
// the sub-path needs its own route to be reachable over HTTP.
597+
server.get(`${prefix}/mcp/skill`, async (req: any, res: any) => {
598+
try {
599+
const result = await dispatcher.dispatch('GET', '/mcp/skill', req.body, req.query, { request: req });
600+
sendResult(result, res);
601+
} catch (err: any) {
602+
errorResponse(err, res);
603+
}
604+
});
605+
594606
server.post(`${prefix}/keys`, async (req: any, res: any) => {
595607
try {
596608
const result = await dispatcher.dispatch('POST', '/keys', req.body, req.query, { request: req });

packages/runtime/src/http-dispatcher.mcp.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

packages/runtime/src/http-dispatcher.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,84 @@ export class HttpDispatcher {
543543
}
544544
}
545545

546+
/**
547+
* `GET /mcp/skill` — the environment-customized portable Agent Skill
548+
* (`SKILL.md`), rendered by the MCP service (ADR-0036 Amendment C: ONE
549+
* generic skill; only the connection URL is environment-specific).
550+
*
551+
* Served PUBLIC like `/discovery`: the content is generic agent
552+
* instructions plus a URL the caller already knows — no schema, no
553+
* tenant data. Gated on the same default-on switch as the `/mcp` route
554+
* (404 when opted out, so the surface isn't advertised) and 501 when the
555+
* MCP plugin isn't loaded, mirroring `handleMcp`.
556+
*/
557+
async handleMcpSkill(method: string, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
558+
if (!HttpDispatcher.isMcpEnabled()) {
559+
return { handled: true, response: this.error('MCP server is not enabled for this environment', 404) };
560+
}
561+
if (method !== 'GET') {
562+
return {
563+
handled: true,
564+
response: {
565+
status: 405,
566+
headers: { Allow: 'GET' },
567+
body: { success: false, error: { message: 'Method not allowed — use GET', code: 405 } },
568+
},
569+
};
570+
}
571+
572+
const mcp: any = await this.resolveService('mcp', context.environmentId);
573+
if (!mcp || typeof mcp.renderSkill !== 'function') {
574+
return { handled: true, response: this.error('MCP server is not available', 501) };
575+
}
576+
577+
// Resolve this environment's MCP URL for the skill's Connect section:
578+
// the auth service owns the canonical value (base URL config); fall
579+
// back to deriving from the request host so the endpoint still works
580+
// when the auth plugin isn't loaded.
581+
let mcpUrl: string | undefined;
582+
try {
583+
const authService: any = await this.resolveService('auth', context.environmentId);
584+
const url = authService?.getMcpResourceUrl?.();
585+
if (typeof url === 'string' && url) mcpUrl = url;
586+
} catch { /* fall through to host derivation */ }
587+
if (!mcpUrl) {
588+
try {
589+
const webReq = this.toMcpWebRequest(context.request, undefined);
590+
const host = webReq?.headers.get('host');
591+
if (host) {
592+
const proto = webReq?.headers.get('x-forwarded-proto') || 'http';
593+
mcpUrl = `${proto}://${host}/api/v1/mcp`;
594+
}
595+
} catch { /* leave the documented placeholder in place */ }
596+
}
597+
598+
const markdown: string = mcp.renderSkill({ mcpUrl });
599+
// Raw text must NOT ride the `response` channel — `sendResult` JSON-
600+
// encodes those bodies unconditionally. The `result` stream channel is
601+
// the one raw pipe through every adapter (string events are written
602+
// verbatim, custom headers honored), so serve the markdown as a
603+
// single-chunk "stream".
604+
return {
605+
handled: true,
606+
result: {
607+
type: 'stream',
608+
status: 200,
609+
contentType: 'text/markdown; charset=utf-8',
610+
headers: {
611+
'content-type': 'text/markdown; charset=utf-8',
612+
'content-disposition': 'inline; filename="SKILL.md"',
613+
// Same reasoning as /discovery (cloud#152): reflects mutable
614+
// runtime config (base URL), must never be edge-cached stale.
615+
'cache-control': 'no-store',
616+
},
617+
events: (async function* () {
618+
yield markdown;
619+
})(),
620+
},
621+
} as any;
622+
}
623+
546624
/**
547625
* Normalise the inbound request into a Web-standard `Request` for the MCP
548626
* transport. Accepts an already-Web `Request`, or a node/Hono-style req
@@ -3865,6 +3943,14 @@ export class HttpDispatcher {
38653943
return this.handleData(cleanPath.substring(5), method, body, query, context);
38663944
}
38673945

3946+
// `/mcp/skill` is the one sub-path NOT owned by the MCP transport:
3947+
// the public, environment-customized SKILL.md download. Matched
3948+
// before the transport branch below, which claims everything else
3949+
// under `/mcp`.
3950+
if (cleanPath === '/mcp/skill' || cleanPath.startsWith('/mcp/skill?')) {
3951+
return this.handleMcpSkill(method, context);
3952+
}
3953+
38683954
if (cleanPath === '/mcp' || cleanPath.startsWith('/mcp/') || cleanPath.startsWith('/mcp?')) {
38693955
return this.handleMcp(body, context);
38703956
}

0 commit comments

Comments
 (0)