MCP is becoming a standard way agents discover and call tools. Claude Desktop, Cursor, Windsurf, and others all speak MCP. But the ecosystem has a split-brain problem: your HTTP API serves humans and scripts, and MCP serves agents but they call the same underlying logic.
This article compares three ways to bridge that gap:
- Raw MCP SDK — build an MCP server from scratch
- Wrap your API by hand — keep your API, add an MCP layer manually
- Graft — define once, serve both transports from one process
I'll use the same example throughout: a simple docs API with search_docs and get_doc tools.
Approach 1: Raw MCP SDK
The @modelcontextprotocol/sdk package gives you full control over the MCP protocol. Here's a minimal server:
// mcp-server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({
name: "docs-api",
version: "1.0.0",
});
server.tool(
"search_docs",
{ q: z.string(), limit: z.number().optional() },
async ({ q, limit }) => {
const results = await searchIndex(q, { limit: limit ?? 10 });
return {
content: [{ type: "text", text: JSON.stringify(results) }],
};
}
);
server.tool(
"get_doc",
{ id: z.string() },
async ({ id }) => {
const doc = await getDoc(id);
if (!doc) {
return {
content: [{ type: "text", text: "Document not found" }],
isError: true,
};
}
return {
content: [{ type: "text", text: JSON.stringify(doc) }],
};
}
);
Then you need a transport. For Claude Desktop, stdio:
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const transport = new StdioServerTransport();
await server.connect(transport);
For HTTP clients, Streamable HTTP:
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const app = express();
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000);
What you get
- Full MCP support (tools, resources, prompts)
- Complete control over the protocol
What you don't get
- No HTTP endpoints. Agents can call
search_docsover MCP, butcurlcan't callGET /search-docs?q=deploy. You need a separate Express/Hono/Fastify server for that. - No OpenAPI spec, no docs UI, no agent discovery document.
- Every tool handler must manually wrap results in
{ content: [{ type: "text", text: JSON.stringify(...) }] }. - Auth is entirely DIY.
- You now have two servers to build and maintain if you also need an HTTP API.
Approach 2: Wrap your existing API by hand
Say you already have an Express API:
// api.ts
import express from "express";
const app = express();
app.get("/search-docs", async (req, res) => {
const results = await searchIndex(req.query.q, {
limit: Number(req.query.limit) || 10,
});
res.json(results);
});
app.get("/docs/:id", async (req, res) => {
const doc = await getDoc(req.params.id);
if (!doc) return res.status(404).json({ error: "Not found" });
res.json(doc);
});
app.listen(3000);
Now you want agents to use it. You add an MCP server that calls your own API:
// mcp-wrapper.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({ name: "docs-api", version: "1.0.0" });
server.tool(
"search_docs",
{ q: z.string(), limit: z.number().optional() },
async ({ q, limit }) => {
// Call our own API... from our own server
const res = await fetch(
`http://localhost:3000/search-docs?q=${encodeURIComponent(q)}&limit=${limit ?? 10}`
);
const data = await res.json();
return { content: [{ type: "text", text: JSON.stringify(data) }] };
}
);
server.tool(
"get_doc",
{ id: z.string() },
async ({ id }) => {
const res = await fetch(`http://localhost:3000/docs/${encodeURIComponent(id)}`);
if (!res.ok) {
return { content: [{ type: "text", text: "Not found" }], isError: true };
}
const data = await res.json();
return { content: [{ type: "text", text: JSON.stringify(data) }] };
}
);
What you get
- Your existing API stays untouched
- MCP server can run as a sidecar
What you don't get
- Two processes to run and deploy (or an awkward monolith with two routers)
- Duplicated logic: param validation defined once in Express, again in MCP tool schemas
- Duplicated auth: your API checks tokens, your MCP server needs to forward them somehow
- Self-calling: the MCP server makes HTTP requests to itself, adding latency and a failure mode
- No shared middleware: logging, rate limiting, error formatting all need separate implementations
- Double the maintenance: add a param to the API? Update the MCP wrapper too. Rename an endpoint? Two changes.
This is where most teams are today. It works, but it doesn't scale.
Approach 3: Graft — define once, serve both
// app.ts
import { createApp, z } from "@schrepa/graft";
const app = createApp({ name: "docs-api" });
app.tool("search_docs", {
description: "Search documentation by keyword",
params: z.object({
q: z.string().describe("Search query"),
limit: z.coerce.number().optional().describe("Max results"),
}),
handler: ({ q, limit }) => searchIndex(q, { limit: limit ?? 10 }),
});
app.tool("get_doc", {
description: "Get a document by ID",
params: z.object({
id: z.string().describe("Document ID"),
}),
handler: async ({ id }) => {
const doc = await getDoc(id);
if (!doc) throw new ToolError("Document not found", 404);
return doc;
},
});
export default app;
Run it:
npx @schrepa/graft serve -e app.ts
That one process now serves:
| Endpoint | What it does |
|---|---|
GET /search-docs?q=deploy |
HTTP endpoint — curl, browsers, any client |
GET /get-doc?id=abc |
HTTP endpoint for get_doc |
POST /mcp |
MCP endpoint — Claude, Cursor, etc. |
/.well-known/agent.json |
Agent discovery document |
/openapi.json |
Auto-generated OpenAPI 3.1 spec |
/docs |
Interactive API reference (Scalar) |
/llms.txt |
Compact tool listing for LLMs |
/health |
Health check |
The same tool, two ways
As HTTP:
curl "http://localhost:3000/search-docs?q=deploy&limit=5"
# → [{ "title": "Deploy Guide", ... }, ...]
As MCP (via Claude Desktop or any MCP client):
tools/call: search_docs({ q: "deploy", limit: 5 })
# → same result, auto-wrapped in MCP content format
Same handler. Same auth check. Same middleware. Same validation. Zero duplication.
What about existing APIs?
You don't need to rewrite anything. Graft has a proxy mode:
npx @schrepa/graft serve \
--openapi ./openapi.yaml \
--target http://localhost:8000
Point it at any OpenAPI spec (or write a simple YAML config), and your existing API becomes an MCP server. No code changes. Any language. Any framework.
# graft.proxy.yaml
target: http://localhost:8000
tools:
- method: GET
path: /search
name: search_docs
description: Search documentation
parameters:
type: object
properties:
q: { type: string, description: Search query }
npx @schrepa/graft serve
Your Python/Go/Ruby/Java API now speaks MCP.
Side-by-side comparison
| Raw MCP SDK | Hand-wrapped | Graft | |
|---|---|---|---|
| Servers to run | 2 (API + MCP) | 2 (API + MCP sidecar) | 1 |
| Tool definitions | MCP only | Duplicated | Once |
| HTTP endpoints | DIY | Existing API | Auto-generated |
| MCP support | Full | Full | Full |
| Auth | DIY per transport | Forwarded/duplicated | Unified pipeline |
| Middleware | DIY | Separate per server | Shared app.use()
|
| OpenAPI spec | No | Maybe (from your API) | Auto-generated |
| Agent discovery | No | No | Auto-served |
| Interactive docs | No | Maybe | Auto-served (Scalar) |
| Proxy existing APIs | No | That's what this is |
--openapi flag |
| Claude Desktop setup | Manual config | Manual config | graft install |
Auth, middleware, and the stuff that matters in production
Unified authentication
const app = createApp({
name: "docs-api",
authenticate: (request) => {
const token = request.headers.get("authorization");
if (!token) throw new AuthError("Unauthorized", 401);
return { subject: verifyToken(token).id, roles: ["user"] };
},
});
// This tool requires auth — same check for HTTP and MCP
app.tool("create_doc", {
auth: true,
description: "Create a new document",
params: z.object({ title: z.string(), body: z.string() }),
handler: ({ title, body }, ctx) => {
// ctx.auth.subject is available in both transports
return createDoc({ title, body, author: ctx.auth.subject });
},
});
// This tool is public — no auth check
app.tool("search_docs", {
description: "Search docs",
params: z.object({ q: z.string() }),
handler: ({ q }) => searchIndex(q),
});
Composable middleware
// Runs for every tool call, both HTTP and MCP
app.use(async (ctx, next) => {
const start = Date.now();
const result = await next();
console.log(`${ctx.meta.toolName} took ${Date.now() - start}ms`);
return result;
});
Selective exposure
Not every tool should be available everywhere:
// MCP only — internal agent tool, no HTTP endpoint
app.tool("summarize_thread", {
expose: "mcp",
description: "Summarize a conversation thread",
handler: ({ threadId }) => summarize(threadId),
});
// HTTP only — webhook receiver, hidden from MCP tools/list
app.tool("stripe_webhook", {
expose: "http",
http: { method: "POST", path: "/webhooks/stripe" },
handler: (payload) => handleWebhook(payload),
});
Developer experience
Scaffold a new project
npx @schrepa/create-graft-app my-api
cd my-api && npm run dev
Comes with example tools, dev server, and all the scripts pre-configured.
Generate tool scaffolds
npx @schrepa/graft add-tool search_docs
# Creates src/tools/search-docs.ts with Zod schema + handler template
Smoke test your tools
Define examples inline:
app.tool("echo", {
description: "Echo a message",
params: z.object({ message: z.string() }),
examples: [
{ name: "hello", args: { message: "hello" }, result: { message: "hello" } },
],
handler: ({ message }) => ({ message }),
});
npx @schrepa/graft test -e app.ts
# ✓ echo > hello (3ms)
Visual testing with Studio
npx @schrepa/graft studio -e app.ts
Opens an interactive UI to browse tools, see schemas, and test calls.
One-command Claude Desktop install
npx @schrepa/graft install -e app.ts --stdio
# ✓ Added "docs-api" to Claude Desktop config
When to use what
Use the raw MCP SDK if you're building something MCP-specific that doesn't need HTTP endpoints — like a filesystem tool or a database inspector that only agents will ever call.
Wrap your API by hand if you can't add dependencies to your existing API server and just need a quick bridge. Accept the maintenance cost.
Use Graft if:
- You're building a new API that agents and humans both need
- You have an existing API you want to expose to agents without rewriting it (proxy mode)
- You want auto-generated docs, discovery, and OpenAPI for free
- You don't want to maintain two parallel implementations of the same logic
Getting started
# New project
npx @schrepa/create-graft-app my-api
# Or add to existing
npm install @schrepa/graft
# Or proxy an existing API
npx @schrepa/graft serve --openapi ./spec.yaml --target http://localhost:8000
- GitHub: github.com/schrepa/graft
- npm: @schrepa/graft
Apache-2.0. TypeScript. Works with Node, Bun, Deno, and Cloudflare Workers.
Top comments (1)
The proxy mode for wrapping existing APIs without rewriting them is the killer feature. That's the biggest MCP adoption blocker I've seen — nobody wants to maintain two implementations of the same logic.