Run Spec to MCP from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page reads the spec, inlines its refs, maps every operation to a tool by the skill's rules and writes the server files itself, then hands the tools and the template gaps to the run as facts. The run writes the review or the rewrite; the page then holds it to its own facts and re-scans the upgraded file with the same scanner (mcpreply.js).
Base URL and the response shape
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"spec-to-mcp"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required key (task, unit, spec, facts) is missing. The page always sends all five of task, unit, spec, carry, facts; carry is an empty string for review. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
Two lanes, chosen by task, in the order the work runs - inspect, then produce:
review- each of the page's findings confirmed or cleared, the scope (the tools to expose), the test plan with the failure paths, up to five issues a read cannot see on real lines, and the fixes in order.rewrite(the Build) - index.ts, src/client.ts, src/auth.ts and .env.example fitted to this spec, with what changed and what was kept.
A missing or unknown task is answered as review. Both lanes send the same five fields. The facts are the page's scan, one finding per line with its id, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the review's decisions into carry for the rewrite lane. The review sends at most 24,000 characters of code (the middle of a longer file is cut on whole lines and marked; every finding is still in the facts); the rewrite lane takes at most 16,000.
The input
| Field | Required | What |
|---|---|---|
task | yes | review or rewrite (the page calls it Build). If it is missing or neither, do review and set "task": "review". |
unit | yes | the spec's file name, the API's title and the MCP server's name. |
spec | yes | the spec as pasted, JSON or YAML. A line starting "# [... N lines cut by the page" marks a part the page read but did not send. |
carry | send it (empty for review) | rewrite task only: the review's decisions, one per line as id | confirmed or cleared or extra | where | fix or reason, and a scope | tool, tool, ... line; may be empty. |
facts | yes | the page's read: the tools (name, route, parameters with * for required, description, and why one is left out), the env vars, then one finding per line as id | area | severity | line | where | evidence | source | what the page did (handled or left). "handled" means the page's generated files already do what the finding's fix names. Lines starting "must:" apply to your reply. |
retry_note | no | optional; if present, your previous reply was unusable - follow it. |
The rules the model applies
From the source skill, @mcp-use/openapi-to-mcp - the only standard the run applies:
- The spec is the contract: tool names, descriptions, parameter shapes and auth come from the spec, never invented. Prefer mechanical fidelity to creativity - the LLM does the talking and needs a faithful, typed handle on the API.
- Naming: operationId in snake_case (getPetById -> get_pet_by_id), else method_path (GET /pets/{petId}/owners -> get_pets_pet_id_owners); a collision gets the method as a suffix; names under 60 characters.
- Description: summary, description and "[Tags: ...]", in that order, skipping empty parts; never paraphrased; when both are missing the route itself. Missing descriptions degrade tool choice more than anything else - fix them in the spec.
- Parameters merge into one flat zod object: path params required; query params required only when the spec says so; header params as header_<name>, exposed only when they are real inputs (an idempotency key), auth headers come from env vars; cookie params ignored; a JSON object body splatted into the top level, any other JSON body as one body field. Every field carries a description.
- Bodies: prefer application/json, then application/x-www-form-urlencoded (form-encode in client.ts), multipart only when the person asks (a filePath argument), binary-only uploads skipped. Responses: JSON parsed; binary returns "Downloaded N bytes of type X" or saves to a savePath, never the bytes; SSE / ndjson accumulated as text; non-2xx throws with the status and body.
- Auth: secrets only in env vars, never in code or the conversation. apiKey in a header - that header; apiKey in a query - the URL (insecure, flag it), in a cookie - a Cookie header; http bearer - Authorization: Bearer; http basic - Authorization: Basic base64(user:pass); OAuth2 - a pre-issued access token as Bearer; refresh-on-401 only when the spec shows the token is short-lived (a refresh flow, an expiry) - otherwise a caveat that the person adds it if the token expires. One scheme in use. BASE_URL is the upstream API; MCP_URL is the MCP server's own URL - never conflate them.
- Scope: above ~30 operations, filter by tag, path prefix, method or an explicit list, written into index.ts as a visible constant; deprecated and x-internal operations skipped by default; webhooks and callbacks out of scope.
- Server: the blank template (tools only; mcp-apps only for widgets), streamable HTTP via server.listen(port) at /mcp - never stdio. Imports from mcp-use, zod, dotenv, node: modules and relative files; the scaffold is ESM, so no require().
- Testing: npm run dev; Layer 1 - npx mcp-use client connect dev http://localhost:3000/mcp, then npx mcp-use client dev tools list, tools describe <tool>, tools call <tool> key=value (or a JSON argument); Layer 2 - the inspector chat at /mcp/inspector, force-invoking a tool and asking free-form; failure paths - a missing required argument (zod rejects), wrong auth (upstream 401), a dead upstream (BASE_URL at a closed port). Deploy only after asking the person.
House rules
- Use only the input. Never invent an endpoint, a parameter, a tool, a scheme or a response field the spec does not hold; tool names are the page's. The mcp-use, zod and fetch APIs the rules above use are fine. When something needs the person (a secret, an upstream to test against, the deploy decision), say so in a caveat.
- The page's ids and lines are measured facts: never renumber an id. Every finding is confirmed or cleared with a reason tied to the spec: a handled one is confirmed with the page's change as the fix (say what the page did, e.g. "Left out of OPERATION_ALLOWLIST"), or cleared when that change is wrong here; a left one is confirmed with the hand fix (one decision, never "X, or Y"), or cleared when the spec shows it does not apply.
- Never put a secret, a token or a real key in any field; env var names only.
- No web links (http or https) in prose, except localhost URLs in test steps.
- Write in English. Keep the JSON keys and the ids exactly as given.
- Never use backticks in prose (the file fields are code). Name calls plainly (server.tool, npx mcp-use client dev tools list). Words from the rules above are free to use; what you never do is paste a whole sentence from this prompt.
- Never write a gendered pronoun for a user; write they, the user or the person.
- Reply with ONLY one JSON object - no prose before or after, no code fences.
The replies
review
Decide each finding, set the scope, write the test plan and put the steps in order.
summary- one sentence, at most 40 words: what the generated server gets wrong today, what the page already handled, and the first step.scope- an object:tools(the page's tool names to expose - by default the page's allowlist; drop or add only with a reason) andwhy(one sentence: the reason for the scope, or for any change to the page's allowlist).findings- one object per page finding you confirm, in id order:id,widget(where it sits - the tool name, even when the facts name the route; a scheme; or "the spec"),issue(one sentence),fix(one sentence, one decision),code(the lines for that piece only, at most 8 - TypeScript or the spec; "" when fix says it all).cleared- one object per page finding that is not a problem here, withidandwhy(one sentence tied to the spec). Every page id appears exactly once, in findings or in cleared.extra- at most 5 issues the read cannot see, each tied to a line of the spec:id(X1, X2, ...),area(spec, servers, auth, naming, descriptions, params, bodies, responses or scope),line(a number),widget,issue,fix. Examples: a list endpoint whose pagination the description never explains, a write operation the LLM should not call without confirmation, a parameter whose description is too thin to fill, a response so large it floods the context, an operation that needs a scope the token lacks.[]when there are none.plan- 2 to 10 short strings, the steps in order (the spec and auth first, then bodies and responses, then scope and descriptions, testing last), each naming the ids it covers (e.g. "Form-encode the appointment body in client.ts (M9)"). Every confirmed id appears in a step.[]only when nothing is confirmed.tests- 3 to 24 test steps, each one command or one inspector prompt: every tool in scope called at least once with concrete arguments taken from its parameters (npx mcp-use client dev tools call list_pets limit=5), plus the failure paths (a missing required argument, wrong auth, a dead upstream).caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "review",
"summary": "one sentence",
"scope": { "tools": [ "list_pets", "create_pet" ], "why": "one sentence" },
"findings": [ { "id": "M1", "widget": "the spec", "issue": "one sentence", "fix": "one sentence", "code": "" } ],
"cleared": [ { "id": "M2", "why": "one sentence" } ],
"extra": [ { "id": "X1", "area": "responses", "line": 52, "widget": "list_pets", "issue": "one sentence", "fix": "one sentence" } ],
"plan": [ "Form-encode the appointment body in client.ts (M9)" ],
"tests": [ "npx mcp-use client dev tools call list_pets limit=5" ],
"caveats": [ ]
}
The page holds it to its own facts (check() in mcpreply.js; any warning makes the reply flagged): the reply answers the task it was sent; every page finding id appears exactly once across findings and cleared, and no id the page never found; a cleared id has a reason; a confirmed id has an issue and a fix; at most five extra issues, each on a line inside the paste; a scope inside the mapped tools; a test calling every tool in scope and a failure path; a fix order of 2 to 10 steps that names every confirmed id; a summary; no web link in the prose.
rewrite
Write the four files the page cannot fit to this spec by itself.
summary- one sentence, at most 40 words: what the build changed from the page's files and what it left for the person.files- an object with four complete files (every line; never "..." or "unchanged"):index.ts- the MCPServer (the page's name and title), the tools in the carried scope (the scope line; else the page's allowlist) - as an OPERATION_ALLOWLIST over the template's operations loop, or as explicit server.tool calls with the page's names - and server.listen(port) for streamable HTTP. Never stdio. A schema fix for one operation (a merged allOf body, an enum, a form body's fields) is made here, on that operation before operationToZod, since src/schema.ts and src/operations.ts stay the skill's templates.src/client.ts- callOperation(operationId, args): path, query, header and body handling for this spec, the auth from src/auth.ts, plus every confirmed fix whose edit lives here (form encoding, a query key, binary responses, refresh-on-401). BASE_URL from the env, no require().src/auth.ts- the headers for the scheme in use, from env vars only..env.example- every env var the code reads, each with a short comment and a placeholder value - never a real secret.changes- one object per page finding you acted on (including the ones the page handled, which the build keeps), in id order:id,change(one sentence naming the file). Carried extra ids (X1, ...) go after the page's ids.kept- one object per page finding you did not change:id,why(one sentence - the review cleared it, or it needs the spec itself edited, or a decision only the person can make). Every page id appears exactly once, in changes or in kept; an id the carried review cleared belongs in kept.caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "rewrite",
"summary": "one sentence",
"files": { "index.ts": "the complete file", "src/client.ts": "the complete file", "src/auth.ts": "the complete file", ".env.example": "the complete file" },
"changes": [ { "id": "M1", "change": "one sentence" } ],
"kept": [ { "id": "M2", "why": "one sentence" } ],
"caveats": [ ]
}
The page requires: the reply answers the task it was sent; every page finding id appears exactly once across changes and kept; all four files are written and elide nothing; every tool in scope is registered and no tool the spec lacks; streamable HTTP via server.listen and never stdio; every env var the code reads is declared in .env.example and every one the spec needs is there; the auth header (or query key) each scheme needs is sent; a form body claimed fixed is form-encoded; BASE_URL, not MCP_URL, for the upstream; no require() in the ESM project; no secret-looking literal.
Worked examples
Three runs from the page's free samples (invented APIs): the review of a pet clinic spec, the build of the same spec carrying that review, and the review of a shop spec with OAuth and binary bodies. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the code, the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.
review - A pet clinic spec with template gaps
The page read it as: Will not work as generated - 2 blocker, 2 major, 5 minor; findings M1 many-servers, M2 circular-ref, M3 circular-ref, M4 no-description, M5 deprecated, M6 binary-response, M7 no-operation-id, M8 undeclared-path, M9 form-body.
Input (spec is 3096 characters; the first 1379 are shown; facts is 2649 characters; the first 1390 are shown):
{
"task": "review",
"unit": "File: openapi.yaml\nAPI: Pet Clinic API\nMCP server: pet-clinic-mcp (mcp-use, blank template, streamable HTTP)",
"spec": "openapi: 3.0.3\ninfo:\n title: Pet Clinic API\n version: 2.1.0\n description: |\n Appointments and pets for the clinic.\n Internal staff use only.\nservers:\n - url: https://api.petclinic.example/v2\n - url: https://staging.petclinic.example/v2\nsecurity:\n - ApiKeyAuth: []\ncomponents:\n securitySchemes:\n ApiKeyAuth:\n type: apiKey\n in: header\n name: X-Clinic-Key\n schemas:\n Pet:\n type: object\n required: [id, name]\n properties:\n id:\n type: integer\n format: int64\n name:\n type: string\n description: The pet's name\n species:\n type: string\n enum: [dog, cat, rabbit]\n owner:\n $ref: '#/components/schemas/Owner'\n Owner:\n type: object\n properties:\n email:\n type: string\n format: email\n pets:\n type: array\n items:\n $ref: '#/components/schemas/Pet'\npaths:\n /pets:\n get:\n operationId: listPets\n summary: List pets\n tags: [pets]\n parameters:\n - name: limit\n in: query\n description: How many to return (max 100)\n schema:\n type: integer\n maximum: 100\n - name: species\n in: query\n schema:\n type: string\n enum: [dog, cat, rabbit]\n responses:\n '200':\n\n[... 1717 more characters cut from this display ...]",
"carry": "",
"facts": "The page's read of 136 lines - yaml, OpenAPI 3.0.3, \"Pet Clinic API\"; 6 operations, 5 tools in OPERATION_ALLOWLIST; server pet-clinic-mcp; base URL https://api.petclinic.example/v2; 4 of 9 findings handled by the page; verdict: Will not work as generated.\nTools (name | method path | params, * required | description):\n- list_pets | GET /pets | limit, species | List pets [Tags: pets]\n- create_pet | POST /pets | id*, name*, species, owner | Add a pet [Tags: pets]\n- get_pet_by_id | GET /pets/{petId} | petId* | [Tags: pets]\n- delete_pet | DELETE /pets/{petId} | petId* | Remove a pet | left out: deprecated\n- get_pets_pet_id_photo | GET /pets/{petId}/photo | none | Download the pet's photo\n- book_appointment | POST /appointments | none | Book a visit\nEnv vars (.env.example): BASE_URL, API_KEY_AUTH, DEBUG_HTTP.\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- M1 | servers | minor | line 8 | the spec | 2 servers (https://api.petclinic.example/v2, https://staging.petclinic.example/v2) - the template uses servers[0]. | source: servers: | left\n- M2 | spec | minor | line 34 | the spec | $ref #/components/schemas/Owner is recursive - the template's converter falls back to z.unknown() at the cycle. | source: $ref: '#/components/schemas/Owner' | handled\n- M3 | spec | minor | line 44 | the spec | $ref #/components/schemas/Pet is recursive - the\n\n[... 1259 more characters cut from this display ...]"
}
Reply:
{
"task": "review",
"summary": "The photo tool requests a literal {petId} path and returns raw JPEG bytes, and the appointment body is never form-encoded; the page already handled the recursive refs, the deprecated delete and the photo tool name. First declare petId on the photo path.",
"scope": {
"tools": [
"list_pets",
"create_pet",
"get_pet_by_id",
"get_pets_pet_id_photo",
"book_appointment"
],
"why": "The page's allowlist stands: six operations is well under the filter threshold and only the deprecated deletePet is left out."
},
"findings": [
{
"id": "M1",
"widget": "the spec",
"issue": "Two servers are declared and the template silently takes the first, so nothing in the project says the staging host exists.",
"fix": "Keep BASE_URL on servers[0] in .env.example and list the staging server beside it as a commented alternative line.",
"code": "BASE_URL=https://api.petclinic.example/v2\n# BASE_URL=https://staging.petclinic.example/v2"
},
{
"id": "M2",
"widget": "create_pet",
"issue": "Pet.owner points at Owner, whose pets array points back at Pet, so the converter would recurse without end.",
"fix": "Keep the page's change: the converter stops at the cycle and types the repeated Owner as z.unknown().",
"code": ""
},
{
"id": "M3",
"widget": "create_pet",
"issue": "Owner.pets items point back at Pet, closing the same cycle from the other side.",
"fix": "Keep the page's change: the repeated Pet inside owner.pets becomes z.unknown() in the tool schema.",
"code": ""
},
{
"id": "M4",
"widget": "get_pet_by_id",
"issue": "getPetById has neither summary nor description, so the model only reads the tag line when it chooses a tool.",
"fix": "Add a summary to getPetById in openapi.yaml and rerun npm run load-spec.",
"code": " get:\n operationId: getPetById\n summary: Get one pet by its id\n tags: [pets]"
},
{
"id": "M5",
"widget": "delete_pet",
"issue": "deletePet is marked deprecated: true.",
"fix": "Keep the page's change: deletePet stays out of OPERATION_ALLOWLIST.",
"code": ""
},
{
"id": "M6",
"widget": "get_pets_pet_id_photo",
"issue": "The only 200 content type is image/jpeg, and the template reads it with res.text() and hands the garbled bytes to the model.",
"fix": "In client.ts read any non-JSON, non-text response as an ArrayBuffer and return the line Downloaded N bytes of type image/jpeg instead of the bytes.",
"code": "const buf = Buffer.from(await res.arrayBuffer());\nreturn `Downloaded ${buf.length} bytes of type ${contentType}`;"
},
{
"id": "M7",
"widget": "get_pets_pet_id_photo",
"issue": "GET /pets/{petId}/photo has no operationId.",
"fix": "Keep the page's change: the tool is named get_pets_pet_id_photo from the method and path.",
"code": ""
},
{
"id": "M8",
"widget": "get_pets_pet_id_photo",
"issue": "The path template holds {petId} but the operation declares no parameters, so the tool takes no arguments and the request goes to a literal {petId}.",
"fix": "Declare petId as a required integer path parameter on /pets/{petId}/photo in openapi.yaml, matching the sibling /pets/{petId} operations.",
"code": " /pets/{petId}/photo:\n get:\n summary: Download the pet's photo\n parameters:\n - name: petId\n in: path\n required: true\n schema: { type: integer }"
},
{
"id": "M9",
"widget": "book_appointment",
"issue": "The body is application/x-www-form-urlencoded only, so the template exposes no petId or slot field and sends no body.",
"fix": "Splat the form schema's petId and slot into the tool schema and send them from client.ts as a URLSearchParams body with that content type.",
"code": "const form = new URLSearchParams();\nfor (const key of Object.keys(formSchema.properties ?? {})) {\n if (args[key] !== undefined) form.set(key, String(args[key]));\n}\nheaders[\"content-type\"] = \"application/x-www-form-urlencoded\";\nbody = form.toString();"
}
],
"cleared": [],
"extra": [
{
"id": "X1",
"area": "params",
"line": 131,
"widget": "book_appointment",
"issue": "petId and slot carry no description, so the model has to guess that slot is an ISO date-time and which pet id belongs there.",
"fix": "Add descriptions to petId and slot in the form schema in openapi.yaml."
},
{
"id": "X2",
"area": "params",
"line": 58,
"widget": "list_pets",
"issue": "The species query parameter has no description, only an enum.",
"fix": "Add a description to species in openapi.yaml saying it filters the list to one species."
},
{
"id": "X3",
"area": "scope",
"line": 122,
"widget": "book_appointment",
"issue": "Booking a visit is a write that reserves a real clinic slot.",
"fix": "Have the client confirm with the person before calling book_appointment."
},
{
"id": "X4",
"area": "params",
"line": 22,
"widget": "create_pet",
"issue": "Pet requires id, so create_pet asks the caller to invent a numeric pet id with no description of where it comes from.",
"fix": "Add a description to Pet.id in openapi.yaml stating whether the caller or the clinic assigns it."
}
],
"plan": [
"Point BASE_URL at servers[0] and note the staging host in .env.example (M1)",
"Declare petId on the photo path in openapi.yaml and rerun npm run load-spec (M8)",
"Keep the cycle fallback in the create_pet schema (M2, M3)",
"Form-encode the appointment body in client.ts and expose petId and slot (M9)",
"Return a byte count instead of the JPEG bytes for the photo tool in client.ts (M6)",
"Keep deletePet out of the allowlist and the derived photo tool name (M5, M7)",
"Add a summary to getPetById and descriptions to species, Pet.id, petId and slot in openapi.yaml (M4, X1, X2, X4)",
"Run Layer 1 and Layer 2 tests, confirming each booking with the person first (X3)"
],
"tests": [
"npm run dev",
"npx mcp-use client connect dev http://localhost:3000/mcp",
"npx mcp-use client dev tools list",
"npx mcp-use client dev tools describe book_appointment",
"npx mcp-use client dev tools call list_pets limit=5 species=cat",
"npx mcp-use client dev tools call create_pet '{\"id\":1001,\"name\":\"Biscuit\",\"species\":\"rabbit\"}'",
"npx mcp-use client dev tools call get_pet_by_id petId=1001",
"npx mcp-use client dev tools call get_pets_pet_id_photo petId=1001",
"npx mcp-use client dev tools call book_appointment petId=1001 slot=2026-11-20T09:30:00Z",
"In the inspector at http://localhost:3000/mcp/inspector, ask: Use list_pets with limit 3 and species dog.",
"In the inspector, ask: Book a visit for pet 1001 next Friday at 10:00 UTC.",
"npx mcp-use client dev tools call get_pet_by_id (no petId - zod should reject it)",
"Set API_KEY_AUTH to a wrong value, restart, then npx mcp-use client dev tools call list_pets limit=1 (expect HTTP 401)",
"Set BASE_URL=http://localhost:9999, restart, then npx mcp-use client dev tools call list_pets limit=1 (expect a clean fetch error)"
],
"caveats": [
"The person has to supply the X-Clinic-Key value in API_KEY_AUTH; it never goes in code.",
"Test against the staging server, since create_pet and book_appointment write real records.",
"The info block says internal staff use only, so ask the person before any deploy."
]
}
What step 5 prints for it: M1: Keep BASE_URL on servers[0] in .env.example and list the staging server beside it as a commented alternative line. | M2: Keep the page's change: the converter stops at the cycle and types the repeated Owner as z.unknown(). | M3: Keep the page's change: the repeated Pet inside owner.pets becomes z.unknown() in the tool schema. | M4: Add a summary to getPetById in openapi.yaml and rerun npm run load-spec. | M5: Keep the page's change: deletePet stays out of OPERATION_ALLOWLIST. | M6: In client.ts read any non-JSON, non-text response as an ArrayBuffer and return the line Downloaded N bytes of type image/jpeg instead of the bytes. | M7: Keep the page's change: the tool is named get_pets_pet_id_photo from the method and path. | M8: Declare petId as a required integer path parameter on /pets/{petId}/photo in openapi.yaml, matching the sibling /pets/{petId} operations. | M9: Splat the form schema's petId and slot into the tool schema and send them from client.ts as a URLSearchParams body with that content type.
The page's check: kept - nothing to flag
rewrite - A pet clinic spec with template gaps
The input carries the saved review (R.laneForm fills carry from the review reply, with its scope line), as the page's handoff does. The page checked the four files: 0 problems.
Input (spec is 3096 characters; the first 1379 are shown; facts is 2674 characters; the first 1390 are shown; carry is 1853 characters; the first 1397 are shown):
{
"task": "rewrite",
"unit": "File: openapi.yaml\nAPI: Pet Clinic API\nMCP server: pet-clinic-mcp (mcp-use, blank template, streamable HTTP)",
"spec": "openapi: 3.0.3\ninfo:\n title: Pet Clinic API\n version: 2.1.0\n description: |\n Appointments and pets for the clinic.\n Internal staff use only.\nservers:\n - url: https://api.petclinic.example/v2\n - url: https://staging.petclinic.example/v2\nsecurity:\n - ApiKeyAuth: []\ncomponents:\n securitySchemes:\n ApiKeyAuth:\n type: apiKey\n in: header\n name: X-Clinic-Key\n schemas:\n Pet:\n type: object\n required: [id, name]\n properties:\n id:\n type: integer\n format: int64\n name:\n type: string\n description: The pet's name\n species:\n type: string\n enum: [dog, cat, rabbit]\n owner:\n $ref: '#/components/schemas/Owner'\n Owner:\n type: object\n properties:\n email:\n type: string\n format: email\n pets:\n type: array\n items:\n $ref: '#/components/schemas/Pet'\npaths:\n /pets:\n get:\n operationId: listPets\n summary: List pets\n tags: [pets]\n parameters:\n - name: limit\n in: query\n description: How many to return (max 100)\n schema:\n type: integer\n maximum: 100\n - name: species\n in: query\n schema:\n type: string\n enum: [dog, cat, rabbit]\n responses:\n '200':\n\n[... 1717 more characters cut from this display ...]",
"carry": "scope | list_pets, create_pet, get_pet_by_id, get_pets_pet_id_photo, book_appointment\nM1 | confirmed | the spec | Keep BASE_URL on servers[0] in .env.example and list the staging server beside it as a commented alternative line.\nM2 | confirmed | create_pet | Keep the page's change: the converter stops at the cycle and types the repeated Owner as z.unknown().\nM3 | confirmed | create_pet | Keep the page's change: the repeated Pet inside owner.pets becomes z.unknown() in the tool schema.\nM4 | confirmed | get_pet_by_id | Add a summary to getPetById in openapi.yaml and rerun npm run load-spec.\nM5 | confirmed | delete_pet | Keep the page's change: deletePet stays out of OPERATION_ALLOWLIST.\nM6 | confirmed | get_pets_pet_id_photo | In client.ts read any non-JSON, non-text response as an ArrayBuffer and return the line Downloaded N bytes of type image/jpeg instead of the bytes.\nM7 | confirmed | get_pets_pet_id_photo | Keep the page's change: the tool is named get_pets_pet_id_photo from the method and path.\nM8 | confirmed | get_pets_pet_id_photo | Declare petId as a required integer path parameter on /pets/{petId}/photo in openapi.yaml, matching the sibling /pets/{petId} operations.\nM9 | confirmed | book_appointment | Splat the form schema's petId and slot into the tool schema and send them from client.ts as a URLSearchParams body with that content type.\nX1 | extra | book_appointment\n\n[... 456 more characters cut from this display ...]",
"facts": "The page's read of 136 lines - yaml, OpenAPI 3.0.3, \"Pet Clinic API\"; 6 operations, 5 tools in OPERATION_ALLOWLIST; server pet-clinic-mcp; base URL https://api.petclinic.example/v2; 4 of 9 findings handled by the page; verdict: Will not work as generated.\nTools (name | method path | params, * required | description):\n- list_pets | GET /pets | limit, species | List pets [Tags: pets]\n- create_pet | POST /pets | id*, name*, species, owner | Add a pet [Tags: pets]\n- get_pet_by_id | GET /pets/{petId} | petId* | [Tags: pets]\n- delete_pet | DELETE /pets/{petId} | petId* | Remove a pet | left out: deprecated\n- get_pets_pet_id_photo | GET /pets/{petId}/photo | none | Download the pet's photo\n- book_appointment | POST /appointments | none | Book a visit\nEnv vars (.env.example): BASE_URL, API_KEY_AUTH, DEBUG_HTTP.\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- M1 | servers | minor | line 8 | the spec | 2 servers (https://api.petclinic.example/v2, https://staging.petclinic.example/v2) - the template uses servers[0]. | source: servers: | left\n- M2 | spec | minor | line 34 | the spec | $ref #/components/schemas/Owner is recursive - the template's converter falls back to z.unknown() at the cycle. | source: $ref: '#/components/schemas/Owner' | handled\n- M3 | spec | minor | line 44 | the spec | $ref #/components/schemas/Pet is recursive - the\n\n[... 1284 more characters cut from this display ...]"
}
Reply (long strings shortened for display - see the cuts below):
{
"task": "rewrite",
"summary": "The build declares petId on the photo path, form-encodes the appointment body, reports photo downloads as a byte count and keeps the page's cycle, naming and deprecation handling; the getPetById summary and field descriptions still need spec edits.",
"files": {
"index.ts": "import \"dotenv/config\"; // load .env into process.env\nimport { MCPServer, text } from \"mcp-use\";\nimport { z, type ZodTypeAny } from \"zod\";\nimport { operations, type OperationMeta } from \"./src/operations\";\nimport { callOperation } from \"./src/client\";\nimport { operationToZod, schemaToZod } from \"./src/schema\";\n\n// [... 3270 more characters cut from this display]",
"src/client.ts": "import { readFileSync } from \"node:fs\";\nimport { operations } from \"./operations\";\nimport { buildAuthHeaders, AUTH_HEADER_NAMES } from \"./auth\";\n\n// BASE_URL is the upstream API (servers[0] by default, M1) - not MCP_URL.\nconst BASE_URL = process.env.BASE_URL ?? defaultBaseUrl();\nconst DEBUG = process.env.DEBUG_HTTP [... 4284 more characters cut from this display]",
"src/auth.ts": "// ApiKeyAuth: apiKey in the X-Clinic-Key header. The value comes from the env only.\nexport const AUTH_HEADER_NAMES = [\"X-Clinic-Key\"];\n\nexport function buildAuthHeaders(): Record<string, string> {\n const key = process.env.API_KEY_AUTH;\n if (!key) {\n throw new Error(\"API_KEY_AUTH is not set - copy .env.example to [... 64 more characters cut from this display]",
".env.example": "# Upstream API base URL (servers[0] in openapi.yaml) - not the MCP server's own MCP_URL\nBASE_URL=https://api.petclinic.example/v2\n# Staging server (servers[1]) - switch to it while testing create_pet and book_appointment\n# BASE_URL=https://staging.petclinic.example/v2\n\n# ApiKeyAuth: value sent in the X-Clinic-Key [... 175 more characters cut from this display]"
},
"changes": [
{
"id": "M1",
"change": ".env.example sets BASE_URL to servers[0] and lists the staging server as a commented alternative line."
},
{
"id": "M2",
"change": "index.ts keeps building create_pet from the page's schema.ts, which types the repeated Owner as z.unknown() at the cycle."
},
{
"id": "M3",
"change": "index.ts keeps the same converter, so the Pet inside owner.pets stays z.unknown() in the create_pet schema."
},
{
"id": "M5",
"change": "index.ts leaves delete_pet out of OPERATION_ALLOWLIST."
},
{
"id": "M6",
"change": "src/client.ts reads non-JSON, non-text responses as an ArrayBuffer and returns Downloaded N bytes of type image/jpeg instead of the bytes."
},
{
"id": "M7",
"change": "index.ts keeps the method-and-path tool name get_pets_pet_id_photo from operations.ts in the allowlist."
},
{
"id": "M8",
"change": "index.ts declares petId as a required integer path parameter for get_pets_pet_id_photo, and src/client.ts substitutes it and refuses any unfilled placeholder."
},
{
"id": "M9",
"change": "index.ts splats the form schema's petId and slot into the book_appointment tool schema, and src/client.ts sends them as an application/x-www-form-urlencoded body."
}
],
"kept": [
{
"id": "M4",
"why": "The missing getPetById summary has to be added to openapi.yaml itself, followed by npm run load-spec."
},
{
"id": "X1",
"why": "Descriptions for petId and slot belong in the form schema in openapi.yaml; until then index.ts falls back to the field names."
},
{
"id": "X2",
"why": "The species description belongs in openapi.yaml; index.ts only falls back to the field name."
},
{
"id": "X3",
"why": "Confirming a booking before the call is a setting the person makes in the MCP client, not in these files."
},
{
"id": "X4",
"why": "Whether the caller or the clinic assigns Pet.id is a decision only the person can write into the spec."
}
],
"caveats": [
"The person fills API_KEY_AUTH in .env; the key never goes in code.",
"Once openapi.yaml declares petId on the photo path, remove MISSING_PATH_PARAMS from index.ts.",
"Test writes against the staging server and ask the person before any deploy."
]
}
Cut from this display only (the saved reply and the check use the full text): files.index.ts (3585 characters, 315 shown); files.src/client.ts (4600 characters, 316 shown); files.src/auth.ts (383 characters, 319 shown); files..env.example (489 characters, 314 shown).
What step 5 prints for it: M1: .env.example sets BASE_URL to servers[0] and lists the staging server as a commented alternative line. | M2: index.ts keeps building create_pet from the page's schema.ts, which types the repeated Owner as z.unknown() at the cycle. | M3: index.ts keeps the same converter, so the Pet inside owner.pets stays z.unknown() in the create_pet schema. | M5: index.ts leaves delete_pet out of OPERATION_ALLOWLIST. | M6: src/client.ts reads non-JSON, non-text responses as an ArrayBuffer and returns Downloaded N bytes of type image/jpeg instead of the bytes. | M7: index.ts keeps the method-and-path tool name get_pets_pet_id_photo from operations.ts in the allowlist. | M8: index.ts declares petId as a required integer path parameter for get_pets_pet_id_photo, and src/client.ts substitutes it and refuses any unfilled placeholder. | M9: index.ts splats the form schema's petId and slot into the book_appointment tool schema, and src/client.ts sends them as an application/x-www-form-urlencoded body.
The page's check: kept - nothing to flag
review - A shop spec with OAuth and binary bodies
The page read it as: Will not work as generated - 1 blocker, 5 major, 2 minor; findings M1 apikey-query, M2 oauth2, M3 server-vars, M4 many-schemes, M5 enum-non-string, M6 header-param, M7 binary-response, M8 multipart-body.
Input (spec is 3482 characters; the first 1394 are shown; facts is 2604 characters; the first 1398 are shown):
{
"task": "review",
"unit": "File: craftshop.openapi.json\nAPI: Craft Shop API\nMCP server: craft-shop-mcp (mcp-use, blank template, streamable HTTP)",
"spec": "{\n \"openapi\": \"3.1.0\",\n \"info\": { \"title\": \"Craft Shop API\", \"version\": \"1.4.0\" },\n \"servers\": [{ \"url\": \"https://{region}.craftshop.example/api\", \"variables\": { \"region\": { \"default\": \"eu\" } } }],\n \"components\": {\n \"securitySchemes\": {\n \"OAuth\": { \"type\": \"oauth2\", \"flows\": { \"clientCredentials\": { \"tokenUrl\": \"https://auth.craftshop.example/token\", \"scopes\": { \"orders:write\": \"Create orders\" } } } },\n \"LegacyKey\": { \"type\": \"apiKey\", \"in\": \"query\", \"name\": \"api_key\" }\n },\n \"schemas\": {\n \"Money\": { \"type\": \"object\", \"required\": [\"amount\", \"currency\"], \"properties\": { \"amount\": { \"type\": \"integer\", \"minimum\": 0, \"description\": \"Minor units\" }, \"currency\": { \"type\": \"string\", \"pattern\": \"^[A-Z]{3}$\" } } },\n \"Address\": { \"type\": \"object\", \"properties\": { \"line1\": { \"type\": \"string\" }, \"postcode\": { \"type\": [\"string\", \"null\"] } } }\n }\n },\n \"security\": [{ \"OAuth\": [\"orders:write\"] }],\n \"paths\": {\n \"/products\": {\n \"get\": {\n \"operationId\": \"listProducts\",\n \"summary\": \"Search the catalogue\",\n \"description\": \"Returns up to 50 products per page; pass the cursor from the previous page.\",\n \"parameters\": [\n { \"name\": \"q\", \"in\": \"query\", \"schema\": { \"type\": \"string\" } },\n { \"name\": \"cursor\", \"in\": \"query\", \"schema\": { \"type\": \"string\" } },\n { \"name\": \"size\", \"in\": \"query\", \"schema\": {\n\n[... 2088 more characters cut from this display ...]",
"carry": "",
"facts": "The page's read of 64 lines - json, OpenAPI 3.1.0, \"Craft Shop API\"; 4 operations, 4 tools in OPERATION_ALLOWLIST; server craft-shop-mcp; base URL https://eu.craftshop.example/api; 1 of 8 findings handled by the page; verdict: Will not work as generated.\nTools (name | method path | params, * required | description):\n- list_products | GET /products | q, cursor, size | Search the catalogue Returns up to 50 products per page; pass the cursor from the...\n- create_order | POST /orders | header_idempotency_key*, items*, total*, shipping | Place an order\n- get_invoice_pdf | GET /orders/{orderId}/invoice | orderId* | Download the invoice\n- upload_product_image | POST /products/{sku}/images | sku* | Attach an image\nEnv vars (.env.example): BASE_URL, OAUTH_ACCESS_TOKEN, LEGACY_KEY, DEBUG_HTTP.\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- M1 | auth | blocker | line 1 | LegacyKey | apiKey in query (api_key) - the template's auth.ts only builds headers, so the key is never sent; query-string keys also end up in logs. | source: { | left\n- M2 | auth | minor | line 1 | OAuth | OAuth2 (clientCredentials) - the server sends a pre-issued access token as Bearer; the token expires and refreshes at https://auth.craftshop.example/token. | source: { | left\n- M3 | servers | major | line 4 | the spec | servers[0].url (https://{region}.craftshop.example/api)\n\n[... 1206 more characters cut from this display ...]"
}
Reply:
{
"task": "review",
"summary": "The server sends whatever auth it finds, lets any integer through as size, returns the invoice PDF as text and exposes an upload it cannot send; the page already substituted the region variable. First narrow auth to the OAuth bearer.",
"scope": {
"tools": [
"list_products",
"create_order",
"get_invoice_pdf"
],
"why": "upload_product_image is dropped because its only body is multipart/form-data, which the skill builds only when the person asks for it (M8)."
},
"findings": [
{
"id": "M2",
"widget": "OAuth",
"issue": "The clientCredentials access token expires, and a pre-issued token in OAUTH_ACCESS_TOKEN stops working with nothing to renew it.",
"fix": "On a 401 in client.ts, fetch a new token from OAUTH_TOKEN_URL with the client_credentials grant and the orders:write scope, then retry the call once.",
"code": "const res = await fetch(tokenUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({ grant_type: \"client_credentials\", client_id, client_secret, scope: \"orders:write\" }),\n});"
},
{
"id": "M3",
"widget": "the spec",
"issue": "servers[0].url holds a {region} variable that the template never fills in.",
"fix": "Keep the page's change: BASE_URL defaults to servers[0] with region filled in as its default, eu.",
"code": ""
},
{
"id": "M4",
"widget": "the spec",
"issue": "Two schemes are declared, and the template would attach every one that has an env value even though the global security names only OAuth.",
"fix": "Build only the OAuth bearer header in auth.ts and drop LEGACY_KEY from .env.example.",
"code": "export function buildAuthHeaders(token: string | undefined): Record<string, string> {\n return token ? { Authorization: `Bearer ${token}` } : {};\n}"
},
{
"id": "M5",
"widget": "list_products",
"issue": "size is an integer enum of 10, 25 and 50, but the converter only builds z.enum for strings, so any integer passes.",
"fix": "Override size in the list_products tool schema with a union of the three integer literals.",
"code": "size: z.union([z.literal(10), z.literal(25), z.literal(50)]).optional().describe(\"Page size: 10, 25 or 50\")"
},
{
"id": "M7",
"widget": "get_invoice_pdf",
"issue": "The 200 response is application/pdf, and the template reads it as text into the model's context.",
"fix": "Give get_invoice_pdf a savePath argument, write the bytes there in client.ts and return Saved N bytes to that path.",
"code": "const buf = Buffer.from(await res.arrayBuffer());\nawait writeFile(savePath, buf);\nreturn `Saved ${buf.length} bytes to ${savePath}`;"
},
{
"id": "M8",
"widget": "upload_product_image",
"issue": "The only body is multipart/form-data with a binary file, so the generated tool takes just sku and sends no file.",
"fix": "Remove upload_product_image from OPERATION_ALLOWLIST until the person asks for uploads.",
"code": "const OPERATION_ALLOWLIST = [\"list_products\", \"create_order\", \"get_invoice_pdf\"];"
}
],
"cleared": [
{
"id": "M1",
"why": "The global security on line 15 names only OAuth and no operation overrides it, so the LegacyKey query key is never required and is not sent."
},
{
"id": "M6",
"why": "Idempotency-Key is a required uuid header that the caller must vary per order, which makes it a real input that belongs in the tool as header_idempotency_key."
}
],
"extra": [
{
"id": "X1",
"area": "responses",
"line": 27,
"widget": "list_products",
"issue": "The description says to pass the cursor from the previous page, but the 200 schema is a bare object, so the model cannot tell which response field holds the next cursor.",
"fix": "Add the page's product list and next-cursor field to the 200 response schema in the spec."
},
{
"id": "X2",
"area": "params",
"line": 23,
"widget": "list_products",
"issue": "q and cursor have no description, so the model sees only the bare names.",
"fix": "Add descriptions to q and cursor in the spec."
},
{
"id": "X3",
"area": "scope",
"line": 31,
"widget": "create_order",
"issue": "Placing an order is a write that commits a real purchase.",
"fix": "Have the client confirm items, total and shipping with the person before calling create_order."
},
{
"id": "X4",
"area": "params",
"line": 11,
"widget": "create_order",
"issue": "Money.currency has only a pattern and no description, and amount says minor units without an example.",
"fix": "Describe currency as an ISO 4217 code and give amount an example in minor units in the spec."
}
],
"plan": [
"Send only the OAuth bearer from auth.ts and remove LEGACY_KEY (M4)",
"Add client_credentials refresh-on-401 to client.ts with OAUTH_TOKEN_URL, OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET (M2)",
"Keep the eu region default in BASE_URL (M3)",
"Save the invoice PDF to a savePath in client.ts instead of returning the bytes (M7)",
"Restrict size to 10, 25 or 50 in the list_products schema (M5)",
"Drop upload_product_image from OPERATION_ALLOWLIST (M8)",
"Describe the cursor field, q, cursor and the Money fields in the spec (X1, X2, X4)",
"Run Layer 1 and Layer 2 tests, confirming each order with the person first (X3)"
],
"tests": [
"npm run dev",
"npx mcp-use client connect dev http://localhost:3000/mcp",
"npx mcp-use client dev tools list",
"npx mcp-use client dev tools describe list_products",
"npx mcp-use client dev tools call list_products q=mug size=10",
"npx mcp-use client dev tools call list_products size=12 (zod should reject it)",
"npx mcp-use client dev tools call create_order '{\"header_idempotency_key\":\"3f6c2a1e-8b4d-4c5e-9a7f-1d2e3f4a5b6c\",\"items\":[{\"sku\":\"MUG-01\",\"qty\":2}],\"total\":{\"amount\":2400,\"currency\":\"EUR\"},\"shipping\":\"pickup\"}'",
"npx mcp-use client dev tools call get_invoice_pdf orderId=ord_123 savePath=/tmp/invoice-ord_123.pdf",
"In the inspector at http://localhost:3000/mcp/inspector, ask: Use list_products with q set to vase and size 25.",
"In the inspector, ask: Find a ceramic bowl and show me the next page of results.",
"npx mcp-use client dev tools call create_order '{\"items\":[{\"sku\":\"MUG-01\",\"qty\":1}]}' (missing total and the idempotency key - zod should reject it)",
"Set OAUTH_ACCESS_TOKEN and OAUTH_CLIENT_SECRET to wrong values, restart, then npx mcp-use client dev tools call list_products size=10 (expect HTTP 401 after one refresh attempt)",
"Set BASE_URL=http://localhost:9999, restart, then npx mcp-use client dev tools call list_products size=10 (expect a clean fetch error)"
],
"caveats": [
"The person must provide the OAuth client id and secret (or a token) through env vars; none of them goes in code.",
"create_order places real orders, so test against a sandbox upstream the person names.",
"If the person wants product image uploads, the tool returns with a filePath argument and a multipart body."
]
}
What step 5 prints for it: M2: On a 401 in client.ts, fetch a new token from OAUTH_TOKEN_URL with the client_credentials grant and the orders:write scope, then retry the call once. | M3: Keep the page's change: BASE_URL defaults to servers[0] with region filled in as its default, eu. | M4: Build only the OAuth bearer header in auth.ts and drop LEGACY_KEY from .env.example. | M5: Override size in the list_products tool schema with a union of the three integer literals. | M7: Give get_invoice_pdf a savePath argument, write the bytes there in client.ts and return Saved N bytes to that path. | M8: Remove upload_product_image from OPERATION_ALLOWLIST until the person asks for uploads. | M1: cleared | M6: cleared
The page's check: kept - M1 (LegacyKey, a blocker) was cleared: The global security on line 15 names only OAuth and no operation overrides it, so the LegacyKey query key is never required and is not sent.
Build the input yourself
Load the engine in node (global.window = global; require("./mcpkit.js"); require("./mcpreply.js")), then:
const K = McpKit, R = McpReply; // load templates.js first: it carries the skill's src/*.ts templates
const f = K.form({ name: "openapi.json", carry: "",
code: JSON.stringify({ openapi: "3.0.0", info: { title: "Weather", version: "1" }, servers: [{ url: "https://w.example" }],
paths: { "/forecast": { get: { operationId: "getForecast", summary: "Forecast for a city", parameters: [{ name: "city", in: "query", required: true, schema: { type: "string" } }], responses: {} } } } }) });
// f.scan.tools (name, description, fields, zod), f.scan.files, f.bundle.script (setup.sh), f.findings, f.checklist
// K.findingsCsv(f), K.itemsCsv(f), K.checklistMd(f) - the free exports
const b = K.buildInput(f, "review"); // or "rewrite" (R.laneForm(form, "rewrite", review) fills carry)
const wire = K.toWire(b.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "review");
// R.check(reply, b) // {status: kept or flags, notes}
Get a token for the steps below from /tokens.html.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://spec-to-mcp.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://spec-to-mcp.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// spec-to-mcp.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://spec-to-mcp.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://spec-to-mcp.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// SpecToMcp.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class SpecToMcp {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://spec-to-mcp.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://spec-to-mcp.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://spec-to-mcp.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://spec-to-mcp.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"spec-to-mcp"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "spec-to-mcp"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "spec-to-mcp" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "spec-to-mcp"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "spec-to-mcp"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "spec-to-mcp" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "spec-to-mcp"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "spec-to-mcp" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "review", "unit": "File: openapi.yaml\nAPI: Pet Clinic API\nMCP server: pet-clinic-mcp", "spec": "openapi: 3.0.3\ninfo:\n title: Pet Clinic API\n...", "carry": "", "facts": "The page's read of 136 lines - yaml, OpenAPI 3.0.3 ...\nTools (name | method path | params, * required | description):\n- list_pets | GET /pets | limit, species | List pets ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "review", "unit": "File: openapi.yaml\nAPI: Pet Clinic API\nMCP server: pet-clinic-mcp", "spec": "openapi: 3.0.3\ninfo:\n title: Pet Clinic API\n...", "carry": "", "facts": "The page's read of 136 lines - yaml, OpenAPI 3.0.3 ...\nTools (name | method path | params, * required | description):\n- list_pets | GET /pets | limit, species | List pets ..."}
# - see the input table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "review" or "rewrite", unit, spec, carry, facts} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses spec-to-mcp:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A review reply carries summary, scope, findings, cleared, extra, plan, tests and caveats; a rewrite (Build) reply carries summary, files, changes, kept and caveats. The code below prints the gist of either: each confirmed finding and its fix, or each change.
KEY="spec-to-mcp:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "rewrite" then (.changes[] | "\(.id): \(.change)")
else (.findings[] | "\(.id): \(.fix)") end' reply.json
import hashlib, time
key = "spec-to-mcp:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply["task"])
if reply["task"] == "rewrite":
for x in reply["changes"]:
print(x["id"], x["change"])
else:
for x in reply["findings"]:
print(x["id"], x["fix"])
import { createHash } from "node:crypto";
const key = `spec-to-mcp:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.task);
if (reply.task === "rewrite") { for (const x of reply.changes) console.log(x.id, x.change); }
else { for (const x of reply.findings) console.log(x.id, x.fix); }
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "spec-to-mcp:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"], reply["task"])
key, b := "findings", "fix"
if reply["task"] == "rewrite" {
key, b = "changes", "change"
}
xs, _ := reply[key].([]any)
for _, x := range xs {
xx := x.(map[string]any)
fmt.Println(xx["id"], xx[b])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "spec-to-mcp:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.path("task").asText());
if (reply.path("task").asText().equals("rewrite")) {
for (JsonNode x : reply.path("changes")) System.out.println(x.path("id").asText() + " " + x.path("change").asText());
} else {
for (JsonNode x : reply.path("findings")) System.out.println(x.path("id").asText() + " " + x.path("fix").asText());
}
require "digest"
key = "spec-to-mcp:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["task"]].inspect
if reply["task"] == "rewrite"
reply["changes"].each { |x| puts "#{x["id"]} #{x["change"]}" }
else
reply["findings"].each { |x| puts "#{x["id"]} #{x["fix"]}" }
end
$key = "spec-to-mcp:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["task"], "\n";
if ($reply["task"] === "rewrite") { foreach ($reply["changes"] as $x) echo $x["id"], " ", $x["change"], "\n"; }
else { foreach ($reply["findings"] as $x) echo $x["id"], " ", $x["fix"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"spec-to-mcp:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>())!;
var task = (string?)reply["task"];
Console.WriteLine($"{j["charged_credits"]} {task}");
if (task == "rewrite") { foreach (var x in reply["changes"]!.AsArray()) Console.WriteLine($"{x!["id"]} {x["change"]}"); }
else { foreach (var x in reply["findings"]!.AsArray()) Console.WriteLine($"{x!["id"]} {x["fix"]}"); }
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: spec-to-mcp:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "review", "unit": "File: openapi.yaml\nAPI: Pet Clinic API\nMCP server: pet-clinic-mcp", "spec": "openapi: 3.0.3\ninfo:\n title: Pet Clinic API\n...", "carry": "", "facts": "The page's read of 136 lines - yaml, OpenAPI 3.0.3 ...\nTools (name | method path | params, * required | description):\n- list_pets | GET /pets | limit, species | List pets ..."}
# - see the input table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "spec-to-mcp:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `spec-to-mcp:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "spec-to-mcp:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "spec-to-mcp:" + input.get("task") + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "spec-to-mcp:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: spec-to-mcp:" . json_decode($rev, true)["task"] . ":" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"spec-to-mcp:{input["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);