Build Your First MCP Server with TypeScript: Tools, Resources, and Prompts

Noqta TeamAI Bot
By Noqta Team & AI Bot ·

Loading the Text to Speech Audio Player...

The protocol that connects AI to everything. MCP (Model Context Protocol) is the open standard that lets AI agents like Claude and Cursor discover and use your tools. In this tutorial, you will build one from scratch.

What You Will Learn

By the end of this tutorial, you will:

  • Understand the MCP architecture: servers, clients, hosts, and transports
  • Set up a TypeScript MCP server project from scratch
  • Register tools that AI agents can invoke
  • Expose resources that provide context to LLMs
  • Define prompts as reusable templates for common tasks
  • Connect your server to Claude Desktop and Cursor
  • Test and debug your server with the MCP Inspector

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (node --version)
  • TypeScript knowledge (types, async/await, modules)
  • A code editor — VS Code or Cursor recommended
  • Claude Desktop or Cursor installed (for testing)
  • Basic understanding of JSON-RPC and APIs

What is MCP?

The Model Context Protocol is an open standard created by Anthropic that defines how AI applications communicate with external data sources and tools. Think of it as USB-C for AI — a single, universal connector that replaces the mess of custom integrations.

Architecture Overview

┌─────────────────────────────────────────────┐
│  MCP Host (Claude Desktop, Cursor, etc.)    │
│                                             │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐    │
│  │ Client 1│  │ Client 2│  │ Client 3│    │
│  └────┬────┘  └────┬────┘  └────┬────┘    │
└───────┼────────────┼────────────┼──────────┘
        │            │            │
   ┌────▼────┐  ┌────▼────┐  ┌───▼─────┐
   │ Server A│  │ Server B│  │ Server C│
   │ (Files) │  │ (DB)    │  │ (APIs)  │
   └─────────┘  └─────────┘  └─────────┘
ComponentRole
HostThe AI application (Claude Desktop, Cursor)
ClientMaintains a 1:1 connection with a server
ServerExposes tools, resources, and prompts
TransportCommunication channel (stdio, HTTP)

What Servers Can Expose

MCP servers provide three primitives:

  1. Tools — Functions the AI can call (like API endpoints)
  2. Resources — Read-only data the AI can access (like files)
  3. Prompts — Reusable templates for common workflows

Step 1: Project Setup

Create a new directory and initialize the project.

mkdir my-mcp-server && cd my-mcp-server
npm init -y

Install the MCP SDK and dependencies.

npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

SDK Version Note: This tutorial uses the MCP TypeScript SDK v1.x which uses @modelcontextprotocol/sdk. The v2 SDK (expected Q2 2026) reorganizes imports into @modelcontextprotocol/server and @modelcontextprotocol/node. The concepts remain identical — only import paths change.

Initialize TypeScript.

npx tsc --init

Update your tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"]
}

Update package.json to mark the project as ESM and add build scripts:

{
  "name": "my-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "my-mcp-server": "./build/index.js"
  },
  "scripts": {
    "build": "tsc",
    "start": "node build/index.js",
    "dev": "tsc --watch"
  }
}

Create the source directory:

mkdir src

Step 2: Create the Server Skeleton

Create src/index.ts — the entry point for your MCP server.

#!/usr/bin/env node
 
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
 
// Create the MCP server
const server = new McpServer({
  name: "my-mcp-server",
  version: "1.0.0",
});
 
// Connect using stdio transport
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP server running on stdio");
}
 
main().catch(console.error);

Key points:

  • McpServer is the high-level class that handles protocol negotiation
  • StdioServerTransport communicates over stdin/stdout — the standard for local MCP servers
  • We log to stderr because stdout is reserved for MCP protocol messages

Build and verify:

npm run build

You now have a working (but empty) MCP server. Let's add capabilities.


Step 3: Register Tools

Tools are the most powerful MCP primitive. They let AI agents take actions — call APIs, query databases, transform data, or run computations.

Your First Tool: A Word Counter

Add this before the main() function in src/index.ts:

import { z } from "zod";
 
server.tool(
  "count_words",
  "Count the number of words in a given text",
  {
    text: z.string().describe("The text to count words in"),
  },
  async ({ text }) => {
    const wordCount = text
      .trim()
      .split(/\s+/)
      .filter((w) => w.length > 0).length;
 
    return {
      content: [
        {
          type: "text",
          text: `The text contains ${wordCount} words.`,
        },
      ],
    };
  }
);

Let's break down the server.tool() signature:

ParameterPurpose
"count_words"Unique tool name (snake_case by convention)
"Count the..."Human-readable description for the AI
{ text: z.string() }Input schema using Zod validation
async ({ text }) => ...Handler function that executes the tool

A More Practical Tool: URL Status Checker

server.tool(
  "check_url",
  "Check if a URL is reachable and return its HTTP status code",
  {
    url: z.string().url().describe("The URL to check"),
    timeout: z
      .number()
      .optional()
      .default(5000)
      .describe("Timeout in milliseconds"),
  },
  async ({ url, timeout }) => {
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeout);
 
      const response = await fetch(url, {
        method: "HEAD",
        signal: controller.signal,
      });
 
      clearTimeout(timeoutId);
 
      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(
              {
                url,
                status: response.status,
                statusText: response.statusText,
                reachable: true,
              },
              null,
              2
            ),
          },
        ],
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(
              {
                url,
                reachable: false,
                error:
                  error instanceof Error ? error.message : "Unknown error",
              },
              null,
              2
            ),
          },
        ],
        isError: true,
      };
    }
  }
);

Notice the pattern:

  • Use Zod for input validation — the AI sees the schema and knows what to send
  • Return content as an array of content blocks (text, image, or resource)
  • Set isError: true when the tool fails, so the AI can handle errors gracefully

Tool with Structured Output

For tools that return data (not just messages), you can define an output schema:

server.tool(
  "calculate_bmi",
  "Calculate Body Mass Index from weight and height",
  {
    weightKg: z.number().positive().describe("Weight in kilograms"),
    heightM: z.number().positive().describe("Height in meters"),
  },
  async ({ weightKg, heightM }) => {
    const bmi = weightKg / (heightM * heightM);
    const category =
      bmi < 18.5
        ? "Underweight"
        : bmi < 25
        ? "Normal"
        : bmi < 30
        ? "Overweight"
        : "Obese";
 
    return {
      content: [
        {
          type: "text",
          text: `BMI: ${bmi.toFixed(1)} (${category})`,
        },
      ],
    };
  }
);

Step 4: Expose Resources

Resources provide read-only context to the AI. Unlike tools (which perform actions), resources are data the AI can reference — configuration files, documentation, database schemas, or live system state.

Static Resource

server.resource(
  "server-info",
  "info://server",
  async (uri) => {
    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify(
            {
              name: "my-mcp-server",
              version: "1.0.0",
              capabilities: ["word counting", "URL checking", "BMI calculation"],
              uptime: process.uptime(),
            },
            null,
            2
          ),
        },
      ],
    };
  }
);

Dynamic Resource with Templates

Resource templates let you expose parameterized data — like individual records from a database.

// Simulated data store
const projects = new Map([
  [
    "web-app",
    {
      name: "Web Application",
      status: "active",
      tech: ["Next.js", "TypeScript", "PostgreSQL"],
    },
  ],
  [
    "api",
    {
      name: "REST API",
      status: "active",
      tech: ["Hono", "Bun", "SQLite"],
    },
  ],
  [
    "mobile",
    {
      name: "Mobile App",
      status: "planning",
      tech: ["React Native", "Expo"],
    },
  ],
]);
 
// Resource template — the {id} is a URI parameter
server.resource(
  "project",
  "projects://{id}",
  async (uri) => {
    const id = uri.pathname.replace("//", "");
    const project = projects.get(id);
 
    if (!project) {
      throw new Error(`Project "${id}" not found`);
    }
 
    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify(project, null, 2),
        },
      ],
    };
  }
);

When an AI agent connects to your server, it can browse available resources and read them for context — just like browsing files on disk.


Step 5: Define Prompts

Prompts are reusable templates that guide the AI toward specific tasks. They are like saved instructions that can accept arguments.

server.prompt(
  "code-review",
  "Review code for bugs, security issues, and best practices",
  {
    code: z.string().describe("The code to review"),
    language: z
      .string()
      .optional()
      .default("typescript")
      .describe("Programming language"),
    focus: z
      .enum(["bugs", "security", "performance", "all"])
      .optional()
      .default("all")
      .describe("What to focus on in the review"),
  },
  async ({ code, language, focus }) => {
    const focusInstructions = {
      bugs: "Focus on finding logical errors, edge cases, and potential runtime exceptions.",
      security:
        "Focus on security vulnerabilities: injection, XSS, authentication flaws, data exposure.",
      performance:
        "Focus on performance: unnecessary allocations, O(n²) algorithms, missing caching.",
      all: "Review for bugs, security vulnerabilities, performance issues, and adherence to best practices.",
    };
 
    return {
      messages: [
        {
          role: "user",
          content: {
            type: "text",
            text: `Review the following ${language} code. ${focusInstructions[focus]}
 
\`\`\`${language}
${code}
\`\`\`
 
Provide your review as:
1. **Critical Issues** — Must fix before merging
2. **Warnings** — Should fix, but not blocking
3. **Suggestions** — Nice-to-have improvements
4. **Summary** — Overall code quality assessment`,
          },
        },
      ],
    };
  }
);

Another Prompt: SQL Query Generator

server.prompt(
  "generate-sql",
  "Generate SQL queries from natural language descriptions",
  {
    description: z
      .string()
      .describe("Natural language description of the query"),
    dialect: z
      .enum(["postgresql", "mysql", "sqlite"])
      .optional()
      .default("postgresql")
      .describe("SQL dialect to target"),
    schema: z
      .string()
      .optional()
      .describe("Database schema context (CREATE TABLE statements)"),
  },
  async ({ description, dialect, schema }) => {
    let contextBlock = "";
    if (schema) {
      contextBlock = `\nHere is the database schema:\n\`\`\`sql\n${schema}\n\`\`\`\n`;
    }
 
    return {
      messages: [
        {
          role: "user",
          content: {
            type: "text",
            text: `Generate a ${dialect} SQL query for the following request:
 
"${description}"
${contextBlock}
Requirements:
- Use proper ${dialect} syntax
- Include comments explaining the query
- Use parameterized queries where user input is involved
- Optimize for readability and performance`,
          },
        },
      ],
    };
  }
);

Step 6: Complete Server Code

Here is the full src/index.ts with all primitives registered. This is the complete, runnable version:

#!/usr/bin/env node
 
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
 
// ── Server ──────────────────────────────────────────────
 
const server = new McpServer({
  name: "my-mcp-server",
  version: "1.0.0",
});
 
// ── Tools ───────────────────────────────────────────────
 
server.tool(
  "count_words",
  "Count the number of words in a given text",
  { text: z.string().describe("The text to count words in") },
  async ({ text }) => {
    const wordCount = text
      .trim()
      .split(/\s+/)
      .filter((w) => w.length > 0).length;
 
    return {
      content: [{ type: "text", text: `The text contains ${wordCount} words.` }],
    };
  }
);
 
server.tool(
  "check_url",
  "Check if a URL is reachable and return its HTTP status code",
  {
    url: z.string().url().describe("The URL to check"),
    timeout: z.number().optional().default(5000).describe("Timeout in ms"),
  },
  async ({ url, timeout }) => {
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeout);
      const response = await fetch(url, {
        method: "HEAD",
        signal: controller.signal,
      });
      clearTimeout(timeoutId);
 
      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(
              {
                url,
                status: response.status,
                statusText: response.statusText,
                reachable: true,
              },
              null,
              2
            ),
          },
        ],
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(
              {
                url,
                reachable: false,
                error: error instanceof Error ? error.message : "Unknown error",
              },
              null,
              2
            ),
          },
        ],
        isError: true,
      };
    }
  }
);
 
// ── Resources ───────────────────────────────────────────
 
const projects = new Map([
  ["web-app", { name: "Web Application", status: "active", tech: ["Next.js", "TypeScript", "PostgreSQL"] }],
  ["api", { name: "REST API", status: "active", tech: ["Hono", "Bun", "SQLite"] }],
  ["mobile", { name: "Mobile App", status: "planning", tech: ["React Native", "Expo"] }],
]);
 
server.resource("server-info", "info://server", async (uri) => ({
  contents: [
    {
      uri: uri.href,
      mimeType: "application/json",
      text: JSON.stringify(
        {
          name: "my-mcp-server",
          version: "1.0.0",
          capabilities: ["word counting", "URL checking"],
          uptime: process.uptime(),
        },
        null,
        2
      ),
    },
  ],
}));
 
server.resource("project", "projects://{id}", async (uri) => {
  const id = uri.pathname.replace("//", "");
  const project = projects.get(id);
  if (!project) throw new Error(`Project "${id}" not found`);
  return {
    contents: [
      { uri: uri.href, mimeType: "application/json", text: JSON.stringify(project, null, 2) },
    ],
  };
});
 
// ── Prompts ─────────────────────────────────────────────
 
server.prompt(
  "code-review",
  "Review code for bugs, security issues, and best practices",
  {
    code: z.string().describe("The code to review"),
    language: z.string().optional().default("typescript").describe("Programming language"),
    focus: z
      .enum(["bugs", "security", "performance", "all"])
      .optional()
      .default("all")
      .describe("Review focus area"),
  },
  async ({ code, language, focus }) => {
    const focusMap = {
      bugs: "Focus on logical errors and edge cases.",
      security: "Focus on security vulnerabilities.",
      performance: "Focus on performance bottlenecks.",
      all: "Review bugs, security, performance, and best practices.",
    };
 
    return {
      messages: [
        {
          role: "user" as const,
          content: {
            type: "text" as const,
            text: `Review this ${language} code. ${focusMap[focus]}\n\n\`\`\`${language}\n${code}\n\`\`\``,
          },
        },
      ],
    };
  }
);
 
// ── Start ───────────────────────────────────────────────
 
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP server running on stdio");
}
 
main().catch(console.error);

Build the server:

npm run build

Step 7: Test with MCP Inspector

Before connecting to Claude Desktop, use the MCP Inspector to test your server interactively.

npx @modelcontextprotocol/inspector node build/index.js

This opens a web UI where you can:

  1. List tools — See all registered tools and their schemas
  2. Call tools — Execute tools with test inputs and see responses
  3. Browse resources — View available resources and read their contents
  4. Use prompts — Select prompts, fill in arguments, and see generated messages

Try calling the count_words tool with "Hello world, this is my MCP server" — you should see the response The text contains 7 words.


Step 8: Connect to Claude Desktop

Open your Claude Desktop configuration file:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json

Add your server:

{
  "mcpServers": {
    "my-mcp-server": {
      "command": "node",
      "args": ["/absolute/path/to/my-mcp-server/build/index.js"]
    }
  }
}

Important: Use the absolute path to your build/index.js file. Relative paths will not work. Replace /absolute/path/to/ with your actual project path.

Restart Claude Desktop. You should see a hammer icon indicating tools are available. Try asking Claude:

  • "Count the words in this paragraph: ..."
  • "Check if https://noqta.tn is reachable"
  • "Use the code review prompt to review this function"

Step 9: Connect to Cursor

Cursor supports MCP servers natively. Add your server in Cursor's MCP settings:

  1. Open Cursor Settings (Cmd+Shift+J on macOS)
  2. Navigate to MCP Servers
  3. Click Add Server
  4. Configure:
    • Name: my-mcp-server
    • Type: stdio
    • Command: node /absolute/path/to/my-mcp-server/build/index.js

Alternatively, create a .cursor/mcp.json file in your project root:

{
  "mcpServers": {
    "my-mcp-server": {
      "command": "node",
      "args": ["/absolute/path/to/my-mcp-server/build/index.js"]
    }
  }
}

Your tools are now available in Cursor's Agent mode. The AI can discover and call your tools automatically during coding sessions.


Step 10: Add Error Handling and Logging

Production MCP servers need proper error handling. Here's a pattern for robust tools:

server.tool(
  "read_json_file",
  "Read and parse a JSON file",
  {
    path: z.string().describe("Path to the JSON file"),
  },
  async ({ path }) => {
    try {
      const { readFile } = await import("node:fs/promises");
      const content = await readFile(path, "utf-8");
      const parsed = JSON.parse(content);
 
      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(parsed, null, 2),
          },
        ],
      };
    } catch (error) {
      if (error instanceof SyntaxError) {
        return {
          content: [{ type: "text", text: `Invalid JSON in file: ${path}` }],
          isError: true,
        };
      }
 
      const nodeError = error as NodeJS.ErrnoException;
      if (nodeError.code === "ENOENT") {
        return {
          content: [{ type: "text", text: `File not found: ${path}` }],
          isError: true,
        };
      }
 
      return {
        content: [
          {
            type: "text",
            text: `Error reading file: ${nodeError.message}`,
          },
        ],
        isError: true,
      };
    }
  }
);

Key practices:

  • Always return a result, even on errors — never throw from a tool handler
  • Set isError: true so the AI knows the tool failed
  • Provide useful error messages so the AI can recover or inform the user
  • Log to stderr for debugging: console.error("Debug:", someValue)

Troubleshooting

Server not appearing in Claude Desktop

  1. Check the config file path and JSON syntax
  2. Ensure the absolute path to build/index.js is correct
  3. Restart Claude Desktop completely (quit and reopen)
  4. Check logs: ~/Library/Logs/Claude/mcp*.log on macOS

"Could not connect to MCP server"

  1. Test your server directly: echo '{}' | node build/index.js — it should not crash
  2. Ensure #!/usr/bin/env node is at the top of the compiled JS file
  3. Make the file executable: chmod +x build/index.js

Tools not appearing

  1. Verify tools are registered before server.connect(transport)
  2. Check for TypeScript compilation errors: npm run build
  3. Use the MCP Inspector to verify: npx @modelcontextprotocol/inspector node build/index.js

Debugging Tips

Add environment-based debug logging:

const DEBUG = process.env.MCP_DEBUG === "true";
 
function debug(...args: unknown[]) {
  if (DEBUG) console.error("[DEBUG]", ...args);
}
 
// Use in tool handlers:
debug("check_url called with:", url);

Run with debugging:

MCP_DEBUG=true node build/index.js

Next Steps

Now that you have a working MCP server, here are ways to extend it:

  • Add database integration — Connect to PostgreSQL, SQLite, or MongoDB and expose queries as tools
  • Build an HTTP transport — Deploy your server as a remote API using StreamableHTTPServerTransport
  • Publish to npm — Share your server with the community via npx
  • Add authentication — Implement API key validation for sensitive tools
  • Explore the ecosystem — Browse MCP server examples on GitHub for inspiration

Conclusion

You have built a fully functional MCP server that exposes tools, resources, and prompts to AI agents. The Model Context Protocol is rapidly becoming the standard for AI-tool integration, and knowing how to build servers puts you at the center of this ecosystem.

The key takeaways:

  1. Tools let AI agents take actions — they are the most powerful primitive
  2. Resources provide context — data the AI can read to make better decisions
  3. Prompts are reusable templates — they guide the AI toward specific workflows
  4. Stdio transport is the default for local servers — simple and reliable
  5. Error handling matters — always return results, never throw from handlers

Start with a real problem: wrap an API you use daily, expose a database you query often, or create tools that automate your team's workflows. The best MCP servers solve specific, practical problems.


Want to read more tutorials? Check out our latest tutorial on Fine-tuning GPT with OpenAI, Next.js and Vercel AI SDK.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

WordPress MCP Adapter: Making Your Site AI-Agent Ready

Learn how to install and configure the WordPress MCP Adapter to make your WordPress site accessible to AI agents in Cursor, Claude Desktop, and other MCP-compatible tools. Complete step-by-step guide with practical examples.

25 min read·