Skip to main content
Crashbytes logoCrashbytes
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Browse Articles
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Network
Theme
Browse Articles
Crashbytes logoCrashbytes

Expert insights on web development, technology trends, and programming best practices. Learn from real-world experiences and cutting-edge techniques that help you build better software.

Follow Us

Our Sites

  • ๐Ÿ”ฎ Predictions
  • ๐Ÿ“ฐ Breaking News
  • ๐ŸŽจ AI Art
  • ๐Ÿ“– Short Stories
  • View All โ†’
  • Products โ†’

Sitemap

  • Home
  • All Articles
  • Open Source
  • Services
  • About Us
  • Contact
  • Donate Compute

Popular Topics

  • Serverless
  • Cloud Architecture
  • DevOps
  • Kubernetes
  • Platform Engineering

Resources

  • Privacy Policy
  • Terms of Service
  • Sitemap
  • RSS Feed
  • PGP Key

Stay Updated

Get the latest articles, tutorials, and insights delivered to your inbox. Join our community of developers and never miss an update.

ยฉ 2021-2026 Crashbytesยฎ by Blackhole Software, LLC. All rights reserved.
| Reg. U.S. Pat. & Tm. Off.

Made for the developer community

  1. Home
  2. /
  3. Articles
  4. /
  5. Building Your First MCP Server in TypeScript From Scratch
TutorialFebruary 23, 202630 min readโ€ข By Michael Eakins

Building Your First MCP Server in TypeScript From Scratch

A complete hands-on tutorial for building a production-ready Model Context Protocol server in TypeScript. Learn to create tools, resources, and prompts, then connect your server to Claude Desktop, VS Code, and Cursor.

Building Your First MCP Server in TypeScript From Scratch

Quick Takeaways

What you'll learn in this article

30 min read
Intermediate
  • 1

    create-task โ€” Create a new task with title, description, priority, and status

  • 2

    list-tasks โ€” List all tasks with optional filtering

  • 3

    update-task โ€” Update task status or details

  • 4

    get-project-stats โ€” Get summary statistics about the project

  • 5

    project://tasks โ€” The full task list as structured JSON

Keep reading for detailed implementation, code examples, and real-world results

The Model Context Protocol has become the universal standard for connecting AI assistants to external tools and data sources. Every major AI host now supports it โ€” Claude Desktop, Claude Code, VS Code Copilot, Cursor, Windsurf, and dozens more. If you build software that interacts with AI, you need to understand MCP. Not tomorrow. Today.

This tutorial walks you through building an MCP server from an empty directory to a fully functional server connected to Claude Desktop. You will build a real project tracker that lets AI assistants create tasks, query project status, and generate reports โ€” all through the standardized MCP protocol.

MCP Ecosystem Growth

11,000+

Community MCP servers published

โ†‘ 340%percent growth since Jan 2025

No hand-waving. No "exercise left to the reader." Every line of code is here, explained, and tested. By the end, you will have a working MCP server running locally and connected to your AI assistant of choice.

I wrote about the trajectory of MCP toward enterprise standardization back in late 2025, and the adoption curve has only steepened since then. The protocol is not a nice-to-have anymore โ€” it is infrastructure.

What You Will Build

The project tracker MCP server exposes three types of primitives:

Tools โ€” Actions the AI can take:

  • create-task โ€” Create a new task with title, description, priority, and status
  • list-tasks โ€” List all tasks with optional filtering
  • update-task โ€” Update task status or details
  • get-project-stats โ€” Get summary statistics about the project

Resources โ€” Read-only data the AI can access:

  • project://tasks โ€” The full task list as structured JSON
  • project://tasks/{id} โ€” Individual task details via URI template

Prompts โ€” Reusable interaction templates:

  • daily-standup โ€” Generate a standup report from current tasks
  • sprint-planning โ€” Analyze backlog and suggest sprint priorities
Pie chart data
NameValue
Tools4
Resources2
Prompts2

Prerequisites

You need Node.js 20 or later and a basic understanding of TypeScript. That is it. No framework experience required. No AI background needed. If you can write a function and import a module, you can build an MCP server.

node --version  # v20.0.0 or later
npm --version   # 10.0.0 or later

Understanding MCP Architecture

Before writing code, you need to understand how the pieces fit together. MCP uses a client-server architecture with three participants.

MCP Architecture

Host Side

HostClaude Desktop, VS Code, Cursor
ClientOne per server connection
ProtocolJSON-RPC 2.0

Server Side

ServerYour TypeScript program
TransportStdio or Streamable HTTP
PrimitivesTools, Resources, Prompts

The host is the AI application your user interacts with. Claude Desktop is a host. VS Code with Copilot is a host. Cursor is a host. The host creates one client for each MCP server it connects to.

The client maintains a dedicated connection to one server. It handles capability negotiation during initialization, routes requests between the host and server, and manages the connection lifecycle.

The server is your code. It exposes tools, resources, and prompts through a standardized JSON-RPC 2.0 protocol. The server does not know or care which host is connecting to it โ€” that is the entire point of the protocol.

Transport Layer

Communication between client and server happens over a transport. For local development, you will use stdio transport โ€” the client launches your server as a subprocess and communicates over stdin and stdout.

Bar chart data
transportlatencycomplexity
Stdio12
Streamable HTTP157
SSE (Deprecated)208

Stdio is fast, simple, and requires zero network configuration. For production remote servers, the newer Streamable HTTP transport supports multiple concurrent clients, session management, and resumability โ€” but that is out of scope for this tutorial. Master stdio first. Everything you learn transfers directly.

The Three Primitives

MCP servers expose capabilities through three primitive types:

Tools are the most important primitive. They let the AI take actions โ€” query a database, create a file, call an API, run a calculation. The AI decides when to call a tool based on the user's request and the tool's description. Every tool has a name, a description, an input schema (validated with Zod), and a handler function.

Resources expose read-only data. Think of them as GET endpoints. The AI (or the user through the host's UI) can read resources to get context. Resources have a URI, a name, a description, and a content handler.

Prompts are reusable message templates. They help users interact with the AI consistently. A prompt might generate a code review request, a standup summary, or a specific analysis format.

Bar chart data
primitiveusage
Tools78
Resources45
Prompts22

Most servers start with tools. You should too. Resources and prompts add depth, but tools are what make MCP powerful.

Advertisement

Step 1: Project Setup

Create a new directory and initialize the project.

mkdir project-tracker-mcp
cd project-tracker-mcp
npm init -y

Install the MCP SDK and Zod for schema validation.

npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
Project initialized100.0%
Dependencies installed100.0%
TypeScript configured0.0%
Server built0.0%
Connected to host0.0%

Create a tsconfig.json at the project root.

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

Update package.json with the correct settings. The critical parts are "type": "module" and the build script.

{
  "name": "project-tracker-mcp",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "project-tracker-mcp": "./build/index.js"
  },
  "scripts": {
    "build": "tsc",
    "start": "node build/index.js",
    "dev": "tsc --watch",
    "inspect": "npx @modelcontextprotocol/inspector node build/index.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.26.0",
    "zod": "^3.25.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "typescript": "^5.7.0"
  }
}

Create the source directory.

mkdir src

Your project structure should look like this:

project-tracker-mcp/
โ”œโ”€โ”€ src/
โ”‚   โ””โ”€โ”€ (empty, for now)
โ”œโ”€โ”€ package.json
โ”œโ”€โ”€ tsconfig.json
โ””โ”€โ”€ node_modules/

Step 2: Define the Data Layer

Before building the MCP server, you need a data model. Create src/store.ts โ€” an in-memory task store. In a production server, you would swap this for a database. The MCP layer does not care what your storage backend is.

// src/store.ts
import { randomUUID } from 'crypto'

export interface Task {
  id: string
  title: string
  description: string
  status: 'backlog' | 'todo' | 'in-progress' | 'review' | 'done'
  priority: 'low' | 'medium' | 'high' | 'critical'
  createdAt: string
  updatedAt: string
  assignee?: string
  tags: string[]
}

export class TaskStore {
  private tasks: Map<string, Task> = new Map()

  create(input: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Task {
    const now = new Date().toISOString()
    const task: Task = {
      ...input,
      id: randomUUID().slice(0, 8),
      createdAt: now,
      updatedAt: now,
    }
    this.tasks.set(task.id, task)
    return task
  }

  get(id: string): Task | undefined {
    return this.tasks.get(id)
  }

  list(filters?: {
    status?: Task['status']
    priority?: Task['priority']
    assignee?: string
  }): Task[] {
    let tasks = Array.from(this.tasks.values())
    if (filters?.status) {
      tasks = tasks.filter(t => t.status === filters.status)
    }
    if (filters?.priority) {
      tasks = tasks.filter(t => t.priority === filters.priority)
    }
    if (filters?.assignee) {
      tasks = tasks.filter(t => t.assignee === filters.assignee)
    }
    return tasks.sort(
      (a, b) =>
        new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
    )
  }

  update(
    id: string,
    updates: Partial<Omit<Task, 'id' | 'createdAt'>>
  ): Task | undefined {
    const task = this.tasks.get(id)
    if (!task) return undefined
    const updated: Task = {
      ...task,
      ...updates,
      updatedAt: new Date().toISOString(),
    }
    this.tasks.set(id, updated)
    return updated
  }

  delete(id: string): boolean {
    return this.tasks.delete(id)
  }

  getStats(): {
    total: number
    byStatus: Record<string, number>
    byPriority: Record<string, number>
    completionRate: number
  } {
    const tasks = Array.from(this.tasks.values())
    const byStatus: Record<string, number> = {}
    const byPriority: Record<string, number> = {}

    for (const task of tasks) {
      byStatus[task.status] = (byStatus[task.status] || 0) + 1
      byPriority[task.priority] = (byPriority[task.priority] || 0) + 1
    }

    const done = byStatus['done'] || 0
    const total = tasks.length

    return {
      total,
      byStatus,
      byPriority,
      completionRate: total > 0 ? Math.round((done / total) * 100) : 0,
    }
  }
}

Data Layer

Complete

In-memory store with CRUD operations

โ†‘ 6%methods implemented

This store is intentionally simple. The point of this tutorial is MCP, not database design. Every method returns plain objects โ€” no ORMs, no query builders. The MCP server will wrap these methods with schema validation and structured responses.

Step 3: Build the MCP Server

Now for the main event. Create src/index.ts โ€” this is where you instantiate the MCP server and register all primitives.

Server Initialization

// src/index.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { TaskStore } from './store.js'

const store = new TaskStore()

const server = new McpServer({
  name: 'project-tracker',
  version: '1.0.0',
})

Two imports from the SDK, one from Zod, one from your store. That is all you need. The McpServer class handles capability negotiation, request routing, and response formatting. You just register your primitives.

Registering Tools

Tools are the heart of your MCP server. Each tool needs four things: a name, a description, an input schema, and a handler function.

Tool 1: Create Task

server.tool(
  'create-task',
  'Create a new task in the project tracker',
  {
    title: z.string().min(1).max(200).describe('Task title'),
    description: z
      .string()
      .max(2000)
      .default('')
      .describe('Detailed task description'),
    priority: z
      .enum(['low', 'medium', 'high', 'critical'])
      .default('medium')
      .describe('Task priority level'),
    status: z
      .enum(['backlog', 'todo', 'in-progress', 'review', 'done'])
      .default('todo')
      .describe('Initial task status'),
    assignee: z.string().optional().describe('Person assigned to this task'),
    tags: z.array(z.string()).default([]).describe('Tags for categorization'),
  },
  async ({ title, description, priority, status, assignee, tags }) => {
    const task = store.create({
      title,
      description,
      priority,
      status,
      assignee,
      tags,
    })

    return {
      content: [
        {
          type: 'text',
          text: `Task created successfully.\n\nID: ${task.id}\nTitle: ${task.title}\nPriority: ${task.priority}\nStatus: ${task.status}${task.assignee ? `\nAssignee: ${task.assignee}` : ''}\nCreated: ${task.createdAt}`,
        },
      ],
    }
  }
)

Zod Schema vs Raw JSON Schema

Zod (MCP SDK)

ValidationAutomatic before handler
TypeScriptFull type inference
DefaultsBuilt-in .default()
Descriptions.describe() for AI context

Raw JSON Schema

ValidationManual parsing required
TypeScriptSeparate type definitions
DefaultsManual implementation
DescriptionsNested description fields

Notice the .describe() calls on each field. These descriptions are sent to the AI as part of the tool schema. The AI uses them to understand what each parameter means and how to populate it. Good descriptions lead to better tool invocations. Treat them like documentation โ€” because they are.

Tool 2: List Tasks

server.tool(
  'list-tasks',
  'List tasks with optional filtering by status, priority, or assignee',
  {
    status: z
      .enum(['backlog', 'todo', 'in-progress', 'review', 'done'])
      .optional()
      .describe('Filter by task status'),
    priority: z
      .enum(['low', 'medium', 'high', 'critical'])
      .optional()
      .describe('Filter by priority level'),
    assignee: z.string().optional().describe('Filter by assigned person'),
  },
  async ({ status, priority, assignee }) => {
    const tasks = store.list({ status, priority, assignee })

    if (tasks.length === 0) {
      return {
        content: [
          {
            type: 'text',
            text: 'No tasks found matching the specified filters.',
          },
        ],
      }
    }

    const taskList = tasks
      .map(
        t =>
          `[${t.id}] ${t.title}\n    Status: ${t.status} | Priority: ${t.priority}${t.assignee ? ` | Assignee: ${t.assignee}` : ''}`
      )
      .join('\n\n')

    return {
      content: [
        {
          type: 'text',
          text: `Found ${tasks.length} task${tasks.length === 1 ? '' : 's'}:\n\n${taskList}`,
        },
      ],
    }
  }
)

Tool 3: Update Task

server.tool(
  'update-task',
  "Update an existing task's status, priority, assignee, or other fields",
  {
    id: z.string().describe('Task ID to update'),
    title: z.string().optional().describe('New task title'),
    description: z.string().optional().describe('New description'),
    status: z
      .enum(['backlog', 'todo', 'in-progress', 'review', 'done'])
      .optional()
      .describe('New status'),
    priority: z
      .enum(['low', 'medium', 'high', 'critical'])
      .optional()
      .describe('New priority'),
    assignee: z.string().optional().describe('New assignee'),
  },
  async ({ id, ...updates }) => {
    const task = store.update(id, updates)

    if (!task) {
      return {
        content: [
          {
            type: 'text',
            text: `Task with ID "${id}" not found.`,
          },
        ],
        isError: true,
      }
    }

    return {
      content: [
        {
          type: 'text',
          text: `Task ${task.id} updated.\n\nTitle: ${task.title}\nStatus: ${task.status}\nPriority: ${task.priority}\nUpdated: ${task.updatedAt}`,
        },
      ],
    }
  }
)

Notice the isError: true flag when a task is not found. This tells the AI that the operation failed and it should inform the user or try a different approach. Without this flag, the AI might interpret the error message as a successful result.

Tool 4: Project Statistics

server.tool(
  'get-project-stats',
  'Get summary statistics about the project including task counts by status and priority',
  {},
  async () => {
    const stats = store.getStats()

    const statusBreakdown = Object.entries(stats.byStatus)
      .map(([status, count]) => `  ${status}: ${count}`)
      .join('\n')

    const priorityBreakdown = Object.entries(stats.byPriority)
      .map(([priority, count]) => `  ${priority}: ${count}`)
      .join('\n')

    return {
      content: [
        {
          type: 'text',
          text: `Project Statistics\n\nTotal Tasks: ${stats.total}\nCompletion Rate: ${stats.completionRate}%\n\nBy Status:\n${statusBreakdown || '  No tasks yet'}\n\nBy Priority:\n${priorityBreakdown || '  No tasks yet'}`,
        },
      ],
    }
  }
)
Project initialized100.0%
Dependencies installed100.0%
TypeScript configured100.0%
Tools registered100.0%
Resources and prompts0.0%

Registering Resources

Resources give the AI read-only access to your data. Unlike tools, resources do not take arbitrary inputs โ€” they expose data at fixed or templated URIs.

// Static resource: full task list
server.resource(
  'all-tasks',
  'project://tasks',
  {
    description: 'Complete list of all tasks in the project',
    mimeType: 'application/json',
  },
  async uri => {
    const tasks = store.list()
    return {
      contents: [
        {
          uri: uri.href,
          text: JSON.stringify(tasks, null, 2),
        },
      ],
    }
  }
)

// Dynamic resource: individual task by ID
server.resource(
  'task-by-id',
  new ResourceTemplate('project://tasks/{taskId}', {
    list: async () => {
      const tasks = store.list()
      return {
        resources: tasks.map(t => ({
          uri: `project://tasks/${t.id}`,
          name: t.title,
          description: `${t.status} | ${t.priority} priority`,
        })),
      }
    },
  }),
  {
    description: 'Individual task details by ID',
    mimeType: 'application/json',
  },
  async (uri, { taskId }) => {
    const task = store.get(taskId as string)

    if (!task) {
      return {
        contents: [
          {
            uri: uri.href,
            text: JSON.stringify({ error: 'Task not found' }),
          },
        ],
      }
    }

    return {
      contents: [
        {
          uri: uri.href,
          text: JSON.stringify(task, null, 2),
        },
      ],
    }
  }
)

The ResourceTemplate with a list callback is powerful. When a client requests the list of available resources, the list function dynamically generates entries based on current data. The AI sees each task as a browsable resource with a descriptive name.

Bar chart data
typecount
Static Resource1
Dynamic Template1
Total URIs2

Registering Prompts

Prompts are templates that structure how users interact with the AI through your server. They reduce friction for common workflows.

// Daily standup prompt
server.prompt(
  'daily-standup',
  'Generate a daily standup report from current tasks',
  {
    assignee: z
      .string()
      .optional()
      .describe('Filter standup to a specific person'),
  },
  ({ assignee }) => {
    const tasks = store.list(assignee ? { assignee } : undefined)
    const inProgress = tasks.filter(t => t.status === 'in-progress')
    const review = tasks.filter(t => t.status === 'review')
    const done = tasks.filter(
      t =>
        t.status === 'done' &&
        new Date(t.updatedAt).getTime() > Date.now() - 24 * 60 * 60 * 1000
    )

    const taskSummary = [
      inProgress.length > 0
        ? `In Progress (${inProgress.length}):\n${inProgress.map(t => `- ${t.title} [${t.id}]`).join('\n')}`
        : '',
      review.length > 0
        ? `In Review (${review.length}):\n${review.map(t => `- ${t.title} [${t.id}]`).join('\n')}`
        : '',
      done.length > 0
        ? `Completed Today (${done.length}):\n${done.map(t => `- ${t.title} [${t.id}]`).join('\n')}`
        : '',
    ]
      .filter(Boolean)
      .join('\n\n')

    return {
      messages: [
        {
          role: 'user' as const,
          content: {
            type: 'text' as const,
            text: `Generate a concise daily standup report based on these tasks:\n\n${taskSummary || 'No active tasks found.'}\n\nFormat it as: What was completed, what is in progress, and any blockers.`,
          },
        },
      ],
    }
  }
)

// Sprint planning prompt
server.prompt(
  'sprint-planning',
  'Analyze the backlog and suggest sprint priorities',
  {
    sprintCapacity: z
      .string()
      .default('10')
      .describe('How many tasks can fit in the sprint'),
  },
  ({ sprintCapacity }) => {
    const backlog = store.list({ status: 'backlog' })
    const todo = store.list({ status: 'todo' })
    const allPending = [...backlog, ...todo]

    const taskList = allPending
      .map(
        t =>
          `- [${t.id}] ${t.title} (Priority: ${t.priority}, Tags: ${t.tags.join(', ') || 'none'})`
      )
      .join('\n')

    return {
      messages: [
        {
          role: 'user' as const,
          content: {
            type: 'text' as const,
            text: `Analyze these pending tasks and recommend which ${sprintCapacity} should be prioritized for the next sprint:\n\n${taskList || 'No pending tasks.'}\n\nConsider priority levels, dependencies between tasks, and balanced workload. Explain your reasoning.`,
          },
        },
      ],
    }
  }
)
Pie chart data
NameValue
Tools registered4
Resources registered2
Prompts registered2

Starting the Server

Add the transport connection at the bottom of src/index.ts.

// Connect via stdio transport
const transport = new StdioServerTransport()
await server.connect(transport)

// Log to stderr (NEVER stdout โ€” that's for MCP messages)
console.error('Project Tracker MCP server running on stdio')

This is critical: never use console.log() in a stdio-based MCP server. The stdout stream is exclusively for JSON-RPC messages between the client and server. Any stray output corrupts the protocol. Use console.error() for logging โ€” it writes to stderr, which the host captures separately for diagnostics.

Critical Rule

No console.log()

Use console.error() โ€” stdout is for MCP only

Adding the Shebang

Add a shebang line at the very top of src/index.ts so the compiled file can run as a standalone executable.

#!/usr/bin/env node

Your complete src/index.ts starts with the shebang, followed by all the imports, the store instantiation, server creation, tool/resource/prompt registrations, and the transport connection at the bottom.

Step 4: Build and Test

Compile the TypeScript.

npm run build

If the build succeeds, you will see compiled JavaScript in the build/ directory. Make the entry point executable.

chmod +x build/index.js

Testing with MCP Inspector

The MCP Inspector is the developer tool for MCP servers. Think of it as Postman for the Model Context Protocol. It connects to your server, displays all registered primitives, and lets you invoke them interactively.

npx @modelcontextprotocol/inspector node build/index.js

This launches a browser UI at http://localhost:6274. You will see your server's capabilities listed.

Step 1

Connect

Inspector launches your server and completes capability negotiation

Step 2

Explore Tools

Click the Tools tab to see create-task, list-tasks, update-task, get-project-stats

Step 3

Test a Tool

Fill in parameters for create-task and click Execute

Step 4

Check Resources

Switch to Resources tab and browse project://tasks

Step 5

Try Prompts

Open Prompts tab and test daily-standup with sample data

Walk through each tool and verify the inputs and outputs look correct. Create a few tasks, list them, update one, and check the statistics. Then browse the resources to confirm the task data appears correctly.

The Inspector is invaluable during development. Run it continuously in a terminal while you code. Rebuild, reconnect, test. The feedback loop is fast.

Bar chart data
featureusefulness
Tool testing95
Resource browsing85
Prompt preview75
Log monitoring90
Schema inspection88

Step 5: Connect to Claude Desktop

Now connect your server to a real AI host. Open Claude Desktop's configuration file.

macOS:

open ~/Library/Application\ Support/Claude/claude_desktop_config.json

Windows:

%APPDATA%\Claude\claude_desktop_config.json

You can also access this through Claude Desktop: go to Settings then the Developer tab and click Edit Config.

Add your server to the configuration. Replace the path with the absolute path to your built index.js.

{
  "mcpServers": {
    "project-tracker": {
      "command": "node",
      "args": ["/Users/yourname/project-tracker-mcp/build/index.js"]
    }
  }
}

The path must be absolute. Relative paths will not resolve correctly. Save the file and completely quit and restart Claude Desktop โ€” not just close the window, but quit the application entirely.

After restarting, look for the hammer icon (or MCP indicator) in the bottom-right area of the chat input. Click it to see your server's tools listed.

Project initialized100.0%
Dependencies installed100.0%
TypeScript configured100.0%
Tools registered100.0%
Connected to Claude Desktop100.0%

Testing the Connection

Try these prompts in Claude Desktop to verify everything works:

  1. "Create a task for implementing user authentication with high priority" โ€” Claude should invoke create-task and return the new task ID.

  2. "What tasks do I have?" โ€” Claude should call list-tasks and show the results.

  3. "Mark task [ID] as in-progress" โ€” Claude should use update-task to change the status.

  4. "Give me project statistics" โ€” Claude should call get-project-stats.

If something is not working, check the logs.

macOS logs:

tail -f ~/Library/Logs/Claude/mcp*.log

Windows logs:

%APPDATA%\Claude\logs\

Common issues include wrong file paths in the configuration, missing "type": "module" in package.json, or console.log() calls polluting stdout.

Step 6: Connect to VS Code and Cursor

VS Code

Create a .vscode/mcp.json file in any workspace where you want the server available.

{
  "servers": {
    "project-tracker": {
      "command": "node",
      "args": ["/Users/yourname/project-tracker-mcp/build/index.js"]
    }
  }
}

VS Code also supports an inputs array for secrets and a dev key for file watching during development.

{
  "servers": {
    "project-tracker": {
      "command": "node",
      "args": ["/Users/yourname/project-tracker-mcp/build/index.js"],
      "dev": {
        "watch": "src/**/*.ts",
        "debug": true
      }
    }
  }
}

With the dev configuration, VS Code watches your TypeScript source files and automatically restarts the server when you make changes. This is excellent for active development.

Cursor

Create a .cursor/mcp.json file in the project root with the same format.

{
  "mcpServers": {
    "project-tracker": {
      "command": "node",
      "args": ["/Users/yourname/project-tracker-mcp/build/index.js"]
    }
  }
}

Host Configuration Comparison

Claude Desktop

Config location~/Library/Application Support/Claude/
Config fileclaude_desktop_config.json
RestartFull app restart required
Logs~/Library/Logs/Claude/mcp*.log

VS Code

Config location.vscode/ in workspace
Config filemcp.json
RestartAuto-reload on config change
LogsOutput panel โ†’ MCP channel

Both VS Code and Cursor load your tools at session start. The AI can then invoke them just like in Claude Desktop.

Advertisement

Error Handling Best Practices

Production MCP servers need robust error handling. Here are the patterns that matter.

Input Validation

Zod handles parameter validation automatically. If the AI sends invalid parameters, the SDK returns a structured protocol error before your handler runs. But you still need to validate business logic inside handlers.

server.tool(
  'delete-task',
  'Permanently delete a task',
  {
    id: z.string().describe('Task ID to delete'),
    confirm: z.boolean().describe('Must be true to confirm deletion'),
  },
  async ({ id, confirm }) => {
    // Business logic validation
    if (!confirm) {
      return {
        content: [
          {
            type: 'text',
            text: 'Deletion not confirmed. Set confirm to true to proceed.',
          },
        ],
        isError: true,
      }
    }

    const task = store.get(id)
    if (!task) {
      return {
        content: [{ type: 'text', text: `Task "${id}" not found.` }],
        isError: true,
      }
    }

    store.delete(id)
    return {
      content: [
        {
          type: 'text',
          text: `Task "${task.title}" (${id}) deleted permanently.`,
        },
      ],
    }
  }
)

Catching Unexpected Errors

Wrap handler logic in try-catch blocks for operations that might fail unpredictably โ€” database connections, API calls, file system access.

server.tool('export-tasks', 'Export all tasks as JSON', {}, async () => {
  try {
    const tasks = store.list()
    return {
      content: [
        {
          type: 'text',
          text: JSON.stringify(tasks, null, 2),
        },
      ],
    }
  } catch (error) {
    // Log full error to stderr for debugging
    console.error('Export failed:', error)

    // Return sanitized message to the AI
    return {
      content: [
        {
          type: 'text',
          text: 'Failed to export tasks. An internal error occurred.',
        },
      ],
      isError: true,
    }
  }
})
Bar chart data
patternreliability
Zod validation98
Business logic checks92
Try-catch handlers95
isError flag88
Stderr logging97

Never return stack traces or internal file paths in error messages sent to the client. The AI does not need them, and they can leak implementation details. Log the full error to stderr and return a clean, actionable message.

Advanced Patterns

Environment Variables

MCP servers often need API keys, database URLs, or other configuration. Pass them through the host configuration.

{
  "mcpServers": {
    "project-tracker": {
      "command": "node",
      "args": ["/path/to/build/index.js"],
      "env": {
        "DATABASE_URL": "postgresql://localhost:5432/projects",
        "API_TOKEN": "your-token-here"
      }
    }
  }
}

Access them with process.env in your server code. For sensitive values in VS Code, use the ${input:...} variable substitution.

{
  "servers": {
    "project-tracker": {
      "command": "node",
      "args": ["/path/to/build/index.js"],
      "env": {
        "API_TOKEN": "${input:api-token}"
      }
    }
  },
  "inputs": [
    {
      "type": "promptString",
      "id": "api-token",
      "description": "API Token for project tracker",
      "password": true
    }
  ]
}

VS Code prompts the user for the value at connection time and stores it securely.

Notification Support

MCP supports server-to-client notifications. Your server can notify the host when resources change, allowing the client to refresh cached data.

// After creating a task, notify clients that the resource list changed
server.tool(
  'create-task-with-notification',
  'Create a task and notify connected clients',
  {
    title: z.string(),
    priority: z.enum(['low', 'medium', 'high', 'critical']).default('medium'),
  },
  async ({ title, priority }, ctx) => {
    const task = store.create({
      title,
      description: '',
      priority,
      status: 'todo',
      tags: [],
    })

    // Send notification that resources have changed
    await server.server.sendResourceListChanged()

    return {
      content: [
        {
          type: 'text',
          text: `Task ${task.id} created: ${task.title}`,
        },
      ],
    }
  }
)

Structured Output

The June 2025 spec revision added structured tool output. You can define an output schema so clients receive machine-readable responses alongside the human-readable content.

server.tool('get-task-count', 'Get the total number of tasks', {}, async () => {
  const stats = store.getStats()

  return {
    content: [
      {
        type: 'text',
        text: `There are ${stats.total} tasks in the project.`,
      },
    ],
    structuredContent: {
      total: stats.total,
      byStatus: stats.byStatus,
      completionRate: stats.completionRate,
    },
  }
})

The content array provides human-readable text for the AI to present. The structuredContent provides machine-readable data that clients can process programmatically.

Nov 2024

MCP Launch

Anthropic releases Model Context Protocol specification and SDK

Mar 2025

Streamable HTTP

New transport replaces deprecated SSE, adds session management

Jun 2025

Structured Output

Tools can return machine-readable data alongside text

Nov 2025

Tasks and Auth

Experimental tasks primitive, step-up authorization, OAuth enhancements

Feb 2026

Universal Adoption

All major AI hosts support MCP as the interoperability standard

Testing Your Server

If you have followed along to this point, the tool-calling fundamentals are already working through the existing tutorials on this site. For readers building production MCP servers, I covered automated testing patterns for MCP in the mcp-test-kit tutorial, which walks through unit and integration testing with Vitest.

For quick manual verification during development, the Inspector is your best friend.

npm run inspect

Walk through this checklist every time you make changes:

  1. All tools appear in the Tools tab with correct schemas
  2. Creating a task returns the expected response format
  3. Listing tasks with filters returns the correct subset
  4. Updating a nonexistent task returns isError: true
  5. Resources reflect the current state after tool invocations
  6. Prompts generate well-structured messages
Bar chart data
methodspeedcoverage
MCP Inspector9570
Vitest unit tests6095
Manual in Claude3050
E2E integration2098

Publishing Your Server

Once your server is working locally, you can publish it to npm so others can use it.

Update package.json with a descriptive name and set the bin field.

{
  "name": "@yourname/project-tracker-mcp",
  "version": "1.0.0",
  "description": "MCP server for project task tracking",
  "type": "module",
  "bin": {
    "project-tracker-mcp": "./build/index.js"
  },
  "files": ["build"],
  "scripts": {
    "build": "tsc",
    "prepublishOnly": "npm run build"
  }
}

Build and publish.

npm run build
npm publish --access public

Users can then connect to your server with a single npx command in their host configuration.

{
  "mcpServers": {
    "project-tracker": {
      "command": "npx",
      "args": ["-y", "@yourname/project-tracker-mcp"]
    }
  }
}

Distribution

npm publish

One command to share your server globally

โ†‘ 11000%community servers and counting

Real-World Use Cases

The project tracker is a teaching example. In production, MCP servers wrap real systems. Here are the patterns I see gaining the most traction.

Database Access

Connect the AI to your PostgreSQL, MySQL, or SQLite database. The server exposes query tools with parameterized inputs and read-only resource access to schema definitions. The AI can help users explore data, write queries, and generate reports without needing direct database credentials.

API Wrappers

Wrap any REST or GraphQL API as an MCP server. GitHub issues, Jira tickets, Slack messages, Notion pages โ€” anything with an API becomes accessible to any MCP-compatible AI host. The server handles authentication, rate limiting, and response formatting.

DevOps Integration

Connect CI/CD pipelines, container orchestration, and cloud infrastructure. An MCP server wrapping Kubernetes lets the AI check pod status, review deployment configs, and troubleshoot issues without the user needing to remember kubectl commands.

File System Tools

Build servers that give the AI controlled access to file systems โ€” reading configs, searching codebases, generating file diffs. The official filesystem server is a good reference implementation, and if you want to understand the broader context of AI-powered tool calling in production, I covered the architectural patterns in depth previously.

Pie chart data
NameValue
Database access28
API wrappers32
DevOps/Infrastructure18
File system12
Knowledge/Search10

What Changed in the MCP Spec

The MCP specification has evolved rapidly. If you read older tutorials from late 2024, some patterns are now outdated.

The standalone SSE transport is deprecated. Use Streamable HTTP for remote servers. Stdio remains the standard for local servers.

Elicitation (June 2025) lets servers pause tool execution and ask the user for additional input. If your tool needs a confirmation or a choice, the server can request it through the protocol instead of returning an error.

Structured output (June 2025) adds machine-readable responses alongside human-readable content. Define an outputSchema for your tool and return structuredContent in addition to the content array.

Tasks (November 2025, experimental) enable long-running operations. Instead of blocking until completion, a tool can return a task handle that the client polls for status. This matters for expensive computations, batch processing, and multi-step workflows.

OAuth 2.1 with PKCE (June 2025) standardizes authentication for remote MCP servers. Servers can now act as OAuth Resource Servers with proper token validation and scope management.

Bar chart data
featurestatus
Stdio transport100
Streamable HTTP100
SSE (deprecated)20
Elicitation90
Structured output85
Tasks40
OAuth 2.175

The Complete Source

Here is the full src/index.ts for reference. The src/store.ts file was shown earlier in Step 2.

#!/usr/bin/env node

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { TaskStore } from './store.js'

const store = new TaskStore()

const server = new McpServer({
  name: 'project-tracker',
  version: '1.0.0',
})

// === TOOLS ===

server.tool(
  'create-task',
  'Create a new task in the project tracker',
  {
    title: z.string().min(1).max(200).describe('Task title'),
    description: z
      .string()
      .max(2000)
      .default('')
      .describe('Detailed task description'),
    priority: z
      .enum(['low', 'medium', 'high', 'critical'])
      .default('medium')
      .describe('Task priority level'),
    status: z
      .enum(['backlog', 'todo', 'in-progress', 'review', 'done'])
      .default('todo')
      .describe('Initial task status'),
    assignee: z.string().optional().describe('Person assigned to this task'),
    tags: z.array(z.string()).default([]).describe('Tags for categorization'),
  },
  async ({ title, description, priority, status, assignee, tags }) => {
    const task = store.create({
      title,
      description,
      priority,
      status,
      assignee,
      tags,
    })
    return {
      content: [
        {
          type: 'text',
          text: `Task created successfully.\n\nID: ${task.id}\nTitle: ${task.title}\nPriority: ${task.priority}\nStatus: ${task.status}${task.assignee ? `\nAssignee: ${task.assignee}` : ''}\nCreated: ${task.createdAt}`,
        },
      ],
    }
  }
)

server.tool(
  'list-tasks',
  'List tasks with optional filtering by status, priority, or assignee',
  {
    status: z
      .enum(['backlog', 'todo', 'in-progress', 'review', 'done'])
      .optional()
      .describe('Filter by task status'),
    priority: z
      .enum(['low', 'medium', 'high', 'critical'])
      .optional()
      .describe('Filter by priority level'),
    assignee: z.string().optional().describe('Filter by assigned person'),
  },
  async ({ status, priority, assignee }) => {
    const tasks = store.list({ status, priority, assignee })
    if (tasks.length === 0) {
      return {
        content: [
          {
            type: 'text',
            text: 'No tasks found matching the specified filters.',
          },
        ],
      }
    }
    const taskList = tasks
      .map(
        t =>
          `[${t.id}] ${t.title}\n    Status: ${t.status} | Priority: ${t.priority}${t.assignee ? ` | Assignee: ${t.assignee}` : ''}`
      )
      .join('\n\n')
    return {
      content: [
        {
          type: 'text',
          text: `Found ${tasks.length} task${tasks.length === 1 ? '' : 's'}:\n\n${taskList}`,
        },
      ],
    }
  }
)

server.tool(
  'update-task',
  "Update an existing task's status, priority, assignee, or other fields",
  {
    id: z.string().describe('Task ID to update'),
    title: z.string().optional().describe('New task title'),
    description: z.string().optional().describe('New description'),
    status: z
      .enum(['backlog', 'todo', 'in-progress', 'review', 'done'])
      .optional()
      .describe('New status'),
    priority: z
      .enum(['low', 'medium', 'high', 'critical'])
      .optional()
      .describe('New priority'),
    assignee: z.string().optional().describe('New assignee'),
  },
  async ({ id, ...updates }) => {
    const task = store.update(id, updates)
    if (!task) {
      return {
        content: [{ type: 'text', text: `Task with ID "${id}" not found.` }],
        isError: true,
      }
    }
    return {
      content: [
        {
          type: 'text',
          text: `Task ${task.id} updated.\n\nTitle: ${task.title}\nStatus: ${task.status}\nPriority: ${task.priority}\nUpdated: ${task.updatedAt}`,
        },
      ],
    }
  }
)

server.tool(
  'get-project-stats',
  'Get summary statistics about the project including task counts by status and priority',
  {},
  async () => {
    const stats = store.getStats()
    const statusBreakdown = Object.entries(stats.byStatus)
      .map(([s, c]) => `  ${s}: ${c}`)
      .join('\n')
    const priorityBreakdown = Object.entries(stats.byPriority)
      .map(([p, c]) => `  ${p}: ${c}`)
      .join('\n')
    return {
      content: [
        {
          type: 'text',
          text: `Project Statistics\n\nTotal Tasks: ${stats.total}\nCompletion Rate: ${stats.completionRate}%\n\nBy Status:\n${statusBreakdown || '  No tasks yet'}\n\nBy Priority:\n${priorityBreakdown || '  No tasks yet'}`,
        },
      ],
    }
  }
)

// === RESOURCES ===

server.resource(
  'all-tasks',
  'project://tasks',
  {
    description: 'Complete list of all tasks in the project',
    mimeType: 'application/json',
  },
  async uri => ({
    contents: [{ uri: uri.href, text: JSON.stringify(store.list(), null, 2) }],
  })
)

server.resource(
  'task-by-id',
  new ResourceTemplate('project://tasks/{taskId}', {
    list: async () => ({
      resources: store.list().map(t => ({
        uri: `project://tasks/${t.id}`,
        name: t.title,
        description: `${t.status} | ${t.priority} priority`,
      })),
    }),
  }),
  {
    description: 'Individual task details by ID',
    mimeType: 'application/json',
  },
  async (uri, { taskId }) => {
    const task = store.get(taskId as string)
    return {
      contents: [
        {
          uri: uri.href,
          text: JSON.stringify(task || { error: 'Task not found' }, null, 2),
        },
      ],
    }
  }
)

// === PROMPTS ===

server.prompt(
  'daily-standup',
  'Generate a daily standup report from current tasks',
  {
    assignee: z
      .string()
      .optional()
      .describe('Filter standup to a specific person'),
  },
  ({ assignee }) => {
    const tasks = store.list(assignee ? { assignee } : undefined)
    const inProgress = tasks.filter(t => t.status === 'in-progress')
    const review = tasks.filter(t => t.status === 'review')
    const done = tasks.filter(
      t =>
        t.status === 'done' &&
        new Date(t.updatedAt).getTime() > Date.now() - 86400000
    )
    const sections = [
      inProgress.length > 0
        ? `In Progress (${inProgress.length}):\n${inProgress.map(t => `- ${t.title} [${t.id}]`).join('\n')}`
        : '',
      review.length > 0
        ? `In Review (${review.length}):\n${review.map(t => `- ${t.title} [${t.id}]`).join('\n')}`
        : '',
      done.length > 0
        ? `Completed Today (${done.length}):\n${done.map(t => `- ${t.title} [${t.id}]`).join('\n')}`
        : '',
    ]
      .filter(Boolean)
      .join('\n\n')
    return {
      messages: [
        {
          role: 'user' as const,
          content: {
            type: 'text' as const,
            text: `Generate a concise daily standup report based on these tasks:\n\n${sections || 'No active tasks found.'}\n\nFormat it as: What was completed, what is in progress, and any blockers.`,
          },
        },
      ],
    }
  }
)

server.prompt(
  'sprint-planning',
  'Analyze the backlog and suggest sprint priorities',
  {
    sprintCapacity: z
      .string()
      .default('10')
      .describe('How many tasks can fit in the sprint'),
  },
  ({ sprintCapacity }) => {
    const allPending = [
      ...store.list({ status: 'backlog' }),
      ...store.list({ status: 'todo' }),
    ]
    const taskList = allPending
      .map(
        t =>
          `- [${t.id}] ${t.title} (Priority: ${t.priority}, Tags: ${t.tags.join(', ') || 'none'})`
      )
      .join('\n')
    return {
      messages: [
        {
          role: 'user' as const,
          content: {
            type: 'text' as const,
            text: `Analyze these pending tasks and recommend which ${sprintCapacity} should be prioritized for the next sprint:\n\n${taskList || 'No pending tasks.'}\n\nConsider priority levels, dependencies between tasks, and balanced workload. Explain your reasoning.`,
          },
        },
      ],
    }
  }
)

// === START SERVER ===

const transport = new StdioServerTransport()
await server.connect(transport)
console.error('Project Tracker MCP server running on stdio')

GitHub Repository

The complete source code for this tutorial is available at github.com/CrashBytes/project-tracker-mcp. Clone it, build it, and connect it to your AI host of choice.

git clone https://github.com/CrashBytes/project-tracker-mcp.git
cd project-tracker-mcp
npm install
npm run build
npm run inspect

Where to Go Next

MCP is moving fast. The November 2025 spec revision added experimental support for Tasks (long-running operations), step-up authorization (incremental OAuth scopes), and an extensions framework. The SDK v2 monorepo split is in pre-alpha and will ship stable packages in Q1 2026.

The acquisition activity around AI agent infrastructure signals that this space is accelerating, not slowing down. Learning MCP now puts you ahead of the curve.

For production servers, invest in proper testing โ€” the MCP test kit tutorial covers automated testing patterns that go beyond manual Inspector validation. For understanding the broader tool-calling architecture that MCP implements, the production agentic AI tool calling guide provides the conceptual foundation.

Build something useful. Wrap that API your team queries every day. Give the AI access to your project management tool. Connect your monitoring dashboard. The protocol is standardized. The SDK is stable. The only thing missing is your server.

Tutorial Complete

8 Primitives

4 tools, 2 resources, 2 prompts โ€” your first MCP server

โ†‘ 100%percent of foundation covered
Advertisement

Was this article helpful?

Your feedback helps us improve our content and create more valuable resources

We appreciate honest feedback - it helps us serve you better

Work with us

This analysis is what we do for clients

CrashBytes consults on enterprise AI strategy and implementation, builds custom web and mobile software, and places senior engineers on corp-to-corp engagements.

See Services

Enjoyed this? Get the next one.

Join developers getting CrashBytes articles, tutorials, and predictions in their inbox. No spam, unsubscribe anytime.

Related Topics

TypeScriptMCPAITutorialDeveloper Tools
Back to Articles
โ† PreviousThe $2.5 Trillion Paradox: Why 90% of Companies See Zero Returns from AINext โ†’The AI Fluency Gap: Why 95% of Workers Are Falling Behind (And How to Catch Up)

From across the CrashBytes network

More than the blog โ€” predictions, news, fiction, and AI art.

PredictionCustom AI Chips Reach Commodity Status by Q4 2027: Cloud Provider Competition Drives Democratization
NewsWeek In Review July 19-25, 2026 - The Week The Money Moved To The Metering Layer
Short StoryThe Answer Key
AI ArtThe Room That Remembers

Continue Your Learning Journey

Explore more articles related to Tutorial and expand your knowledge.

๐Ÿ“„Tutorial

Build a Cost-Aware Multi-Model AI Router in TypeScript

A complete hands-on tutorial for routing prompts to the cheapest capable LLM in TypeScript. Build a classifier, a model registry, a fallback ladder, and per-request cost telemetry that survives the May 2026 price war.

25 min readRead more
๐Ÿ“„Tutorial

Building a Production Async Agent Queue in TypeScript with Bun and Mistral Work Mode: A 2026 Tutorial

An end-to-end tutorial for engineers shipping long-running coding agents in production. We build a TypeScript queue on Bun that submits agent jobs to Mistral Le Chat Work mode and OpenAI background mode, polls or receives webhooks for completion, enforces per-job cost ceilings, and exposes a small status dashboard โ€” the operating model behind the new async-coding paradigm.

26 min readRead more
๐Ÿ“„Tutorial

Migrating Your Coding Agent from GPT-5 to DeepSeek V4: A TypeScript Tutorial

A practical, end-to-end migration guide for engineers running production coding agents on GPT-5 who want to evaluate or move to DeepSeek V4 โ€” the open-source frontier model that landed Friday claiming the strongest agentic coding scores in the open ecosystem. Covers API differences, tool-calling adaptation, streaming, the agent loop, evaluation, and the real cost math.

25 min readRead more
๐Ÿ“„Tutorial

Deploy a Remote MCP Server with Streamable HTTP on Cloudflare Workers

A complete hands-on tutorial for building and deploying a remote Model Context Protocol server using the new Streamable HTTP transport on Cloudflare Workers. Go beyond local stdio servers and make your AI tools accessible from anywhere.

24 min readRead more