PluginWorld
To

tool-forge

MCP

Turns any OpenAPI spec into a single-tool MCP server where the model writes JavaScript against a typed api object in a QuickJS sandbox, so multi-step tasks run in one round trip and only the final result reaches context. Credentials, rate limiting, retries, write gating and audit logging stay on the host, outside the sandbox.

@moeealii · v0.2.0 · MIT License · updated today

SECURITY

B

SCORE

68

STARS

▲ 0

PLUG IN

git clone https://github.com/moeealii/tool-forge.git

See the README to configure this MCP server

README

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 no fetch, require, filesystem, process or timers, and api is frozen.
  • Every api call 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 ApiError with .status and .body, so the script can handle a 404 itself. Uncaught errors come back with line numbers that match the script.
  • console.log output 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/DELETE operations don't exist on api unless you pass --allow-writes. Scope further with --include/--exclude globs on operationId, tag or "METHOD /path".
  • Secrets stay in the environment. --auth bearer:GITHUB_TOKEN names 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. A POST or PATCH is only retried on 429 or a refused connection, never on a 5xx where it may already have been applied.
  • Audit log. --audit calls.jsonl records 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 becomes args.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: nullable becomes | null, readOnly properties are dropped from request bodies, and Authorization/Cookie parameters 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 need multipart/form-data or 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 with redocly 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.

SIMILAR PLUGINS