Why your React app needs an MCP server
You asked Cursor to "create a project from our dashboard template" and watched it invent a POST route that never existed. Same thing happened when someone tried to pull usage metrics through Claude Desktop. Your React app already knows how to fetch data, update records, and render state. What it lacks is a contract that AI agents can discover and call without guessing.
The Model Context Protocol (MCP) gives agents a typed catalog of capabilities: tools for actions, resources for read-only context, and prompts for repeatable workflows. Building an MCP server in TypeScript next to your React codebase means you define those capabilities once, close to your domain logic, and every compatible client can use them.
By the end of this guide, you'll have a standalone Node.js MCP server, three concrete tools backed by your backend, and a React hook that calls them through a safe bridge. No frontend rewrite required.
What MCP actually is (in one minute)
MCP is a standard way for AI hosts—Cursor, Claude Desktop, custom assistants—to talk to external systems. Think of it as USB-C for agent integrations: one protocol, many clients, predictable behavior.
When you register a tool named create_project with a JSON Schema for its inputs, the host shows that schema to the model before any call happens. The model proposes structured arguments. The host validates them. Your server executes. That validation step is what separates a toy demo from something you'd trust near production data.
Resources fill a different role. A tool might trigger a deployment. A resource might expose project://{id}/config as read-only JSON the model reads for grounding. Prompts bundle instructions plus default arguments for tasks like onboarding a tenant. These three primitives map cleanly onto how product teams already think about what their app can do.
- Discovery: clients list tools at session start without hard-coded route tables.
- Validation: JSON Schema rejects bad calls before they hit your database.
- Portability: the same server works in Cursor, Claude Desktop, and your React bridge.
- Observability: each tool call is a discrete event you can log and rate-limit.
How the pieces fit together
Keep the MCP server out of the browser bundle. It runs as a Node.js process with access to secrets, connection pools, and internal APIs. Your React app reaches it through stdio when an IDE spawns the process locally, or through Server-Sent Events over HTTP when you host a development bridge.
Picture three layers. The inner layer is domain code: functions that create projects, fetch metrics, or rotate keys. The middle layer is the MCP adapter: tool definitions, schema mapping, error normalization. The outer layer is transport: stdio for desktop agents, or an Express route that streams SSE for in-app assistants.
React components should not import MCP SDK types in production UI. A small hook calls your bridge API, which forwards to the MCP server. That split keeps bundles small and stops service credentials from leaking into client JavaScript.
| Layer | Job | Runs where |
|---|---|---|
| Domain services | Business logic, DB access | Node.js |
| MCP adapter | Tool schemas, handlers | Node.js |
| Transport | stdio or SSE | Node.js |
| React bridge | UI session, call proxy | Browser or BFF |
Step 1: Create the TypeScript MCP server project
Start with a dedicated package in your monorepo, something like packages/mcp-server. Use TypeScript 5.x with moduleResolution: NodeNext if you're on modern Node. Install the official SDK and Zod for runtime validation:
mkdir packages/mcp-server && cd packages/mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsxCreate src/index.ts as your entry point. The SDK gives you McpServer for registration and StdioServerTransport for local development:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new McpServer({
name: "xplodivity-app",
version: "1.0.0",
});
// tools registered in step 2
const transport = new StdioServerTransport();
await server.connect(transport);Add "start": "tsx src/index.ts" to package.json scripts. Launch it from the terminal and confirm the process stays alive. Stdio servers must never write debug output to stdout—log to stderr only, or you'll corrupt the JSON-RPC stream.
Pin SDK versions. MCP moves fast. Lock versions in CI and upgrade after reading changelog notes for schema or transport changes.
Step 2: Register tools with typed schemas
Tools are the heart of your MCP server TypeScript setup. Each one needs a name, a description written for the model, an input schema, and an async handler. Zod works well here because you define inputs in TypeScript and convert to JSON Schema for the protocol.
Here's a realistic example—a tool that creates a project in your product backend:
import { z } from "zod";
import { createProjectInApi } from "./services/projects.js";
const CreateProjectInput = z.object({
name: z.string().min(1).max(80),
templateSlug: z.string().optional(),
});
server.tool(
"create_project",
"Create a new project in the workspace. Returns id and url.",
CreateProjectInput.shape,
async (input) => {
const project = await createProjectInApi(input);
return {
content: [
{
type: "text",
text: JSON.stringify({ id: project.id, url: project.url }, null, 2),
},
],
};
}
);Write descriptions for the model, not for marketing copy. Say what the tool does, what it returns, and when not to use it. Vague descriptions cause duplicate calls when multiple tools overlap.
Round out your surface with two more tools: get_usage_metrics accepting a date range, and list_templates with no required args. Keep handlers thin. Run authorization inside the service layer using the same rules your REST API uses.
Handle errors in a way agents understand
When createProjectInApi throws, catch domain errors and return structured text the model can act on. Include an error code, a short message, and a suggested next step. Log stack traces server-side only—never ship them in tool responses.
server.tool(
"create_project",
"Create a new project in the workspace.",
CreateProjectInput.shape,
async (input) => {
try {
const project = await createProjectInApi(input);
return {
content: [{ type: "text", text: JSON.stringify(project) }],
};
} catch (err) {
const message =
err instanceof Error ? err.message : "Unknown error";
return {
content: [
{
type: "text",
text: JSON.stringify({
error: "CREATE_FAILED",
message,
hint: "Check templateSlug against list_templates output.",
}),
},
],
isError: true,
};
}
}
);Step 3: Expose read-only resources
Tools mutate or compute. Resources expose stable read-only documents. Register a resource template so clients fetch project configuration without calling a tool that looks like a GET dressed up as an action.
server.resource(
"project-config",
"project://{projectId}/config",
async (uri, { projectId }) => {
const config = await fetchProjectConfig(projectId);
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(config),
},
],
};
}
);Resources cut down on hallucination. When the model needs feature flags or integration settings, it reads a resource instead of making up values. Keep payloads focused. Large JSON blobs belong behind pagination or a filtered tool.
You can also register a static resource for workspace-wide defaults—things like supported regions or plan limits. Agents fetch it once at the start of a session and stay grounded for the rest of the conversation.
Step 4: Connect MCP to your React frontend
For in-app assistants during development, run the MCP server as a child process behind a small Express BFF route. The React side exposes a hook that posts tool requests to /api/mcp/call and shows results in the UI.
// hooks/useMcpTool.ts
import { useState, useCallback } from "react";
type ToolResult = { ok: boolean; data?: unknown; error?: string };
export function useMcpTool(toolName: string) {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<ToolResult | null>(null);
const invoke = useCallback(
async (args: Record<string, unknown>) => {
setLoading(true);
try {
const res = await fetch("/api/mcp/call", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tool: toolName, arguments: args }),
});
const data = await res.json();
setResult({ ok: res.ok, data });
} catch (e) {
setResult({ ok: false, error: String(e) });
} finally {
setLoading(false);
}
},
[toolName]
);
return { invoke, loading, result };
}In a settings panel, wire a button to invoke({ name: "Q2 Launch" }) for create_project. Show loading state and render the returned project URL. The pattern mirrors any mutation hook your team already uses.
Don't embed LLM API keys in React. The bridge route attaches user identity, calls your MCP layer, and optionally talks to an LLM only on the server. The MCP server executes tools. The LLM decides which tools to call. Mixing those roles in the browser creates security headaches.
The Express bridge route
Your BFF route validates the session, checks that the requested tool is allowed for that user, and forwards the call to the MCP handler layer:
// server/routes/mcp.ts
import { Router } from "express";
import { invokeTool } from "../mcp/bridge.js";
const router = Router();
router.post("/call", async (req, res) => {
const { tool, arguments: args } = req.body;
const userId = req.session.userId;
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
try {
const result = await invokeTool(tool, args, { userId });
res.json(result);
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
export default router;The invokeTool function imports the same handler functions your stdio server uses. One implementation, two transports. That's the whole point of keeping domain logic separate from the MCP adapter.
Step 5: Wire it into Cursor or Claude Desktop
Desktop clients spawn your server over stdio. Add a config entry pointing at the compiled entry file and required environment variables:
{
"mcpServers": {
"xplodivity": {
"command": "node",
"args": ["./packages/mcp-server/dist/index.js"],
"env": {
"API_BASE_URL": "https://api.example.com",
"SERVICE_TOKEN": "${XPL_SERVICE_TOKEN}"
}
}
}
}Build the server first with tsc or tsup. Restart the host after config changes. Open the MCP panel in Cursor and confirm all three tools appear with schemas attached.
Run a smoke test: ask the agent to list templates, then create a project using one of them. If the model picks the wrong tool, tighten descriptions before adding more tools. More tools isn't always better—overlap confuses selection.
Check stderr when calls fail silently. Stdio transport gives you no HTTP status codes. Failures show up as protocol errors or empty content arrays. Structured logging with a correlation ID per request makes cross-layer debugging much easier.
Security rules worth enforcing early
An MCP server is a remote execution surface. Treat it with the same skepticism as a public API, even when it only speaks stdio on a developer laptop.
- Scope tokens per environment. Don't point a local MCP config at production without an explicit approval workflow.
- Allow-list tools per role if your bridge serves multiple user types.
- Rate-limit handlers that trigger expensive side effects.
- Redact secrets from resource payloads and tool responses.
- Audit log every invocation with actor, arguments hash, and outcome.
Authorization belongs in domain services, not scattered across handlers. When your REST layer already calls assertUserCanCreateProject(userId), reuse that inside create_project. Duplicating rules guarantees they'll drift apart.
Validate argument sizes at the schema level. A model might send a 10,000-character string where you expected a slug. Zod's .max() catches that before it reaches your database layer.
Testing and debugging tool calls
When a tool returns unexpected output, walk backward through four checkpoints: schema validation, handler input, downstream API response, and serialization back to MCP content blocks. Most bugs sit at the boundaries. A Zod schema that coerces empty strings to undefined can silently drop fields your API requires.
Add a temporary debug_echo tool that returns its arguments unchanged. Use it to verify the host passes structured data correctly before you blame business logic. Gate it behind an environment flag before shipping.
For React integration bugs, compare the network payload your bridge receives with what stdio clients send. Field names must match the registered schema exactly. Agents are sensitive to camelCase versus snake_case mismatches because JSON Schema property names are literal.
Unit test handlers by importing them without starting transport. Integration tests can spawn the server with stdio pipes or call handler functions with fixture inputs. Snapshot the JSON content blocks so schema drift gets caught in CI.
MCP vs REST: a quick comparison
| Concern | MCP server | REST API |
|---|---|---|
| Agent discovery | Built-in tool listing | Needs OpenAPI plus custom glue |
| Human-driven UI | Indirect via bridge | Native fit for React fetching |
| Validation | Schema at protocol level | Per-route middleware |
| Coupling to AI hosts | Low; standard protocol | High; bespoke agent prompts |
| Operational maturity | Newer ecosystem | Decades of tooling |
You don't pick one and abandon the other. Keep REST for your React app and mobile clients. Add MCP as the agent-facing facade that internally calls the same service modules. That dual entry point avoids rewriting proven HTTP routes while giving Cursor and Claude a clean contract.
If your team already maintains an OpenAPI spec, treat MCP tools as a curated subset of those operations—the ones agents actually need—not a one-to-one mirror of every endpoint.
Summary
Building an MCP server in TypeScript for your React app comes down to clean boundaries. Domain logic stays central. The MCP adapter declares tools and resources with strict schemas. The React layer proxies calls through a bridge instead of bundling the protocol into the client.
Follow the five steps: scaffold the server, define tools with Zod, add read-only resources, wire a React hook to a BFF route, and register stdio with your IDE. Start with three focused tools, reuse authorization from your existing API layer, and invest in logging early. Agents are only as trustworthy as the contracts you give them.
FAQ
Does the MCP server run in the browser?
No. The MCP server is a Node.js process with secret access. The browser calls a backend bridge that forwards tool requests. Bundling the server into client JavaScript would expose credentials and break stdio transports that desktop agents rely on.
Can I share one server across multiple apps?
Yes, if they share the same backend domain. Register tools that are app-agnostic or accept a workspaceId argument. Use separate MCP server instances when security boundaries differ—internal admin versus customer-facing apps, for example.
How do I update tools without breaking agents?
Prefer additive changes: new optional fields, new tools alongside deprecated ones. If you must rename, ship both names for one release cycle and log deprecation warnings. Document breaking changes in the server version field and in tool descriptions.
Should handlers call my REST API or shared services?
Call shared service modules directly when the MCP server lives in the same codebase. An HTTP hop to your own REST API only makes sense when the server must stay decoupled. Direct service calls reduce latency and duplicate auth logic.
Which transport works best in production?
Stdio suits IDE-spawned local servers. For deployed assistants, use SSE or streamable HTTP behind authenticated BFF routes. Never expose an unauthenticated MCP endpoint to the public internet.
How do I test MCP tools in CI?
Unit test handlers by importing them without starting transport. Integration tests spawn the server with stdio pipes or call handler functions with fixture inputs. Snapshot the JSON content blocks returned so schema drift is caught early.