How to Build an MCP Server in TypeScript
Before You Start
You need Node.js 18 or later and basic TypeScript familiarity. The SDK uses ES modules, so your project needs "type": "module" in package.json. If you prefer CommonJS, you can use the SDK with ts-node and appropriate tsconfig settings, but ES modules are the recommended path.
This guide builds a stdio server for local use. The same tool logic works with HTTP transport for remote deployment. The transport is just how the server communicates; the tools, resources, and prompts stay identical.
Step-by-Step Setup
Create a new directory, initialize a Node.js project, and install the MCP SDK along with Zod for schema validation. Zod is required because the SDK uses Zod schemas to define tool parameters and validate inputs.
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/nodeCreate a tsconfig.json with ES module output and Node module resolution. The SDK requires these settings to import correctly.
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}Create
src/index.ts with the McpServer import. The server takes a name and version that clients display in their UI. The name should describe what the server does.
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: "project-tools",
version: "1.0.0"
});Each tool is registered with a name string, a description string, a Zod schema object for parameters, and an async handler function. The handler receives the validated parameters and returns a content array with the result. The Zod schema provides both validation and automatic JSON Schema generation for the MCP protocol.
server.tool(
"search_codebase",
"Search source files for text matching a query",
{
query: z.string().describe("Text to search for"),
fileType: z.string().default(".ts").describe("File extension filter")
},
async ({ query, fileType }) => {
const { glob } = await import("glob");
const files = await glob(`**/*${fileType}`, { ignore: "node_modules/**" });
const matches: string[] = [];
for (const file of files) {
const fs = await import("fs/promises");
const content = await fs.readFile(file, "utf-8");
if (content.toLowerCase().includes(query.toLowerCase())) {
matches.push(file);
}
}
const result = matches.length > 0
? `Found in ${matches.length} files:\n${matches.join("\n")}`
: `No ${fileType} files contain "${query}"`;
return { content: [{ type: "text", text: result }] };
}
);
server.tool(
"read_file",
"Read the contents of a source file",
{
path: z.string().describe("Relative path to the file")
},
async ({ path }) => {
const fs = await import("fs/promises");
try {
const content = await fs.readFile(path, "utf-8");
return { content: [{ type: "text", text: content }] };
} catch {
return { content: [{ type: "text", text: `File not found: ${path}` }] };
}
}
);.describe() calls on each Zod field become part of the JSON schema that the AI model reads. Write them the same way you would write parameter documentation: clear, specific, with any constraints or expected formats noted.
Resources expose data the AI can read without invoking a tool. Prompts provide reusable workflow templates. Both are registered on the server instance with handlers that return the content.
server.resource(
"project-structure",
"file://project-structure",
async () => {
const { glob } = await import("glob");
const files = await glob("**/*", {
ignore: ["node_modules/**", "dist/**"]
});
return {
contents: [{
uri: "file://project-structure",
text: files.sort().join("\n"),
mimeType: "text/plain"
}]
};
}
);
server.prompt(
"code-review",
{ filePath: z.string() },
({ filePath }) => ({
messages: [{
role: "user",
content: {
type: "text",
text: `Review ${filePath} for security, performance, and readability. Read the file first, then provide analysis.`
}
}]
})
);Wire the server to a transport and start it. For local development, use StdioServerTransport. For remote deployment, use the HTTP transport. Add these lines at the end of your file.
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch(console.error);Build the project with npx tsc and verify it compiles without errors. The output goes to the dist directory.
Run the Inspector against your compiled server to verify tool registration and test invocations. Then add the server to your AI client configuration.
npx @modelcontextprotocol/inspector node dist/index.jsFor Claude Code, add the server to your project's .mcp.json:
{
"mcpServers": {
"project-tools": {
"command": "node",
"args": ["dist/index.js"],
"cwd": "/path/to/my-mcp-server"
}
}
}Structured Return Types
Tools can return multiple content blocks of different types. Text is the most common, but you can also return images (base64-encoded) and embedded resources. For most tools, a single text block with the result is sufficient.
server.tool(
"analyze_json",
"Parse and summarize a JSON file",
{ path: z.string() },
async ({ path }) => {
const fs = await import("fs/promises");
const raw = await fs.readFile(path, "utf-8");
const data = JSON.parse(raw);
const keys = Object.keys(data);
return {
content: [
{ type: "text", text: `Keys: ${keys.join(", ")}` },
{ type: "text", text: `Full content:\n${JSON.stringify(data, null, 2)}` }
]
};
}
);Error Handling Pattern
Return errors as content rather than throwing exceptions. The model can read error messages and adjust its approach, but it cannot recover from unhandled exceptions that produce generic protocol errors.
server.tool(
"run_query",
"Execute a read-only SQL query",
{ sql: z.string().describe("SELECT query to run") },
async ({ sql }) => {
if (!sql.trim().toUpperCase().startsWith("SELECT")) {
return {
content: [{ type: "text", text: "Error: Only SELECT queries allowed." }],
isError: true
};
}
try {
const results = await executeQuery(sql);
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
} catch (e) {
return {
content: [{ type: "text", text: `Query failed: ${String(e)}` }],
isError: true
};
}
}
);Moving to HTTP Transport
For remote deployment, switch the transport from stdio to Streamable HTTP. Install the express adapter and change the startup code. All tool, resource, and prompt definitions stay exactly the same.
import { StreamableHTTPServerTransport } from
"@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport("/mcp");
await server.connect(transport);
await transport.handleRequest(req, res);
});
app.listen(8080, () => {
console.log("MCP server running on port 8080");
});Connect to a memory server that is already built. Adaptive Recall provides store, recall, graph traversal, and more through a production MCP interface.
Get Started Free