How to Build an MCP Server with TypeScript
Model Context Protocol (MCP) gives AI applications a standardized way to discover and interact with external capabilities.
Instead of writing a custom integration for every AI client, you can build an MCP server that exposes tools, resources, and prompts through a common protocol.
For TypeScript developers, the architecture is familiar:
AI Application
↓
MCP Client
↓
MCP Transport
↓
Your TypeScript MCP Server
↓
APIs / Databases / ServicesIn this guide, we'll build a small MCP server in TypeScript, expose a tool, run it locally over stdio, and look at what needs to change for a production server.
Version note: The official TypeScript SDK has moved to split server/client packages in its current generation. MCP evolves quickly, so check the SDK documentation when starting a new project rather than copying older examples built around deprecated transports or package layouts.
What Does an MCP Server Actually Do?
An MCP server sits between an AI application and a capability you want the AI to access.
Suppose your backend has an internal user API. You could expose a tool called get_user. The MCP server describes the tool, defines its input schema, executes the backend operation when requested, and returns the result in MCP's expected format.
User Request
↓
AI Application
↓
Discovers get_user
↓
MCP Server
↓
Validate Input
↓
Internal Service
↓
ResultThe LLM does not need to know your internal implementation. It only needs the capability exposed through the protocol.

Tools, Resources, and Prompts
Tools
Tools perform operations such as:
get_weather
create_ticket
search_customers
run_reportA tool normally has a name, description, input schema, and handler.
Resources
Resources expose information that a client can read, such as documents, configuration, or application data.
Prompts
Prompts expose reusable prompt templates that clients can discover.
For most backend integrations, tools are the easiest place to start.
Create the TypeScript Project
mkdir typescript-mcp-server
cd typescript-mcp-server
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D typescript tsx @types/nodeCreate:
typescript-mcp-server/
├── src/
│ └── index.ts
├── package.json
└── tsconfig.jsonA basic tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}Create the MCP Server
Inside src/index.ts:
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
function createServer() {
const server = new McpServer({
name: "devcollar-example",
version: "1.0.0",
});
return server;
}
void serveStdio(createServer);At this point, the server exists, but it does not expose anything useful.
Register Your First MCP Tool
Let's add a user lookup tool:
server.registerTool(
"get_user",
{
description: "Get a user by their numeric ID",
inputSchema: {
userId: z.number().int().positive(),
},
},
async ({ userId }) => {
const user = {
id: userId,
name: "Alex",
role: "Developer",
};
return {
content: [
{
type: "text",
text: JSON.stringify(user),
},
],
};
}
);The structure is:
get_user
│
├── Description
├── Input Schema
└── HandlerThe description helps an AI client understand when the tool is useful. The schema validates arguments, while the handler contains the backend operation.
Why Tool Descriptions Matter
Compare:
"Gets stuff"with:
"Get a user by their numeric ID"The second gives the model much better information.
Tool names and descriptions should clearly communicate what the tool does and when it should be used. Keep them concise but unambiguous.
Validate Inputs With Schemas
Never assume arguments are valid because an LLM generated them.
inputSchema: {
userId: z.number().int().positive(),
}For a support tool:
inputSchema: {
title: z.string().min(3),
description: z.string().min(10),
priority: z.enum(["low", "medium", "high"]),
}Treat MCP inputs like inputs to any backend endpoint: validate before touching your systems.
Connect the Tool to a Real Service
A real implementation might call an internal API:
async function getUser(userId: number) {
const response = await fetch(
`https://api.example.com/users/${userId}`
);
if (!response.ok) {
throw new Error(`User lookup failed: ${response.status}`);
}
return response.json();
}Then use it from the handler:
async ({ userId }) => {
const user = await getUser(userId);
return {
content: [
{
type: "text",
text: JSON.stringify(user),
},
],
};
}Now the architecture is:
AI Client
↓
get_user
↓
MCP Server
↓
Internal API
↓
User DataRun the Server Over stdio
Run:
npx tsx src/index.tsThe process waits for an MCP client to communicate over stdin and stdout.
One important rule:
Do not write normal logs to stdout when using stdio.
stdout is the protocol channel. A console.log() can corrupt JSON-RPC communication.
Use stderr:
console.error("MCP server started");Test With MCP Inspector
Run:
npx @modelcontextprotocol/inspector npx tsx src/index.tsThe Inspector lets you view exposed capabilities and call tools manually.
For get_user, test:
{
"userId": 42
}Testing the server independently helps separate MCP implementation problems from LLM or agent behavior.
stdio vs Streamable HTTP
stdio
Use stdio when an MCP host launches your server as a local process:
MCP Host
↓
Starts Process
↓
stdin / stdout
↓
MCP ServerIt is useful for local developer tools and desktop integrations.
Streamable HTTP
For remotely hosted MCP servers, use Streamable HTTP:
AI Application
↓
Network
↓
HTTP MCP Endpoint
↓
MCP Server
↓
Backend ServicesOlder tutorials may show HTTP+SSE. That transport belongs to an older MCP protocol generation; new implementations should use the current Streamable HTTP approach unless older-client compatibility is required.
Structure a Larger MCP Project
Once you have several tools, avoid putting everything in index.ts.
src/
├── index.ts
├── server.ts
├── tools/
│ ├── get-user.ts
│ ├── search-users.ts
│ └── create-ticket.ts
├── services/
│ ├── users.ts
│ └── tickets.ts
└── schemas/
└── common.tsKeep MCP handlers thin:
MCP Tool
↓
Service Function
↓
API / DatabaseYour service layer should own the actual business logic.
Error Handling
Tools will fail. APIs time out, records disappear, and dependencies become unavailable.
Translate internal failures into useful tool responses without unnecessarily exposing stack traces or infrastructure details.
Internal Error
↓
Detailed Server Log
↓
Safe Tool Error
↓
AI ClientDistinguish where possible between invalid input, not found, permission denied, dependency failure, and unexpected errors.
Authentication and Authorization
An AI requesting an operation does not make that operation authorized.
For tools such as:
get_customer
update_subscription
delete_projectyour normal security model still applies:
Identity
↓
Authentication
↓
Authorization
↓
Tool ExecutionFor sensitive operations, consider explicit confirmation, narrow permissions, audit logs, rate limits, tenant isolation, and downstream validation.
Avoid Overpowered Tools
Compare:
execute_arbitrary_sqlwith:
get_customer_ordersThe second is narrower and easier to validate, authorize, monitor, and reason about.
Prefer business capabilities such as:
search_products
get_invoice
create_support_ticket
update_order_statusinstead of unrestricted infrastructure access.
Production Architecture
A production deployment may look like:
AI Clients
↓
Streamable HTTP
↓
┌─────────────┐
│ MCP Server │
└──────┬──────┘
│
┌────────────┼────────────┐
↓ ↓ ↓
User API Search API Ticket API
↓ ↓ ↓
PostgreSQL Search Index SaaS ServiceThe MCP layer should usually remain thin. Its job is to expose capabilities, validate inputs, enforce access rules, call backend services, and return useful results.
What Should You Expose Through MCP?
Do not automatically convert every backend endpoint into a tool.
A capability is a good MCP candidate when:
An AI application has a real reason to use it.
Its purpose can be described clearly.
Inputs can be validated.
Permissions can be enforced.
Outputs are understandable to the model.
Side effects are controlled.
An engineering assistant might expose:
search_documentation
get_service_status
query_deployment
get_recent_errors
create_incidentA focused set of useful capabilities is better than exposing your entire backend.
Common Mistakes
Using Outdated Transport Examples
MCP evolves quickly. Check the current SDK documentation before copying older SSE tutorials.
Logging to stdout With stdio
stdout belongs to MCP protocol traffic. Use stderr for diagnostics.
Weak Tool Descriptions
Ambiguous descriptions make tool selection less reliable.
Putting Business Logic Inside Tool Handlers
Keep handlers thin and delegate to reusable services.
Trusting LLM-Generated Inputs
Always validate arguments.
Exposing Excessive Permissions
Prefer narrow, task-specific capabilities.
Ignoring Observability
Track tool latency, failures, downstream calls, and safe audit information.
A Practical Development Workflow
1. Identify one useful backend capability
↓
2. Define a narrow MCP tool
↓
3. Add a strict input schema
↓
4. Connect existing service logic
↓
5. Test with MCP Inspector
↓
6. Add authorization + error handling
↓
7. Add monitoring
↓
8. Integrate with an AI clientStart with one or two tools. You will learn more from watching an AI client use a small, well-designed toolset than from creating dozens of tools before testing.
Conclusion
Building an MCP server with TypeScript is mostly familiar backend engineering wrapped in a standardized protocol.
Create MCP Server
↓
Register Tool
↓
Define Input Schema
↓
Call Backend Service
↓
Return MCP Content
↓
Connect Through a TransportFor local integrations, stdio is simple and effective. For remote deployments, use Streamable HTTP and apply the authentication, authorization, validation, observability, and error handling expected from any production backend.
The goal is not to expose as many tools as possible.
It is to provide clear, narrow, secure capabilities that an AI application can reliably understand and use.

Comments