diff --git a/.changeset/mcp-skill-endpoint.md b/.changeset/mcp-skill-endpoint.md new file mode 100644 index 0000000000..54ea3e8a41 --- /dev/null +++ b/.changeset/mcp-skill-endpoint.md @@ -0,0 +1,18 @@ +--- +'@objectstack/mcp': minor +'@objectstack/runtime': minor +--- + +feat(mcp): `GET /api/v1/mcp/skill` — download the environment-customized Agent Skill + +`renderSkillMarkdown()` was export-only; nothing served it over HTTP, so the +"one generic skill" distributable (ADR-0036 Amendment C) had no self-serve +outlet. The runtime dispatcher now serves it at `GET /api/v1/mcp/skill` as +`text/markdown` — public like `/discovery` (generic agent instructions plus a +URL the caller already knows; no schema, no tenant data), gated on the same +default-on MCP switch (404 when opted out), 501 when the MCP plugin isn't +loaded. The environment URL comes from the auth service's canonical +`getMcpResourceUrl()` with a request-host fallback. `MCPServerRuntime` gains +`renderSkill()` so hosts reach the renderer via the registered `'mcp'` +service without a package dependency. Feeds the Setup "Connect an agent" +page (objectui#2363) and the distribution shells (#2714). diff --git a/packages/mcp/src/mcp-server-runtime.ts b/packages/mcp/src/mcp-server-runtime.ts index 4964ccbb0b..63227f9f41 100644 --- a/packages/mcp/src/mcp-server-runtime.ts +++ b/packages/mcp/src/mcp-server-runtime.ts @@ -13,6 +13,7 @@ import type { RegisterObjectToolsOptions, RegisterActionToolsOptions, } from './mcp-http-tools.js'; +import { renderSkillMarkdown, type RenderSkillOptions } from './skill.js'; import { z } from 'zod'; /** @@ -503,6 +504,19 @@ export class MCPServerRuntime { this.config.logger?.info('[MCP] Server stopped'); } + /** + * Render the portable Agent Skill (`SKILL.md`) for this environment + * (ADR-0036 Amendment C: ONE generic skill, schema discovered live). + * + * Exposed on the runtime so HTTP hosts can serve it (`GET /api/v1/mcp/skill` + * in the runtime dispatcher) without depending on `@objectstack/mcp` — + * they duck-call this through the registered `'mcp'` service, mirroring + * how `handleHttpRequest` is reached. + */ + renderSkill(options?: RenderSkillOptions): string { + return renderSkillMarkdown(options); + } + // ── HTTP (Streamable HTTP) transport ─────────────────────────── /** diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index ce9a80306f..28a16ea6de 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -591,6 +591,18 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu mountMcp('GET'); mountMcp('DELETE'); + // Public SKILL.md download (env-customized portable Agent Skill). + // Separate registration: `/mcp` above is an exact-path mount, so + // the sub-path needs its own route to be reachable over HTTP. + server.get(`${prefix}/mcp/skill`, async (req: any, res: any) => { + try { + const result = await dispatcher.dispatch('GET', '/mcp/skill', req.body, req.query, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + server.post(`${prefix}/keys`, async (req: any, res: any) => { try { const result = await dispatcher.dispatch('POST', '/keys', req.body, req.query, { request: req }); diff --git a/packages/runtime/src/http-dispatcher.mcp.test.ts b/packages/runtime/src/http-dispatcher.mcp.test.ts index e8f5d77596..2095d7c4a6 100644 --- a/packages/runtime/src/http-dispatcher.mcp.test.ts +++ b/packages/runtime/src/http-dispatcher.mcp.test.ts @@ -48,6 +48,7 @@ function makeKernel(opts: { withMcp?: boolean; recordedContexts?: any[] } = {}) const mcpService: any = { lastOpts: undefined, lastReq: undefined, + renderSkill: (o: any) => `---\nname: objectstack\n---\n\n# ObjectStack\n\nMCP: ${o?.mcpUrl ?? ''}\n`, handleHttpRequest: async (_req: Request, o: any) => { mcpService.lastOpts = o; mcpService.lastReq = _req; @@ -171,3 +172,86 @@ describe('HttpDispatcher.handleMcp', () => { }); }); }); + +describe('HttpDispatcher.handleMcpSkill (GET /mcp/skill)', () => { + const prev = process.env.OS_MCP_SERVER_ENABLED; + afterEach(() => { + if (prev === undefined) delete process.env.OS_MCP_SERVER_ENABLED; + else process.env.OS_MCP_SERVER_ENABLED = prev; + }); + + const ctx = (overrides: any = {}) => + makeContext({ + request: new Request('http://acme.example.com/api/v1/mcp/skill', { + method: 'GET', + headers: { host: 'acme.example.com', 'x-forwarded-proto': 'https' }, + }), + // Anonymous on purpose: the skill is public like /discovery. + executionContext: undefined, + ...overrides, + }); + + /** Drain the single-chunk markdown "stream" the endpoint returns. */ + async function drainSkill(res: any): Promise<{ status: number; headers: any; text: string }> { + const r = res.result; + expect(r?.type).toBe('stream'); + let text = ''; + for await (const chunk of r.events) text += chunk; + return { status: r.status, headers: r.headers, text }; + } + + it('serves the env-customized SKILL.md as text/markdown, anonymously', async () => { + delete process.env.OS_MCP_SERVER_ENABLED; + const { kernel } = makeKernel({ withMcp: true }); + const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); + const { status, headers, text } = await drainSkill(await d.handleMcpSkill('GET', ctx())); + expect(status).toBe(200); + expect(headers?.['content-type']).toContain('text/markdown'); + expect(headers?.['cache-control']).toBe('no-store'); + // No auth service in the fake kernel → URL derived from the request host. + expect(text).toContain('https://acme.example.com/api/v1/mcp'); + }); + + it('404s when the MCP surface is opted out (nothing advertised)', async () => { + process.env.OS_MCP_SERVER_ENABLED = 'false'; + const { kernel } = makeKernel({ withMcp: true }); + const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); + const res = await d.handleMcpSkill('GET', ctx()); + expect(res.response.status).toBe(404); + }); + + it('501s when the MCP service is not loaded', async () => { + delete process.env.OS_MCP_SERVER_ENABLED; + const { kernel } = makeKernel({ withMcp: false }); + const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); + const res = await d.handleMcpSkill('GET', ctx()); + expect(res.response.status).toBe(501); + }); + + it('405s non-GET with an Allow header', async () => { + delete process.env.OS_MCP_SERVER_ENABLED; + const { kernel } = makeKernel({ withMcp: true }); + const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); + const res = await d.handleMcpSkill('POST', ctx()); + expect(res.response.status).toBe(405); + expect(res.response.headers?.Allow).toBe('GET'); + }); + + it('prefers the auth service canonical URL over host derivation', async () => { + delete process.env.OS_MCP_SERVER_ENABLED; + const { kernel } = makeKernel({ withMcp: true }); + const services = (kernel as any).__services ?? null; + // makeKernel exposes getService via closure; extend by wrapping. + const origGet = kernel.getServiceAsync; + (kernel as any).getServiceAsync = async (n: string) => + n === 'auth' + ? { getMcpResourceUrl: () => 'https://canonical.example.com/api/v1/mcp' } + : origGet(n); + const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); + const { status, text } = await drainSkill(await d.handleMcpSkill('GET', ctx())); + expect(status).toBe(200); + expect(text).toContain('https://canonical.example.com/api/v1/mcp'); + expect(text).not.toContain('acme.example.com'); + void services; + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index b74c39b628..30b96303bf 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -543,6 +543,84 @@ export class HttpDispatcher { } } + /** + * `GET /mcp/skill` — the environment-customized portable Agent Skill + * (`SKILL.md`), rendered by the MCP service (ADR-0036 Amendment C: ONE + * generic skill; only the connection URL is environment-specific). + * + * Served PUBLIC like `/discovery`: the content is generic agent + * instructions plus a URL the caller already knows — no schema, no + * tenant data. Gated on the same default-on switch as the `/mcp` route + * (404 when opted out, so the surface isn't advertised) and 501 when the + * MCP plugin isn't loaded, mirroring `handleMcp`. + */ + async handleMcpSkill(method: string, context: HttpProtocolContext): Promise { + if (!HttpDispatcher.isMcpEnabled()) { + return { handled: true, response: this.error('MCP server is not enabled for this environment', 404) }; + } + if (method !== 'GET') { + return { + handled: true, + response: { + status: 405, + headers: { Allow: 'GET' }, + body: { success: false, error: { message: 'Method not allowed — use GET', code: 405 } }, + }, + }; + } + + const mcp: any = await this.resolveService('mcp', context.environmentId); + if (!mcp || typeof mcp.renderSkill !== 'function') { + return { handled: true, response: this.error('MCP server is not available', 501) }; + } + + // Resolve this environment's MCP URL for the skill's Connect section: + // the auth service owns the canonical value (base URL config); fall + // back to deriving from the request host so the endpoint still works + // when the auth plugin isn't loaded. + let mcpUrl: string | undefined; + try { + const authService: any = await this.resolveService('auth', context.environmentId); + const url = authService?.getMcpResourceUrl?.(); + if (typeof url === 'string' && url) mcpUrl = url; + } catch { /* fall through to host derivation */ } + if (!mcpUrl) { + try { + const webReq = this.toMcpWebRequest(context.request, undefined); + const host = webReq?.headers.get('host'); + if (host) { + const proto = webReq?.headers.get('x-forwarded-proto') || 'http'; + mcpUrl = `${proto}://${host}/api/v1/mcp`; + } + } catch { /* leave the documented placeholder in place */ } + } + + const markdown: string = mcp.renderSkill({ mcpUrl }); + // Raw text must NOT ride the `response` channel — `sendResult` JSON- + // encodes those bodies unconditionally. The `result` stream channel is + // the one raw pipe through every adapter (string events are written + // verbatim, custom headers honored), so serve the markdown as a + // single-chunk "stream". + return { + handled: true, + result: { + type: 'stream', + status: 200, + contentType: 'text/markdown; charset=utf-8', + headers: { + 'content-type': 'text/markdown; charset=utf-8', + 'content-disposition': 'inline; filename="SKILL.md"', + // Same reasoning as /discovery (cloud#152): reflects mutable + // runtime config (base URL), must never be edge-cached stale. + 'cache-control': 'no-store', + }, + events: (async function* () { + yield markdown; + })(), + }, + } as any; + } + /** * Normalise the inbound request into a Web-standard `Request` for the MCP * transport. Accepts an already-Web `Request`, or a node/Hono-style req @@ -3865,6 +3943,14 @@ export class HttpDispatcher { return this.handleData(cleanPath.substring(5), method, body, query, context); } + // `/mcp/skill` is the one sub-path NOT owned by the MCP transport: + // the public, environment-customized SKILL.md download. Matched + // before the transport branch below, which claims everything else + // under `/mcp`. + if (cleanPath === '/mcp/skill' || cleanPath.startsWith('/mcp/skill?')) { + return this.handleMcpSkill(method, context); + } + if (cleanPath === '/mcp' || cleanPath.startsWith('/mcp/') || cleanPath.startsWith('/mcp?')) { return this.handleMcp(body, context); }