tool-forge
Turn any OpenAPI spec into a code-mode MCP server.
npx -y github:moeealii/tool-forge serve https://petstore3.swagger.io/api/v3/openapi.json
Most OpenAPI-to-MCP converters give the model one tool per endpoint. Point one at a real API and the model has to pick from hundreds of tools. Every intermediate response lands in its context, and a five-step task takes five round trips.
tool-forge gives the model a typed api object instead, and lets it write the glue:
const owner = "swamp-club", repo = "swamp";
const [info, releases, contributors] = await Promise.all([
api.reposGet({ owner, repo }),
api.reposListReleases({ owner, repo, per_page: 3 }),
api.reposListContributors({ owner, repo, per_page: 5 }),
]);
return {
stars: info.stargazers_count,
latestReleases: releases.map((r) => r.tag_name),
topContributors: contributors.map((c) => `${c.login} (${c.contributions})`),
};
Against GitHub's live API, that script made 3 calls in parallel in about half a second. The release data alone was over 52,000 characters of JSON. The model got back the ~400-character answer and nothing else.
Models write code more reliably than they juggle tool calls, and code can loop, branch, join and filter. tool-forge supplies the parts that make that safe against a real service.
How it works
The MCP server exposes one tool, execute. Its description holds TypeScript declarations generated from the spec: one method per operation, with argument and return types, doc comments, and whether it is read-only or destructive:
declare const api: {
/**
* Get a pet
* GET /pets/{petId} · read-only
*/
getPet(args: {
petId: string;
}): Promise<{
id: string;
name: string;
nickname?: string | null;
}>;
};
APIs too large to describe in full (GitHub's is 1.3 MB of TypeScript) also get search_api. It ranks operations with BM25 over their summaries, names, tags and paths, folds plurals and maps verb synonyms (add → create), and returns only the matching declarations. Return types are kept compact: the nesting is deep enough to write code against without paying for every field of every nested object.
The model's script runs in a QuickJS WebAssembly sandbox:
- The only way out is
api.*. There is nofetch,require, filesystem,processor timers, andapiis frozen. - Every
apicall goes back to the host, where credentials, rate limiting, retries and the audit log apply. Secrets never enter the sandbox. - Each run has a heap limit (64 MB), a CPU deadline and wall-clock limit (60 s), and a call budget (50). Runaway loops are interrupted, and calls that never return time out.
- A failed call throws
ApiErrorwith.statusand.body, so the script can handle a 404 itself. Uncaught errors come back with line numbers that match the script. console.logoutput is returned with the result. Large results are truncated with a hint to return less.
Safety, unchanged from before
- Read-only by default.
POST/PUT/PATCH/DELETEoperations don't exist onapiunless you pass--allow-writes. Scope further with--include/--excludeglobs on operationId, tag or"METHOD /path". - Secrets stay in the environment.
--auth bearer:GITHUB_TOKENnames a variable, never a value. - Polite under load. A shared token-bucket rate limiter, retries with jittered backoff on 429/502/503/504 that honour
Retry-After, and per-request timeouts. APOSTorPATCHis only retried on 429 or a refused connection, never on a 5xx where it may already have been applied. - Audit log.
--audit calls.jsonlrecords every API call (secret query values redacted, argument names but not values) and every script run (duration, call count, outcome).
Quick start
tool-forge types ./openapi.yaml # the TypeScript the model will see
tool-forge exec ./openapi.yaml script.js # run a script yourself, exactly as the model would
tool-forge list ./openapi.yaml # operations, access level, and what was skipped and why
Claude Code
claude mcp add github -e GITHUB_TOKEN=ghp_... -- \
npx -y github:moeealii/tool-forge serve \
https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json \
--auth bearer:GITHUB_TOKEN --header "User-Agent:tool-forge"
Claude Desktop, Cursor and other clients
{
"mcpServers": {
"immich": {
"command": "npx",
"args": ["-y", "github:moeealii/tool-forge", "serve", "http://nas.example.com:2283/api/spec.json",
"--base-url", "http://nas.example.com:2283/api", "--auth", "header:x-api-key:IMMICH_API_KEY",
"--audit", "/var/log/immich-mcp.jsonl"],
"env": { "IMMICH_API_KEY": "..." }
}
}
}
Options
| Flag | Default | |
|---|---|---|
--base-url <url> |
first servers entry |
Relative server URLs resolve against the spec's URL |
--auth <spec> |
none | bearer:ENV, header:Name:ENV, query:name:ENV, basic:USER_ENV:PASS_ENV. Repeatable |
--header <Name:Value> |
Static header on every request. Repeatable | |
--allow-writes |
off | Put state-changing operations on api |
--include / --exclude <glob> |
Repeatable | |
--max-calls <n> |
50 | API calls per script run |
--exec-timeout <ms> |
60000 | Wall-clock limit per script run |
--memory-mb <n> |
64 | Sandbox heap limit |
--max-chars <n> |
50000 | Output returned to the model |
--inline-types <n> |
20000 | Above this many characters of TypeScript, add search_api |
--rps <n> / --burst <n> |
5 / 10 | Shared across all calls. --rps 0 disables limiting |
--retries <n> |
3 | |
--timeout <ms> |
30000 | Per HTTP request |
--audit <file> |
JSON Lines |
How a spec becomes an API
- Path, query and header parameters become properties of
args. The request body becomesargs.body. Path-level parameters are inherited and operation-level ones override them. $refs are inlined. Recursive schemas are cut off at the point of recursion.- OpenAPI-only keywords are translated or removed:
nullablebecomes| null,readOnlyproperties are dropped from request bodies, andAuthorization/Cookieparameters are never exposed, because auth is injected by the host. - Query parameters follow the spec's
style/explode. JSON, form-encoded and text bodies are supported. Operations that needmultipart/form-dataor binary uploads are listed as skipped, with the reason.
As a library
import { apiNames, buildTools, createServer, Executor, loadSpec, runInSandbox } from "tool-forge";
const spec = await loadSpec("./openapi.yaml");
const { tools } = buildTools(spec, { allowWrites: false, include: [], exclude: [] });
const executor = new Executor({ baseUrl: "https://api.example.com", auth: [], rps: 5, burst: 10,
retries: 3, timeoutMs: 30_000, maxResponseChars: 50_000 });
const result = await runInSandbox("return (await api.listPets()).length;", apiNames(tools), executor);
const server = createServer(tools, executor, { name: "pets", version: "1.0.0" });
Limits
- OpenAPI 3.0 and 3.1. Convert Swagger 2 first, for example with
swagger2openapi. - Local
$refs only. Bundle multi-file specs first, for example withredocly bundle. - stdio transport only.
Development
npm install
npm test # 47 tests: spec handling, HTTP policy, type generation, sandbox isolation and limits,
# and end-to-end runs where a real MCP client drives the server over stdio
npm run build
MIT licensed.