DEV Community

schrepa
schrepa

Posted on

Serving MCP and REST from the same TypeScript process

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:

  1. Raw MCP SDK — build an MCP server from scratch
  2. Wrap your API by hand — keep your API, add an MCP layer manually
  3. 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) }],
    };
  }
);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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_docs over MCP, but curl can't call GET /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);
Enter fullscreen mode Exit fullscreen mode

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) }] };
  }
);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Run it:

npx @schrepa/graft serve -e app.ts
Enter fullscreen mode Exit fullscreen mode

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", ... }, ...]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode
npx @schrepa/graft serve
Enter fullscreen mode Exit fullscreen mode

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),
});
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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),
});
Enter fullscreen mode Exit fullscreen mode

Developer experience

Scaffold a new project

npx @schrepa/create-graft-app my-api
cd my-api && npm run dev
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }),
});
Enter fullscreen mode Exit fullscreen mode
npx @schrepa/graft test -e app.ts
# ✓ echo > hello (3ms)
Enter fullscreen mode Exit fullscreen mode

Visual testing with Studio

npx @schrepa/graft studio -e app.ts
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Apache-2.0. TypeScript. Works with Node, Bun, Deno, and Cloudflare Workers.


Top comments (0)