Skip to content

Commit 3ce76ec

Browse files
kinlaneclaude
andcommitted
Iteration three: generate a runnable MCP server from the design
New 'MCP server' export emits a complete TypeScript project — static MCP runtime (stdio) + generated per-operation data. Each tool proxies to its real OpenAPI operation (path/query/header/body mapping), resources and prompts are served, and auth is derived from the spec's security scheme. Download the zip, npm install && npm run dev, and it serves. Verified: generated against the live API Evangelist OpenAPI, installed the real @modelcontextprotocol/sdk (1.29), tsc --noEmit clean, and an MCP client drove it end to end — tools/resources/prompts list, prompt render, and live upstream tool calls (get_service_info, search_network) returned real API responses. Also fixed a latent tsc-only Blob typing error in zip.ts (iteration one). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 01b5e83 commit 3ce76ec

5 files changed

Lines changed: 282 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ An OpenAPI tells developers what your API can do. Agents need more. Toolsmith is
1515
- an **MCP server design** — tools, resources, prompts, and sampling in the shapes MCP clients see
1616
- a registry-ready **server.json** ([2025-09-29 schema](https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json)) for the official MCP Registry
1717
- an **Agent Skills bundle** — one [`SKILL.md`](https://agentskills.io/specification) per skill, zipped by directory, with an [agent-skills-discovery](https://github.com/cloudflare/agent-skills-discovery-rfc) v0.2.0 `index.json` ready for `.well-known/agent-skills/`
18+
- a **runnable MCP server** — a complete TypeScript project (official [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk), stdio) where every tool proxies to its real OpenAPI operation with path/query/header/body mapping and auth derived from the spec's security scheme; `npm install && npm run dev` and it serves. The runtime is static; only the operation data is generated, so you can regenerate as the design evolves.
1819
- the **APIs.json properties** that wire the artifacts to your provider listing
1920

2021
## Develop

index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ <h3>Operations</h3>
8282
<button class="out-tab is-active" data-out="openapi">Enriched OpenAPI</button>
8383
<button class="out-tab" data-out="mcp">MCP design</button>
8484
<button class="out-tab" data-out="serverjson">server.json</button>
85+
<button class="out-tab" data-out="scaffold">MCP server</button>
8586
<button class="out-tab" data-out="skills">Agent Skills</button>
8687
<button class="out-tab" data-out="apisjson">APIs.json</button>
8788
</div>

src/engine.ts

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,3 +421,268 @@ export function emitApisJsonProps(doc: any): any {
421421
{ type: 'AgentSkills', url: 'https://example.com/.well-known/agent-skills/index.json', description: `Agent Skills for working with ${title} competently.` },
422422
];
423423
}
424+
425+
// ---- MCP server scaffold (runnable TypeScript codegen) -------------------------
426+
// Turn the design into a complete, runnable stdio MCP server that proxies each tool
427+
// to its real OpenAPI operation. The runtime (src/index.ts) is static; only the
428+
// operation data and auth (src/operations.ts) are generated from the design.
429+
export const MCP_SDK_RANGE = '^1.12.0';
430+
431+
// Derive an applyAuth(headers, query) body + .env keys from the OpenAPI security scheme.
432+
function generateAuth(doc: any): { body: string; env: string[]; note: string } {
433+
const schemes = (doc.components?.securitySchemes) || {};
434+
const first = Object.values(schemes)[0] as any;
435+
const s = first ? deref(doc, first) : null;
436+
if (s?.type === 'http' && /^basic$/i.test(s.scheme || '')) {
437+
return {
438+
body: [
439+
' if (process.env.API_USER && process.env.API_PASS) {',
440+
' const token = Buffer.from(process.env.API_USER + ":" + process.env.API_PASS).toString("base64");',
441+
' headers["authorization"] = "Basic " + token;',
442+
' }',
443+
].join('\n'),
444+
env: ['API_USER=', 'API_PASS='],
445+
note: 'Basic auth: set `API_USER` and `API_PASS`.',
446+
};
447+
}
448+
if (s?.type === 'apiKey' && s.in === 'header') {
449+
return {
450+
body: ` if (process.env.API_KEY) headers[${JSON.stringify(String(s.name || 'x-api-key').toLowerCase())}] = process.env.API_KEY;`,
451+
env: ['API_KEY='],
452+
note: `API key header (\`${s.name}\`): set \`API_KEY\`.`,
453+
};
454+
}
455+
if (s?.type === 'apiKey' && s.in === 'query') {
456+
return {
457+
body: ` if (process.env.API_KEY) query.set(${JSON.stringify(String(s.name || 'api_key'))}, process.env.API_KEY);`,
458+
env: ['API_KEY='],
459+
note: `API key query param (\`${s.name}\`): set \`API_KEY\`.`,
460+
};
461+
}
462+
// bearer, oauth2, openIdConnect, or nothing declared → optional bearer token
463+
const declared = s?.type === 'http' || s?.type === 'oauth2' || s?.type === 'openIdConnect';
464+
return {
465+
body: ' if (process.env.API_TOKEN) headers["authorization"] = "Bearer " + process.env.API_TOKEN;',
466+
env: ['API_TOKEN='],
467+
note: declared
468+
? 'Bearer token: set `API_TOKEN`.'
469+
: 'No security scheme was declared in the OpenAPI — the scaffold falls back to an optional `API_TOKEN` bearer. Edit `applyAuth` in `src/operations.ts` if your API differs.',
470+
};
471+
}
472+
473+
const INDEX_TS = `#!/usr/bin/env node
474+
// Generated by Toolsmith (toolsmith.apicommons.org). This runtime is static — it reads
475+
// the generated operation data in ./operations.ts and serves it over MCP (stdio),
476+
// proxying each tool call to the upstream REST API.
477+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
478+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
479+
import {
480+
CallToolRequestSchema,
481+
ListToolsRequestSchema,
482+
ListResourcesRequestSchema,
483+
ReadResourceRequestSchema,
484+
ListPromptsRequestSchema,
485+
GetPromptRequestSchema,
486+
} from "@modelcontextprotocol/sdk/types.js";
487+
import { SERVER, BASE_URL, TOOLS, RESOURCES, PROMPTS, applyAuth } from "./operations.js";
488+
489+
const server = new Server(
490+
{ name: SERVER.name, version: SERVER.version },
491+
{ capabilities: { tools: {}, resources: {}, prompts: {} } },
492+
);
493+
494+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
495+
tools: TOOLS.map((t) => ({
496+
name: t.name,
497+
title: t.title,
498+
description: t.description,
499+
inputSchema: t.inputSchema,
500+
annotations: t.annotations,
501+
})),
502+
}));
503+
504+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
505+
const tool = TOOLS.find((t) => t.name === req.params.name);
506+
if (!tool) return { content: [{ type: "text", text: "Unknown tool: " + req.params.name }], isError: true };
507+
try {
508+
const text = await callOperation(tool, req.params.arguments || {});
509+
return { content: [{ type: "text", text }] };
510+
} catch (err) {
511+
const msg = err && err.message ? err.message : String(err);
512+
return { content: [{ type: "text", text: "Error calling " + tool.name + ": " + msg }], isError: true };
513+
}
514+
});
515+
516+
async function callOperation(tool, args) {
517+
let path = tool.path;
518+
const query = new URLSearchParams();
519+
const headers = { accept: "application/json" };
520+
let body;
521+
for (const key of Object.keys(args)) {
522+
const value = args[key];
523+
if (value === undefined || value === null) continue;
524+
if (key === "body") { body = JSON.stringify(value); headers["content-type"] = "application/json"; continue; }
525+
const param = tool.params.find((p) => p.name === key);
526+
const loc = param ? param.in : "query";
527+
if (loc === "path") path = path.split("{" + key + "}").join(encodeURIComponent(String(value)));
528+
else if (loc === "header") headers[key] = String(value);
529+
else query.set(key, String(value));
530+
}
531+
applyAuth(headers, query);
532+
const qs = query.toString();
533+
const url = BASE_URL.replace(/\\/+$/, "") + path + (qs ? "?" + qs : "");
534+
const resp = await fetch(url, { method: tool.method, headers, body });
535+
const text = await resp.text();
536+
if (!resp.ok) throw new Error("HTTP " + resp.status + " from " + url + ": " + text.slice(0, 500));
537+
return text;
538+
}
539+
540+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
541+
resources: RESOURCES.map((r) => ({ uri: r.uri, name: r.name, description: r.description, mimeType: r.mimeType })),
542+
}));
543+
544+
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
545+
const r = RESOURCES.find((x) => x.uri === req.params.uri);
546+
if (!r) throw new Error("Unknown resource: " + req.params.uri);
547+
if (/^https?:/.test(r.uri)) {
548+
const resp = await fetch(r.uri, { headers: { accept: r.mimeType || "text/plain" } });
549+
const text = await resp.text();
550+
return { contents: [{ uri: r.uri, mimeType: r.mimeType || "text/plain", text }] };
551+
}
552+
return { contents: [{ uri: r.uri, mimeType: r.mimeType || "text/markdown", text: r.description || "" }] };
553+
});
554+
555+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
556+
prompts: PROMPTS.map((p) => ({ name: p.name, description: p.description, arguments: p.arguments })),
557+
}));
558+
559+
server.setRequestHandler(GetPromptRequestSchema, async (req) => {
560+
const p = PROMPTS.find((x) => x.name === req.params.name);
561+
if (!p) throw new Error("Unknown prompt: " + req.params.name);
562+
let text = p.template;
563+
const a = req.params.arguments || {};
564+
for (const key of Object.keys(a)) text = text.split("{{" + key + "}}").join(String(a[key]));
565+
return { description: p.description, messages: [{ role: "user", content: { type: "text", text } }] };
566+
});
567+
568+
async function main() {
569+
const transport = new StdioServerTransport();
570+
await server.connect(transport);
571+
console.error(SERVER.name + " MCP server running on stdio — " + TOOLS.length + " tools, " + RESOURCES.length + " resources, " + PROMPTS.length + " prompts.");
572+
}
573+
main().catch((err) => { console.error(err); process.exit(1); });
574+
`;
575+
576+
export function emitServerScaffold(doc: any, ops: Operation[]): { path: string; content: string }[] {
577+
const s = getServer(doc);
578+
const title = doc.info?.title || 'API';
579+
const pkg = kebab(title) || 'api';
580+
const baseUrl = doc.servers?.[0]?.url || 'https://api.example.com';
581+
const auth = generateAuth(doc);
582+
583+
const tools: any[] = [];
584+
const resources: any[] = []; const seenRes = new Set<string>();
585+
const prompts: any[] = []; const seenPrompt = new Set<string>();
586+
const sampling: { tool: string; purpose: string }[] = [];
587+
for (const op of ops) {
588+
const t = getTool(doc, op); if (!t) continue;
589+
tools.push({
590+
name: t.name, title: t.title || undefined, description: t.description,
591+
method: op.method.toUpperCase(), path: op.path,
592+
params: op.params.filter((p) => p.name).map((p) => ({ name: p.name, in: p.in || 'query' })),
593+
inputSchema: t.inputSchema || { type: 'object' },
594+
annotations: t.annotations || {},
595+
});
596+
for (const r of t.resources || []) { if (!r.uri || seenRes.has(r.uri)) continue; seenRes.add(r.uri); resources.push({ name: r.name, uri: r.uri, description: r.description || '', mimeType: r.mimeType || 'text/markdown' }); }
597+
for (const p of t.prompts || []) { let name = p.name || 'prompt'; let n = 2; while (seenPrompt.has(name)) name = `${p.name}-${n++}`; seenPrompt.add(name); prompts.push({ name, description: p.description || '', arguments: p.arguments || [], template: p.template || '' }); }
598+
if (t.sampling?.enabled) sampling.push({ tool: t.name, purpose: t.sampling.purpose || '' });
599+
}
600+
601+
const operationsTs = [
602+
'// Generated by Toolsmith — operation data + auth for this MCP server.',
603+
'// Regenerate from your enriched OpenAPI; hand-edits here will be overwritten.',
604+
`export const SERVER = { name: ${JSON.stringify(s.name)}, version: ${JSON.stringify(s.version || '0.1.0')} };`,
605+
`export const BASE_URL = process.env.UPSTREAM_BASE_URL || ${JSON.stringify(baseUrl)};`,
606+
'',
607+
`export const TOOLS = ${JSON.stringify(tools, null, 2)};`,
608+
'',
609+
`export const RESOURCES = ${JSON.stringify(resources, null, 2)};`,
610+
'',
611+
`export const PROMPTS = ${JSON.stringify(prompts, null, 2)};`,
612+
'',
613+
'// Auth derived from the OpenAPI security scheme. ' + auth.note.replace(/`/g, ''),
614+
'export function applyAuth(headers, query) {',
615+
auth.body,
616+
'}',
617+
'',
618+
].join('\n');
619+
620+
const pkgJson = {
621+
name: `${pkg}-mcp-server`, version: s.version || '0.1.0', private: true, type: 'module',
622+
description: `MCP server for ${title}, generated by Toolsmith.`,
623+
bin: { [`${pkg}-mcp`]: 'dist/index.js' },
624+
scripts: { dev: 'tsx src/index.ts', build: 'tsc', start: 'node dist/index.js' },
625+
engines: { node: '>=18' },
626+
dependencies: { '@modelcontextprotocol/sdk': MCP_SDK_RANGE },
627+
devDependencies: { typescript: '^5.7.2', tsx: '^4.19.2', '@types/node': '^22.10.0' },
628+
};
629+
630+
const tsconfig = {
631+
compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: false, esModuleInterop: true, skipLibCheck: true, outDir: 'dist', types: ['node'] },
632+
include: ['src'],
633+
};
634+
635+
const samplingDoc = sampling.length
636+
? ['', '## Sampling', '', 'These tools were designed to use sampling (asking the client\'s model to reason over results). The generated handlers return the raw upstream response; add a sampling round-trip with `server.createMessage(...)` where you want the server to summarize or transform before replying:', '', ...sampling.map((x) => `- \`${x.tool}\`${x.purpose ? ` — ${x.purpose}` : ''}`)].join('\n')
637+
: '';
638+
639+
const readme = [
640+
`# ${title} MCP Server`,
641+
'',
642+
`Generated by [Toolsmith](https://toolsmith.apicommons.org) from the enriched OpenAPI for **${title}**. A stdio [MCP](https://modelcontextprotocol.io) server that proxies each tool to the upstream REST API at \`${baseUrl}\`.`,
643+
'',
644+
`**${tools.length}** tools · **${resources.length}** resources · **${prompts.length}** prompts.`,
645+
'',
646+
'## Run',
647+
'',
648+
'```bash',
649+
'npm install',
650+
'npm run dev # tsx src/index.ts (stdio)',
651+
'# or: npm run build && npm start',
652+
'```',
653+
'',
654+
'## Auth',
655+
'',
656+
auth.note,
657+
'Copy `.env.example` to `.env` and fill it in (values are read from the environment; set `UPSTREAM_BASE_URL` to override the base URL).',
658+
'',
659+
'## Use it from a client',
660+
'',
661+
'Point any MCP client at the built server. For a stdio client config:',
662+
'',
663+
'```json',
664+
JSON.stringify({ mcpServers: { [pkg]: { command: 'node', args: ['dist/index.js'], env: Object.fromEntries(auth.env.map((e) => [e.replace(/=$/, ''), ''])) } } }, null, 2),
665+
'```',
666+
'',
667+
'## Structure',
668+
'',
669+
'- `src/index.ts` — static MCP runtime (tools/resources/prompts handlers + upstream proxy). Don\'t edit.',
670+
'- `src/operations.ts` — generated operation data + `applyAuth`. Regenerate from Toolsmith.',
671+
samplingDoc,
672+
'',
673+
'Regenerate `src/operations.ts` whenever the design changes; `src/index.ts` stays put.',
674+
'',
675+
].join('\n');
676+
677+
const envExample = [`UPSTREAM_BASE_URL=${baseUrl}`, ...auth.env].join('\n') + '\n';
678+
679+
return [
680+
{ path: 'package.json', content: JSON.stringify(pkgJson, null, 2) + '\n' },
681+
{ path: 'tsconfig.json', content: JSON.stringify(tsconfig, null, 2) + '\n' },
682+
{ path: 'src/index.ts', content: INDEX_TS },
683+
{ path: 'src/operations.ts', content: operationsTs },
684+
{ path: '.env.example', content: envExample },
685+
{ path: '.gitignore', content: 'node_modules\ndist\n.env\n' },
686+
{ path: 'README.md', content: readme },
687+
];
688+
}

src/main.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
parseDoc, operations, coverage, kebab, runChecks,
44
getTool, setTool, getSkills, setSkills, getServer, setServer, getRootSkills, setRootSkills,
55
draftTool, draftResource, draftPrompt, draftSkill, draftApiSkill, draftServer,
6-
emitEnrichedOpenApi, emitMcpDesign, emitServerJson, emitSkillFiles, emitSkillsIndex, emitApisJsonProps,
6+
emitEnrichedOpenApi, emitMcpDesign, emitServerJson, emitSkillFiles, emitSkillsIndex, emitApisJsonProps, emitServerScaffold,
77
toYaml, toJson,
88
type Operation, type ToolDesign, type SkillDesign, type PromptDesign, type Check,
99
} from './engine';
@@ -27,7 +27,7 @@ let ops: Operation[] = [];
2727
let sourceLabel = '';
2828
let selectedKey = '';
2929
let mainTab: 'design' | 'source' | 'checks' | 'exports' = 'design';
30-
let outTab: 'openapi' | 'mcp' | 'serverjson' | 'skills' | 'apisjson' = 'openapi';
30+
let outTab: 'openapi' | 'mcp' | 'serverjson' | 'scaffold' | 'skills' | 'apisjson' = 'openapi';
3131
let currentOut = '';
3232
let saveTimer: number | undefined;
3333

@@ -398,12 +398,18 @@ function emitOut() {
398398
openapi: 'Your OpenAPI with the agent layer embedded — x-mcp on each operation, x-agent-skills alongside, x-mcp-server at the root.',
399399
mcp: 'The MCP server design — tools, resources, prompts, and sampling in the shapes clients see. Hand this to whoever builds or generates the server.',
400400
serverjson: 'A registry-ready server.json (2025-09-29 schema) for publishing to the official MCP Registry.',
401+
scaffold: 'A complete, runnable TypeScript MCP server — each tool wired to its OpenAPI operation, with resources, prompts, and auth from the spec. Download the project, then npm install && npm run dev.',
401402
skills: 'One SKILL.md per skill, each in its own directory, plus an agent-skills-discovery index.json (v0.2.0, the format apis.io serves at .well-known/agent-skills/). Download bundles it all as a zip.',
402403
apisjson: 'APIs.json property entries wiring these artifacts to your provider listing (swap in your real URLs).',
403404
};
404405
if (outTab === 'openapi') currentOut = emitEnrichedOpenApi(doc);
405406
else if (outTab === 'mcp') currentOut = toJson(emitMcpDesign(doc, ops));
406407
else if (outTab === 'serverjson') currentOut = toJson(emitServerJson(doc));
408+
else if (outTab === 'scaffold') {
409+
const files = emitServerScaffold(doc, ops);
410+
const hasTool = ops.some((o) => coverage(doc, o).tool);
411+
currentOut = hasTool ? files.map((f) => `━━ ${f.path} ━━\n${f.content}`).join('\n') : 'No MCP tools designed yet — draft some on the Design tab, then generate the server.';
412+
}
407413
else if (outTab === 'skills') {
408414
const files = emitSkillFiles(doc, ops);
409415
currentOut = files.length
@@ -417,6 +423,11 @@ function emitOut() {
417423
function downloadOut() {
418424
if (!doc) return;
419425
const base = kebab(doc.info?.title || 'api');
426+
if (outTab === 'scaffold') {
427+
if (!ops.some((o) => coverage(doc, o).tool)) return;
428+
const blob = makeZip(emitServerScaffold(doc, ops).map((f) => ({ path: `${base}-mcp-server/${f.path}`, content: f.content })));
429+
dl(blob, `${base}-mcp-server.zip`); return;
430+
}
420431
if (outTab === 'skills') {
421432
const files = emitSkillFiles(doc, ops); if (!files.length) return;
422433
const blob = makeZip([
@@ -438,7 +449,7 @@ function about() {
438449
<h2>Forge the agent layer of your API</h2>
439450
<p>An OpenAPI tells developers what your API can do. Agents need more: an <strong>MCP tool</strong> for each operation with an honest input schema and behavior annotations, the <strong>resources</strong> and <strong>prompts</strong> that give it context, <strong>sampling</strong> guidance where the server should lean on the client's model — and <strong>Agent Skills</strong> that teach an agent to apply each tool competently.</p>
440451
<p>Toolsmith is the workbench for that layer. Load an OpenAPI (the API Evangelist and APIs.io APIs are one click away), <strong>draft</strong> a design for every operation deterministically, then refine each tool, resource, prompt, and skill by hand. The design lives inside your OpenAPI as <code>x-mcp</code> and <code>x-agent-skills</code> extensions, so the enriched contract is the single artifact of record.</p>
441-
<p>Export the <strong>enriched OpenAPI</strong>, an <strong>MCP server design</strong>, a registry-ready <strong>server.json</strong>, an <strong>Agent Skills bundle</strong>, and the <strong>APIs.json properties</strong> that wire it all to your provider listing. It pairs with <a href="https://contextgate.apicommons.org" target="_blank" rel="noopener">Context Gate</a> and the <a href="https://validator.apicommons.org" target="_blank" rel="noopener">Validator</a>.</p>
452+
<p>Export the <strong>enriched OpenAPI</strong>, an <strong>MCP server design</strong>, a registry-ready <strong>server.json</strong>, an <strong>Agent Skills bundle</strong>, and the <strong>APIs.json properties</strong> that wire it all to your provider listing — or generate a <strong>complete, runnable MCP server</strong> in TypeScript, every tool wired to its OpenAPI operation, ready to <code>npm install &amp;&amp; npm run dev</code>. It pairs with <a href="https://contextgate.apicommons.org" target="_blank" rel="noopener">Context Gate</a> and the <a href="https://validator.apicommons.org" target="_blank" rel="noopener">Validator</a>.</p>
442453
<p class="muted small">Runs entirely in your browser — your API definitions never leave the page. Work is saved locally as you edit.</p>
443454
</div>`;
444455
document.body.appendChild(el);

0 commit comments

Comments
 (0)