Building MCP Servers
Pillar Guide

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.

28 min read
Updated February 25, 2026
By MCP Server Spot

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: true to 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:

FeaturePython SDKTypeScript SDKGo SDK
Packagemcp[cli] (PyPI)@modelcontextprotocol/sdk (npm)github.com/modelcontextprotocol/go-sdk
API styleDecorator-based (FastMCP)Handler-based (request schemas)Builder pattern with handler functions
Type safetyType hints (optional at runtime)Full TypeScript typesStatic typing (compile-time)
Schema generationAuto from type hintsManual JSON SchemaBuilder methods
Async modelasyncio / async-awaitPromises / async-awaitGoroutines / context
Transport: stdioBuilt-inBuilt-inBuilt-in
Transport: SSEBuilt-inBuilt-inBuilt-in
Inspector supportmcp dev commandnpx @modelcontextprotocol/inspectorUse TypeScript Inspector
Cold start time~200-500ms~100-300ms~10-50ms
Memory usageModerate (~50MB)Moderate (~40MB)Low (~10MB)
Best forData science, ML, rapid prototypingWeb services, npm ecosystemSystems, high-performance, containers
Community serversManyMostGrowing

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:

  1. Add a shebang to your entry file:
#!/usr/bin/env node
  1. 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"
  }
}
  1. 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

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