Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/mcp-skill-endpoint.md
Original file line number Diff line number Diff line change
@@ -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).
14 changes: 14 additions & 0 deletions packages/mcp/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
RegisterObjectToolsOptions,
RegisterActionToolsOptions,
} from './mcp-http-tools.js';
import { renderSkillMarkdown, type RenderSkillOptions } from './skill.js';
import { z } from 'zod';

/**
Expand Down Expand Up @@ -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 ───────────────────────────

/**
Expand Down
12 changes: 12 additions & 0 deletions packages/runtime/src/dispatcher-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
84 changes: 84 additions & 0 deletions packages/runtime/src/http-dispatcher.mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '<YOUR_ENV_MCP_URL>'}\n`,
handleHttpRequest: async (_req: Request, o: any) => {
mcpService.lastOpts = o;
mcpService.lastReq = _req;
Expand Down Expand Up @@ -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;
});
});
86 changes: 86 additions & 0 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpDispatcherResult> {
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
Expand Down Expand Up @@ -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);
}
Expand Down