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 (0)