Building MCP Servers in Node.js, TypeScript & Go: Full Guides
Complete guides for building MCP servers in Node.js/TypeScript and Go using the official SDKs. Code examples, setup, and best practices for each language.
Building MCP servers is not limited to Python. The TypeScript/Node.js SDK is the most widely used MCP SDK, powering the majority of production MCP servers in the ecosystem. Go provides a performance-focused alternative for systems-level servers. This guide gives you complete, working tutorials for both languages.
Whether you are a frontend developer comfortable with TypeScript, a backend engineer who prefers Go, or someone evaluating which language fits your project best, this guide covers everything you need to build production-ready MCP servers.
For the Python tutorial, see our companion guide: How to Build Your First MCP Server in Python.
Part 1: Building an MCP Server in TypeScript/Node.js
The TypeScript SDK (@modelcontextprotocol/sdk) is the reference implementation of the MCP protocol. It provides a low-level, explicit API that gives you full control over server behavior.
Prerequisites
- Node.js 18+ (we recommend 20 LTS or 22)
- npm, yarn, or pnpm
- TypeScript (optional but strongly recommended)
node --version # Should be 18.x or higher
npm --version
Step 1: Initialize the Project
mkdir mcp-notes-server
cd mcp-notes-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
Create a tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true
},
"include": ["src/**/*"]
}
We use zod for input validation, which is a common pattern in TypeScript MCP servers.
Step 2: Create the Server
Create src/index.ts:
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
ListPromptsRequestSchema,
GetPromptRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
// In-memory notes storage
interface Note {
id: string;
title: string;
content: string;
createdAt: string;
updatedAt: string;
}
const notes: Map<string, Note> = new Map();
// Create the MCP server
const server = new Server(
{
name: "notes-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
resources: {},
prompts: {},
},
}
);
The Server constructor takes two arguments: server identity (name and version) and capability declarations. Declaring tools: {}, resources: {}, and prompts: {} tells clients that your server supports all three MCP building blocks.
Step 3: Register Tools
Add tool handlers after the server creation:
// --- Tool Definitions ---
const AddNoteSchema = z.object({
title: z.string().describe("Title of the note"),
content: z.string().describe("Text content of the note"),
});
const SearchNotesSchema = z.object({
query: z.string().describe("Search query to find notes"),
});
const DeleteNoteSchema = z.object({
id: z.string().describe("ID of the note to delete"),
});
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "add_note",
description:
"Create a new note with a title and content. " +
"Returns the created note with its generated ID.",
inputSchema: {
type: "object" as const,
properties: {
title: {
type: "string",
description: "Title of the note",
},
content: {
type: "string",
description: "Text content of the note",
},
},
required: ["title", "content"],
},
},
{
name: "search_notes",
description:
"Search through notes by title or content. " +
"Returns matching notes sorted by relevance.",
inputSchema: {
type: "object" as const,
properties: {
query: {
type: "string",
description: "Search query to find notes",
},
},
required: ["query"],
},
},
{
name: "delete_note",
description: "Delete a note by its ID.",
inputSchema: {
type: "object" as const,
properties: {
id: {
type: "string",
description: "ID of the note to delete",
},
},
required: ["id"],
},
},
],
};
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case "add_note": {
const { title, content } = AddNoteSchema.parse(args);
const id = crypto.randomUUID();
const now = new Date().toISOString();
const note: Note = {
id,
title,
content,
createdAt: now,
updatedAt: now,
};
notes.set(id, note);
// Notify clients that resources have changed
await server.notification({
method: "notifications/resources/list_changed",
});
return {
content: [
{
type: "text",
text: `Note created successfully.\nID: ${id}\nTitle: ${title}`,
},
],
};
}
case "search_notes": {
const { query } = SearchNotesSchema.parse(args);
const queryLower = query.toLowerCase();
const results = Array.from(notes.values()).filter(
(note) =>
note.title.toLowerCase().includes(queryLower) ||
note.content.toLowerCase().includes(queryLower)
);
if (results.length === 0) {
return {
content: [
{
type: "text",
text: `No notes found matching "${query}".`,
},
],
};
}
const formatted = results
.map(
(note) =>
`[${note.id}] ${note.title}\n${note.content}\n(Created: ${note.createdAt})`
)
.join("\n\n---\n\n");
return {
content: [{ type: "text", text: formatted }],
};
}
case "delete_note": {
const { id } = DeleteNoteSchema.parse(args);
if (!notes.has(id)) {
return {
content: [
{ type: "text", text: `Note with ID "${id}" not found.` },
],
isError: true,
};
}
notes.delete(id);
await server.notification({
method: "notifications/resources/list_changed",
});
return {
content: [
{ type: "text", text: `Note "${id}" deleted successfully.` },
],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
});
Key patterns to note:
- Input schemas use JSON Schema format, which the AI model uses to understand what parameters each tool accepts
- Tool responses return content arrays with
type: "text"objects - Error responses include
isError: trueto signal tool failures - Resource change notifications tell clients to refresh their resource lists after mutations
Step 4: Add Resources
Resources in the TypeScript SDK use a handler-based pattern:
// --- Resource Definitions ---
// List available resources (dynamic based on current notes)
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: Array.from(notes.entries()).map(([id, note]) => ({
uri: `notes://${id}`,
name: note.title,
description: `Note: ${note.title}`,
mimeType: "text/plain",
})),
};
});
// Read a specific resource
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
// Parse the note ID from the URI
const match = uri.match(/^notes:\/\/(.+)$/);
if (!match) {
throw new Error(`Invalid resource URI: ${uri}`);
}
const noteId = match[1];
const note = notes.get(noteId);
if (!note) {
throw new Error(`Note not found: ${noteId}`);
}
return {
contents: [
{
uri,
mimeType: "text/plain",
text: `# ${note.title}\n\n${note.content}\n\nCreated: ${note.createdAt}\nUpdated: ${note.updatedAt}`,
},
],
};
});
This creates dynamic resources: each note gets its own URI, and the resource list updates as notes are added or deleted.
Step 5: Add Prompts
// --- Prompt Definitions ---
server.setRequestHandler(ListPromptsRequestSchema, async () => {
return {
prompts: [
{
name: "summarize_notes",
description: "Summarize all current notes into a brief overview",
},
{
name: "search_and_analyze",
description: "Search notes on a topic and provide analysis",
arguments: [
{
name: "topic",
description: "The topic to search and analyze",
required: true,
},
],
},
],
};
});
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case "summarize_notes": {
const allNotes = Array.from(notes.values());
const notesList = allNotes
.map((n) => `- ${n.title}: ${n.content.substring(0, 100)}...`)
.join("\n");
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Please summarize the following notes into a concise overview:\n\n${notesList}`,
},
},
],
};
}
case "search_and_analyze": {
const topic = args?.topic ?? "general";
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Search through my notes for anything related to "${topic}" and provide an analysis of the key themes and insights.`,
},
},
],
};
}
default:
throw new Error(`Unknown prompt: ${name}`);
}
});
Step 6: Connect the Transport and Run
Add the entry point at the bottom of src/index.ts:
// --- Main Entry Point ---
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Notes MCP server running on stdio");
}
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});
Note that we use console.error (not console.log) for server messages. Just like Python, stdout is reserved for the JSON-RPC protocol when using stdio transport.
Step 7: Test with MCP Inspector
Add a script to package.json:
{
"scripts": {
"build": "tsc",
"start": "tsx src/index.ts",
"inspect": "npx @modelcontextprotocol/inspector tsx src/index.ts"
}
}
Run the Inspector:
npm run inspect
This opens a web interface where you can:
- List and call tools (add_note, search_notes, delete_note)
- Browse and read resources (note URIs)
- Test prompt templates
Step 8: Configure Claude Desktop
Add your TypeScript server to claude_desktop_config.json:
{
"mcpServers": {
"notes": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/mcp-notes-server/src/index.ts"]
}
}
}
Or if you have compiled the TypeScript to JavaScript:
{
"mcpServers": {
"notes": {
"command": "node",
"args": ["/absolute/path/to/mcp-notes-server/dist/index.js"]
}
}
}
Part 2: Building an MCP Server in Go
The Go MCP SDK (github.com/modelcontextprotocol/go-sdk) provides a compiled, high-performance option for MCP servers.
Prerequisites
- Go 1.22+
- Basic familiarity with Go modules
Step 1: Initialize the Module
mkdir mcp-calc-server
cd mcp-calc-server
go mod init github.com/yourname/mcp-calc-server
go get github.com/modelcontextprotocol/go-sdk
Step 2: Create the Server
Create main.go:
package main
import (
"context"
"fmt"
"math"
"os"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/modelcontextprotocol/go-sdk/server"
)
func main() {
// Create the MCP server
s := server.NewMCPServer(
"calculator-server",
"1.0.0",
server.WithToolCapabilities(true),
)
// Register tools
calculatorTool := mcp.NewTool("calculate",
mcp.WithDescription("Perform mathematical calculations"),
mcp.WithString("expression",
mcp.Required(),
mcp.Description("Mathematical expression to evaluate (e.g., '2 + 3', 'sqrt(16)')"),
),
)
s.AddTool(calculatorTool, handleCalculate)
unitConvertTool := mcp.NewTool("convert_units",
mcp.WithDescription("Convert between units of measurement"),
mcp.WithNumber("value",
mcp.Required(),
mcp.Description("The numeric value to convert"),
),
mcp.WithString("from_unit",
mcp.Required(),
mcp.Description("Source unit (e.g., 'km', 'miles', 'celsius', 'fahrenheit')"),
),
mcp.WithString("to_unit",
mcp.Required(),
mcp.Description("Target unit to convert to"),
),
)
s.AddTool(unitConvertTool, handleUnitConvert)
// Start the stdio server
transport := server.NewStdioTransport()
if err := transport.ServeAndListen(s); err != nil {
fmt.Fprintf(os.Stderr, "Server error: %v\n", err)
os.Exit(1)
}
}
func handleCalculate(
ctx context.Context,
request mcp.CallToolRequest,
) (*mcp.CallToolResult, error) {
expression, ok := request.Params.Arguments["expression"].(string)
if !ok {
return mcp.NewToolResultError("expression must be a string"), nil
}
// Simple expression evaluation (in production, use a proper parser)
result, err := evaluateExpression(expression)
if err != nil {
return mcp.NewToolResultError(
fmt.Sprintf("Error evaluating '%s': %v", expression, err),
), nil
}
return mcp.NewToolResultText(
fmt.Sprintf("Result: %s = %g", expression, result),
), nil
}
func handleUnitConvert(
ctx context.Context,
request mcp.CallToolRequest,
) (*mcp.CallToolResult, error) {
value, _ := request.Params.Arguments["value"].(float64)
fromUnit, _ := request.Params.Arguments["from_unit"].(string)
toUnit, _ := request.Params.Arguments["to_unit"].(string)
result, err := convertUnits(value, fromUnit, toUnit)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(
fmt.Sprintf("%.4g %s = %.4g %s", value, fromUnit, result, toUnit),
), nil
}
func evaluateExpression(expr string) (float64, error) {
// Simplified: handle basic operations
// In production, use a library like github.com/Knetic/govaluate
switch expr {
case "pi":
return math.Pi, nil
case "e":
return math.E, nil
default:
return 0, fmt.Errorf("expression parsing not implemented for: %s", expr)
}
}
func convertUnits(value float64, from, to string) (float64, error) {
key := from + "_to_" + to
conversions := map[string]func(float64) float64{
"km_to_miles": func(v float64) float64 { return v * 0.621371 },
"miles_to_km": func(v float64) float64 { return v * 1.60934 },
"celsius_to_fahrenheit": func(v float64) float64 { return v*9/5 + 32 },
"fahrenheit_to_celsius": func(v float64) float64 { return (v - 32) * 5 / 9 },
"kg_to_lbs": func(v float64) float64 { return v * 2.20462 },
"lbs_to_kg": func(v float64) float64 { return v * 0.453592 },
}
fn, ok := conversions[key]
if !ok {
return 0, fmt.Errorf("conversion from %s to %s is not supported", from, to)
}
return fn(value), nil
}
Step 3: Build and Run
go build -o mcp-calc-server .
Configure in Claude Desktop:
{
"mcpServers": {
"calculator": {
"command": "/absolute/path/to/mcp-calc-server"
}
}
}
Go servers compile to a single binary, which simplifies deployment -- no runtime or package manager required.
Language SDK Comparison
Here is a detailed comparison of the three official MCP SDKs to help you choose the right one:
| Feature | Python SDK | TypeScript SDK | Go SDK |
|---|---|---|---|
| Package | mcp[cli] (PyPI) | @modelcontextprotocol/sdk (npm) | github.com/modelcontextprotocol/go-sdk |
| API style | Decorator-based (FastMCP) | Handler-based (request schemas) | Builder pattern with handler functions |
| Type safety | Type hints (optional at runtime) | Full TypeScript types | Static typing (compile-time) |
| Schema generation | Auto from type hints | Manual JSON Schema | Builder methods |
| Async model | asyncio / async-await | Promises / async-await | Goroutines / context |
| Transport: stdio | Built-in | Built-in | Built-in |
| Transport: SSE | Built-in | Built-in | Built-in |
| Inspector support | mcp dev command | npx @modelcontextprotocol/inspector | Use TypeScript Inspector |
| Cold start time | ~200-500ms | ~100-300ms | ~10-50ms |
| Memory usage | Moderate (~50MB) | Moderate (~40MB) | Low (~10MB) |
| Best for | Data science, ML, rapid prototyping | Web services, npm ecosystem | Systems, high-performance, containers |
| Community servers | Many | Most | Growing |
When to Choose Each Language
Choose Python when:
- You are building data science or ML-related servers
- You want the fastest path from idea to working server (FastMCP decorators)
- Your tools wrap Python libraries (pandas, scikit-learn, etc.)
- You prefer convention over configuration
Choose TypeScript when:
- You are building web-oriented servers
- Your team works primarily in the JavaScript/TypeScript ecosystem
- You need to integrate with npm packages
- You want explicit control over schemas and handlers
- You are building a server you plan to publish on npm
Choose Go when:
- You need minimal resource usage (container deployments)
- Fast cold-start times matter (serverless / edge)
- You are building infrastructure-level servers
- You want a single binary deployment with no runtime dependencies
- You need maximum throughput for high-volume operations
TypeScript SDK Deep Dive: Advanced Patterns
Using Zod for Runtime Validation
While JSON Schema in tool definitions tells the AI model what parameters to send, you should also validate inputs at runtime:
import { z } from "zod";
const CreateItemSchema = z.object({
name: z.string().min(1).max(200),
category: z.enum(["task", "note", "reminder"]),
priority: z.number().int().min(1).max(5).optional().default(3),
tags: z.array(z.string()).optional().default([]),
});
// In your tool handler:
case "create_item": {
const validated = CreateItemSchema.parse(args);
// validated is fully typed and validated
}
SSE Transport for Remote Servers
For servers that need to be accessible over HTTP:
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";
const app = express();
app.get("/sse", async (req, res) => {
const transport = new SSEServerTransport("/messages", res);
await server.connect(transport);
});
app.post("/messages", async (req, res) => {
// Handle incoming messages
await transport.handlePostMessage(req, res);
});
app.listen(3001, () => {
console.error("MCP SSE server running on port 3001");
});
Error Handling Patterns
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
switch (name) {
case "risky_operation": {
const result = await performRiskyOperation(args);
return {
content: [{ type: "text", text: JSON.stringify(result) }],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
// Return error as tool result (not throwing, which would be a protocol error)
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`,
},
],
isError: true,
};
}
});
There is an important distinction here: returning an error in the tool result tells the AI model that the tool failed, so it can try a different approach. Throwing an error causes a protocol-level error that may crash the connection.
Resource Templates with URI Patterns
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [],
resourceTemplates: [
{
uriTemplate: "db://tables/{table_name}/schema",
name: "Database Table Schema",
description: "Get the schema for any database table",
},
{
uriTemplate: "db://tables/{table_name}/rows?limit={limit}",
name: "Database Table Rows",
description: "Read rows from a database table",
},
],
};
});
Publishing Your Server as an npm Package
To distribute your TypeScript MCP server:
- Add a shebang to your entry file:
#!/usr/bin/env node
- Configure
package.json:
{
"name": "mcp-notes-server",
"version": "1.0.0",
"bin": {
"mcp-notes-server": "./dist/index.js"
},
"files": ["dist"],
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
}
}
- Build and publish:
npm run build
npm publish
Users can then run your server with:
npx mcp-notes-server
And configure it in Claude Desktop:
{
"mcpServers": {
"notes": {
"command": "npx",
"args": ["-y", "mcp-notes-server"]
}
}
}
Project Structure for Production TypeScript Servers
mcp-notes-server/
package.json
tsconfig.json
src/
index.ts # Entry point — creates server and transport
server.ts # Server configuration and capability setup
tools/
index.ts # Tool registration
add-note.ts # Individual tool implementations
search-notes.ts
delete-note.ts
resources/
index.ts # Resource handlers
note-resource.ts
prompts/
index.ts # Prompt definitions
services/
note-store.ts # Business logic / data access
utils/
validation.ts # Shared validation helpers
tests/
tools.test.ts # Unit tests for tool handlers
integration.test.ts # Integration tests with MCP client
What to Read Next
- Learn how to define advanced tools and resources: Creating Custom Tools & Resources
- Understand the protocol building blocks: MCP Core Building Blocks
- Test and debug your server thoroughly: Testing & Debugging MCP Servers
- Connect your server to AI clients: How to Connect MCP Servers to Claude Desktop
- Browse working servers for inspiration: MCP Server Directory
Summary
The MCP ecosystem supports multiple languages, each with distinct advantages. TypeScript provides the most mature SDK with the largest community of published servers. Go delivers performance and simplicity for production infrastructure. Python offers the most rapid development experience with its decorator-based API.
Regardless of language, every MCP server follows the same pattern: declare capabilities, register handlers for tools/resources/prompts, connect a transport, and run. The protocol is language-agnostic -- a TypeScript server and a Python server are indistinguishable from a client's perspective. Choose the language that fits your team and project, not the one that is "best" in abstract.
Frequently Asked Questions
Should I use TypeScript or Python for my MCP server?
Choose TypeScript if your team already works in the Node.js ecosystem, if you need tight integration with JavaScript-based tools, or if you prefer explicit type safety. Choose Python if you are working with data science libraries, ML pipelines, or prefer the simpler FastMCP decorator API. Both SDKs are officially maintained and feature-complete.
What version of Node.js do I need for the MCP TypeScript SDK?
You need Node.js 18 or higher. The MCP TypeScript SDK uses modern JavaScript features like top-level await and the Fetch API that were stabilized in Node.js 18. We recommend Node.js 20 LTS or Node.js 22 for the best experience.
Can I use plain JavaScript instead of TypeScript for MCP servers?
Yes, you can write MCP servers in plain JavaScript. The @modelcontextprotocol/sdk package ships compiled JavaScript with TypeScript type definitions. However, TypeScript is strongly recommended because the SDK uses complex types for tool schemas and request handlers that are much easier to work with when you have type checking.
How do I install the MCP TypeScript SDK?
Install it with npm: 'npm install @modelcontextprotocol/sdk'. For TypeScript projects, also install the TypeScript compiler and ts-node or tsx for running TypeScript directly: 'npm install -D typescript tsx'.
Is there an official MCP SDK for Go?
Yes, there is an official Go SDK at github.com/modelcontextprotocol/go-sdk. It provides the mcp and server packages for building MCP servers in Go. The Go SDK follows Go idioms and uses standard library patterns.
How does the TypeScript SDK differ from the Python FastMCP approach?
The TypeScript SDK uses a lower-level, handler-based pattern where you define JSON schemas for tools and implement request handlers. Python's FastMCP uses decorators and infers schemas from type hints. The TypeScript approach gives you more explicit control, while Python's approach is more concise. Both produce the same MCP protocol messages.
Can I use Bun or Deno instead of Node.js?
Bun is generally compatible with the MCP TypeScript SDK since it supports the Node.js API. Deno can work with compatibility flags. However, Node.js is the primary tested runtime. If you encounter issues with alternative runtimes, test with Node.js first to isolate whether the problem is runtime-specific.
How do I handle async operations in the TypeScript MCP SDK?
All handler functions in the TypeScript SDK are async. You return Promises from your tool and resource handlers. Use async/await syntax for API calls, database queries, or file system operations. The SDK handles the async lifecycle and error propagation automatically.
What is the performance difference between TypeScript and Go MCP servers?
Go MCP servers have lower memory usage and faster cold-start times, making them ideal for containerized deployments. TypeScript servers are typically fast enough for most use cases and benefit from the rich npm ecosystem. For high-throughput production servers, Go has an edge. For rapid development and prototyping, TypeScript is often faster to build.
How do I publish my MCP server as an npm package?
Compile your TypeScript to JavaScript, set the bin field in package.json pointing to your entry file, and publish with npm publish. Users can then run your server with npx your-package-name. Make sure to include a shebang (#!/usr/bin/env node) at the top of your entry file.
Related Guides
Master the three pillars of MCP functionality — Tools (model-controlled functions), Resources (app-controlled data), and Prompts (user-controlled templates).
A complete, beginner-friendly tutorial for building an MCP server in Python using the official SDK. Includes code, testing with Inspector, and Claude Desktop setup.
Learn how to create powerful custom tools and resources for your MCP servers — from simple functions to complex data providers with proper schemas.