The short answer: you read the schema you already have, turn every operation into one MCP tool with a JSON Schema for its inputs, and run each tool call by rebuilding the original HTTP request. For a REST API the schema is your OpenAPI document. For a GraphQL API it is the introspection result. That part takes an afternoon, and I have now watched it take an afternoon several times.
What takes longer is everything the mapping does not tell you. This post is the version I wish I had read before we pointed our converter at AnnounceKit's GraphQL API. The scanner found 220 operations. We shipped 84. The difference between those two numbers is most of what matters.
The REST mapping, in about forty lines
For OpenAPI, each path and method pair becomes a tool. The tool name comes from operationId, and when a spec has none we fall back to method plus path. Path, query and header parameters become properties. The request body becomes a single body property. This is close to what our parser does:
for (const [pathTpl, pathItem] of Object.entries(spec.paths)) {
for (const method of ["get", "post", "put", "patch", "delete"]) {
const op = pathItem[method];
if (!op) continue;
const name = slug(op.operationId ?? `${method}_${pathTpl}`);
const properties = {};
const required = [];
for (const p of [...(pathItem.parameters ?? []), ...(op.parameters ?? [])]) {
properties[p.name] = { ...p.schema, description: p.description };
if (p.required) required.push(p.name);
}
if (op.requestBody) {
properties.body = op.requestBody.content["application/json"].schema;
if (op.requestBody.required) required.push("body");
}
tools.push({
name,
description: op.summary || op.description || `${method.toUpperCase()} ${pathTpl}`,
inputSchema: { type: "object", properties, required, additionalProperties: false },
});
}
}Two details are worth copying. Set additionalProperties: false, because models will otherwise invent a plausible parameter your API silently ignores. And resolve every $ref before you emit the schema. MCP clients do not follow references into a document they never saw.
Execution is the reverse trip: fill the path template, build the query string, send the body, return the response as text. Our OpenAPI to MCP guide goes through the edge cases.
The GraphQL mapping, and the null that broke it
GraphQL looks easier. Introspection hands you every root query and mutation field with typed arguments, so each field becomes a tool and each argument becomes a property. We prefix mutations with do_ so a model can tell a read from a write by name alone.
The trap is that a GraphQL tool has to ship a query document, and a query document needs a selection set. Our first generator was greedy. It selected nested objects a few levels deep so the agent would get rich answers. It passed every test we ran against sample data.
Then it met real AnnounceKit data and returned Cannot return null for non-nullable field Post.project. The schema promised every post a project. One row did not have one. GraphQL's rule is that a null in a non-null field nulls out the parent, and the parent's parent, until it reaches something nullable. The whole posts call came back as data: null and an errors array. One bad row, zero posts.
The fix was to generate less:
const MAX_DEPTH = 1;
const MAX_FIELDS_PER_LEVEL = 30;
function isNonNullSingularObject(t) {
if (t.kind !== "NON_NULL" || !t.ofType) return false;
if (t.ofType.kind === "LIST") return false;
const u = unwrap(t.ofType);
return u.kind === "OBJECT" || u.kind === "INTERFACE" || u.kind === "UNION";
}
// while building the selection:
if (depth >= MAX_DEPTH) continue;
if (isNonNullSingularObject(field.type)) continue;Depth one, at most thirty fields per level, and never a required singular object. Fields that take arguments are skipped too, since the generator cannot guess them. The agent gets ids back and can ask for detail with a second call. That costs one extra round trip and removes a whole class of failures that only show up in production. The GraphQL to MCP guide has the longer version.
GraphQL fails with HTTP 200
A REST tool can map a non-2xx status to MCP's isError flag and be mostly right. GraphQL returns 200 for field errors, so that mapping reports every partial failure as a success. We now decide on the payload instead:
export function graphqlOutcome(payload, httpOk) {
if (!httpOk) return [payload, false];
const errors = Array.isArray(payload?.errors) ? payload.errors : null;
if (!errors || errors.length === 0) return [payload, true];
const hasData =
payload?.data != null &&
Object.values(payload.data).some((v) => v !== null && v !== undefined);
if (!hasData) return [payload, false];
return [{ ...payload, _warning: `Partial result: ${errors.length} field error(s)` }, true];
}Errors with no usable data are a failure. Errors next to real data are a success with a warning the model can read. I wrote up the other ways this flag misleads a host in isError is not an error.
Ship fewer tools than you generate
220 generated tools is not a feature. An agent chooses from the list on every turn, and the list rides along in the prompt. Near-duplicates like post, posts and post_by_slug make it guess. For AnnounceKit we kept the 84 queries customers actually ask about and held every mutation back.
Our wizard now pre-selects safe reads and never auto-selects a destructive operation. If you build this yourself, make the default the same: reads in, writes opt in one by one, deletes behind an explicit approval.
Descriptions: where it broke without telling anyone
The model decides which tool to call by reading its description, and spec descriptions are written for developers. "Returns a paginated list of Post objects" does not tell an agent when a user wants it. So we added a step that rewrites every description for an agent.
It never worked. When I finally looked, every stored annotation in production had the model field set to unavailable. The LLM calls returned 200 every time. Usage logs showed 145 of them. The output was being thrown away after the fetch.
The cause: the OpenAI-compatible endpoint we were calling ignored response_format: json_object and wrapped its answer in a markdown code fence. A bare JSON.parse threw, a catch block returned null, and nothing logged. On top of that the model was a thinking model, its reasoning counted against a max_tokens of 512, and the JSON was cut off even when it was unfenced.
function parseModelJson(content: string) {
const raw = content.trim();
const candidates = [raw];
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
if (fenced?.[1]) candidates.push(fenced[1].trim());
const first = raw.indexOf("{");
const last = raw.lastIndexOf("}");
if (first !== -1 && last > first) candidates.push(raw.slice(first, last + 1));
for (const c of candidates) {
try {
const parsed = JSON.parse(c);
if (parsed && typeof parsed === "object") return parsed;
} catch {}
}
return null;
}Try the raw text, then the fenced block, then the outermost object. Raise the token budget to 2048, and log every response that still does not parse. The logging is the real fix. A pipeline that fails quietly keeps failing until someone reads the database.
Cap what a tool returns
APIs return what a frontend needs, and a list endpoint can easily return a few hundred kilobytes. Every byte lands in the model's context. We cut non-JSON responses at 8,000 characters with an explicit [truncated] marker, so the model knows to page or filter instead of assuming it saw everything. JSON passes through whole, which is one more reason the shallow GraphQL selection above matters. Pick your own limits, but pick them.
Decide whose credential each call carries
This is the question that separates an internal tool from a product feature. If the MCP server calls your API with one shared token, your API cannot tell customers apart and every user can read every account. For anything customer-facing, each user connects with their own API key or OAuth account and every call runs as them. AnnounceKit went with customer API keys, validated on connect and revocable. The trade-offs are in per-customer identity for MCP.
Build it, generate it, or host it
If you have a dozen endpoints and the only user is your own team, write the server by hand with the official SDK. You will understand every line and it will take a day.
If you have a large API and want code you own, generators like Stainless and Speakeasy's Gram produce an MCP server from an OpenAPI document. You still run it, and you still make the curation and auth decisions above.
- If you want a hosted URL your customers connect to from Claude, Cursor or ChatGPT, with per-customer auth and usage numbers, that is what Command+K's Build MCP does. The steps are in the conversion docs.
- Whichever you choose, test with a production client before you announce anything. The three client bugs we hit all passed a spec review.
The checklist I actually use
FAQ
- How do I turn my REST or GraphQL API into an MCP server?
- Read the machine-readable schema you already have (an OpenAPI document for REST, introspection for GraphQL), emit one MCP tool per operation with a JSON Schema for its inputs, and execute each tool call by rebuilding the original HTTP request with the caller's credential. The mapping is mechanical. The work that decides whether agents use it well is choosing which operations to expose, keeping responses small, and writing descriptions for a model instead of a developer.
- Do I need an OpenAPI spec to convert a REST API to MCP?
- You need some machine-readable description of the endpoints. Most frameworks can emit one: FastAPI serves /openapi.json by default, and NestJS, Spring and ASP.NET have OpenAPI generators. Without a spec you are writing each tool by hand, which is fine for ten endpoints and painful for two hundred.
- Why do GraphQL MCP tools fail on real data when they pass testing?
- Usually the selection set. A generator that selects every nested object will include non-nullable singular relations, and the first record where that relation is actually null makes GraphQL null out the parent and return an errors array. Keep selection shallow, skip non-null singular object fields, and let the agent ask for detail with a second call.
- Should every API endpoint become an MCP tool?
- No. Our AnnounceKit scan produced 220 tools and we shipped 84, all reads. Agents pick tools from a list, and a long list of near-duplicates lowers accuracy and raises the token cost of every turn. Start with the reads your users actually ask about and add writes one at a time.