Back to Research
Build a Custom MCP Server in TypeScript

Build a Custom MCP Server in TypeScript

A Step-by-Step Guide to Building and Deploying Your First Custom MCP Server

E
Exo
March 26, 20265 min read

TL;DR

  • What you will learn: How to build a custom MCP server in TypeScript from scratch, including tool registration with Zod schemas, resource exposure, error handling, and transport configuration.

  • Why it matters: TypeScript is the most popular language for MCP server development. A working template with proper patterns saves weeks of trial and error.

  • Exo Edge: We build production MCP servers in TypeScript for technical teams. These patterns come from real deployments, not documentation examples.


From Zero to a Working MCP Server in Under an Hour

How do you build a custom MCP server? This guide walks through the entire process in TypeScript, from project setup to a working server with tools, resources, and proper error handling. By the end, you will have a production-ready MCP server template you can adapt for any integration.

TypeScript is the most popular language for MCP server development. The official @modelcontextprotocol/sdk package provides type-safe abstractions over the protocol, and the Node.js ecosystem gives you access to libraries for virtually any external service you might want to wrap.


Project Setup and Dependencies

Start with a new Node.js project. You need two packages: the MCP SDK and Zod for runtime schema validation (the SDK uses Zod internally for type-safe tool input parsing).

mkdir my-mcp-server && cd my-mcp-server npm init -y npm install @modelcontextprotocol/sdk zod npm install -D typescript @types/node npx tsc --init

In your tsconfig.json, set the target to ES2022 and module to Node16. This ensures compatibility with the SDK's async patterns and ESM support.


Creating the Server Instance

The McpServer class is the entry point. It handles protocol negotiation, capability registration, and message routing. Create a server with a name, version, and optional configuration.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod";

const server = new McpServer({ name: "my-server", version: "1.0.0", });

This creates a server instance but does not start it yet. First, register your capabilities.


Registering Tools with Zod Schemas

Tools are registered using the server.tool() method. The SDK uses Zod schemas for type-safe input validation, which means your tool handlers receive fully validated, typed arguments.

Here is a tool that looks up a user by email:

server.tool( "lookup-user", "Looks up a user by email address. Returns the user's name, role, and account status. Use this when you need to find information about a specific user.", { email: z.string().email().describe("The user's email address") }, async ({ email }) => { const user = await db.users.findByEmail(email); if (!user) { return { content: [{ type: "text", text: No user found with email: ${email} }], isError: true, }; } return { content: [{ type: "text", text: JSON.stringify({ name: , role: user.role, status: user.status }), }], }; } );

Several things to note here. The description is detailed and tells the AI model exactly when and how to use this tool. The Zod schema validates that the email argument is a valid email format before the handler runs. The handler returns content blocks (text in this case, but images and embedded resources are also supported). The isError flag signals a tool-level error that the model should communicate to the user.


Exposing Resources

Resources provide read-only context to the AI model. Register them with server.resource(), providing a URI, name, and a handler that returns the content.

server.resource( "app-config", "config://app/settings", "Current application configuration including feature flags and environment settings", async () => ({ contents: [{ uri: "config://app/settings", mimeType: "application/json", text: JSON.stringify(await loadAppConfig()), }], }) );

For dynamic resources that accept parameters, use URI templates:

server.resource( "user-profile", new ResourceTemplate("users://{userId}/profile", { list: undefined }), "User profile data for a specific user", async (uri, { userId }) => ({ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(await db.users.getProfile(userId)), }], }) );

The host application decides which resources to load and when. Resources are typically loaded at session start to give the model background context, but they can also be loaded on demand through the host's UI.


Error Handling and Validation

Production MCP servers need robust error handling at two levels.

Input validation happens automatically through Zod schemas. If the AI model sends arguments that do not match the schema, the SDK returns a protocol-level error before your handler runs. Use Zod's built-in validators (.email(), .url(), .min(), .max()) and add .describe() annotations to help the model understand constraints.

Runtime errors in your handler should be caught and returned as tool-level errors (isError: true in the response), not thrown as exceptions. Unhandled exceptions crash the server or produce protocol errors that confuse the model. Wrap your handler logic in try/catch blocks and return meaningful error messages.

server.tool( "execute-query", "Executes a read-only SQL query against the database. Only SELECT statements are allowed.", { query: z.string().describe("SQL SELECT query to execute") }, async ({ query }) => { if (!query.trim().toUpperCase().startsWith("SELECT")) { return { content: [{ type: "text", text: "Only SELECT queries are allowed." }], isError: true, }; } try { const results = await db.query(query); return { content: [{ type: "text", text: JSON.stringify(results.rows, null, 2) }], }; } catch (err) { return { content: [{ type: "text", text: Query failed: ${err.message} }], isError: true, }; } } );


Starting the Server and Connecting a Transport

With tools and resources registered, start the server by connecting it to a transport. For local development, use the stdio transport:

const transport = new StdioServerTransport(); await server.connect(transport); console.error("MCP server running on stdio");

Note the console.error for logging. As covered in the architecture post, stdout is reserved for protocol messages in stdio transport. All logging must go to stderr.

To test your server, add it to Claude Desktop's configuration (claude_desktop_config.json):

{ "mcpServers": { "my-server": { "command": "node", "args": ["dist/index.js"] } } }

Restart Claude Desktop, and your tools and resources will appear in the interface. You can also use the MCP Inspector (npx @modelcontextprotocol/inspector) for debugging without a full AI client.


Moving to Production: HTTP Transport

For production deployments where the server needs to run remotely and serve multiple clients, switch to the Streamable HTTP transport. The SDK provides SSEServerTransport for this:

import express from "express"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";

const app = express(); const transports = new Map();

app.get("/sse", async (req, res) => { const transport = new SSEServerTransport("/messages", res); transports.set(transport.sessionId, transport); await server.connect(transport); });

("/messages", async (req, res) => { const sessionId = req.query.sessionId as string; const transport = transports.get(sessionId); await transport.handlePostMessage(req, res); });

app.listen(3000);

This gives you a remotely accessible MCP server that multiple clients can connect to. Add TLS, authentication middleware, and rate limiting for production use.


Best Practices for Custom MCP Server Development

  • Write detailed tool descriptions. The AI model is your user. It reads descriptions to decide what to call. Include what the tool does, when to use it, what inputs it expects, and what it returns.

  • Use .describe() on every Zod field. These descriptions become part of the JSON Schema that the model sees.

  • Return structured data as JSON strings. The model can parse JSON and extract specific fields.

  • Keep servers focused. A server with 5-10 well-defined tools is easier for the model to use than one with 50 tools. Split large integrations into multiple focused servers.

  • Test with the MCP Inspector before connecting a real AI client. This lets you verify tool schemas, test handlers, and debug protocol issues in isolation.

  • Handle graceful shutdown. Listen for SIGINT and SIGTERM, close database connections, and clean up resources. The server.close() method handles protocol-level shutdown.


Conclusion + Action

You now have the complete pattern for building a custom MCP server in TypeScript: project setup, server creation, tool registration with Zod schemas, resource exposure, error handling, and transport configuration for both local and production use.

Start with the stdio transport and a single tool. Get it working with Claude Desktop or the MCP Inspector. Then add resources, improve error handling, and switch to HTTP transport when you need remote access. The patterns in this guide scale from a single-tool prototype to a multi-capability production server.


Exo builds custom MCP servers for technical teams who need production-grade AI integrations. From internal tooling to blockchain state access to enterprise data systems, we handle the full lifecycle of MCP server development. Ready to build? Reach out at founders@exotechnologies.xyz