Module 4: MCP Server in TypeScript

Project: Complete TypeScript MCP Server

Project: Complete TypeScript MCP Server

Capsule description

This is the integration moment. In capsules 02-05 you learned each piece separately: project setup, tools with Zod, resources with URI templates, and transports. Now you're going to build a complete MCP server that combines everything into a real use case.

The project is a TaskFlow MCP Server — a task management server that Claude Code can use to create, organize, search, and manage development tasks. It's not a toy example: it's the kind of server a developer would build to integrate their task management system with Claude Code.

By the end of this capsule, you'll have a functional MCP server with multiple tools, resources, complete validation with Zod, robust error handling, and connected to Claude Code.


The project: TaskFlow MCP Server

What you'll build

taskflow-mcp-server/
├── package.json
├── tsconfig.json
└── src/
    ├── index.ts              ← Entry point
    ├── tools/
    │   ├── task-tools.ts     ← Task CRUD
    │   ├── tag-tools.ts      ← Tag management
    │   └── search-tools.ts   ← Search and filters
    ├── resources/
    │   ├── task-resources.ts  ← Task resources
    │   └── stats-resources.ts ← Statistics
    └── store/
        └── task-store.ts      ← In-memory storage

Capabilities

Tools:

  • create_task — Create a new task with title, description, priority, tags
  • update_task — Update fields of an existing task
  • complete_task — Mark a task as completed
  • delete_task — Delete a task
  • search_tasks — Search tasks with filters (status, priority, tags, text)
  • manage_tags — Create, list, and delete tags

Resources:

  • tasks://all — Complete list of tasks
  • tasks://task/{id} — Detail of a specific task
  • tasks://stats — Statistics: total, by status, by priority

Step 1: Project setup

Create the project

mkdir taskflow-mcp-server
cd taskflow-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

package.json

{
  "name": "taskflow-mcp-server",
  "version": "1.0.0",
  "description": "MCP Server for development task management",
  "type": "module",
  "main": "build/index.js",
  "bin": {
    "taskflow-mcp": "build/index.js"
  },
  "scripts": {
    "build": "tsc",
    "start": "node build/index.js",
    "dev": "tsc --watch",
    "inspect": "npm run build && npx @modelcontextprotocol/inspector node build/index.js"
  }
}

tsconfig.json

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

Create the structure

mkdir -p src/tools src/resources src/store

Step 2: The store — data storage

We start with the store because tools and resources need it. It's an in-memory store with persistence to a JSON file.

src/store/task-store.ts:

import * as fs from "fs/promises";
import * as path from "path";
import { randomUUID } from "crypto";

export type Priority = "low" | "medium" | "high" | "critical";
export type TaskStatus = "pending" | "in_progress" | "completed" | "cancelled";

export interface Task {
  id: string;
  title: string;
  description: string;
  status: TaskStatus;
  priority: Priority;
  tags: string[];
  createdAt: string;
  updatedAt: string;
  completedAt?: string;
}

interface StoreData {
  tasks: Task[];
  tags: string[];
}

const DATA_FILE = process.env.TASKFLOW_DATA || path.join(process.cwd(), "taskflow-data.json");

let store: StoreData = {
  tasks: [],
  tags: ["bug", "feature", "refactor", "docs", "test", "chore"],
};

export async function loadStore(): Promise<void> {
  try {
    const data = await fs.readFile(DATA_FILE, "utf-8");
    store = JSON.parse(data);
  } catch {
    await saveStore();
  }
}

async function saveStore(): Promise<void> {
  await fs.writeFile(DATA_FILE, JSON.stringify(store, null, 2), "utf-8");
}

export function getAllTasks(): Task[] {
  return [...store.tasks];
}

export function getTaskById(id: string): Task | undefined {
  return store.tasks.find(t => t.id === id);
}

export async function createTask(
  title: string,
  description: string,
  priority: Priority,
  tags: string[]
): Promise<Task> {
  const task: Task = {
    id: randomUUID().slice(0, 8),
    title,
    description,
    status: "pending",
    priority,
    tags,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  };

  store.tasks.push(task);
  await saveStore();
  return task;
}

export async function updateTask(
  id: string,
  updates: Partial<Pick<Task, "title" | "description" | "priority" | "tags" | "status">>
): Promise<Task | null> {
  const task = store.tasks.find(t => t.id === id);
  if (!task) return null;

  if (updates.title !== undefined) task.title = updates.title;
  if (updates.description !== undefined) task.description = updates.description;
  if (updates.priority !== undefined) task.priority = updates.priority;
  if (updates.tags !== undefined) task.tags = updates.tags;
  if (updates.status !== undefined) task.status = updates.status;
  task.updatedAt = new Date().toISOString();

  if (updates.status === "completed") {
    task.completedAt = new Date().toISOString();
  }

  await saveStore();
  return task;
}

export async function deleteTask(id: string): Promise<Task | null> {
  const index = store.tasks.findIndex(t => t.id === id);
  if (index === -1) return null;

  const removed = store.tasks.splice(index, 1)[0];
  await saveStore();
  return removed;
}

export function searchTasks(filters: {
  status?: TaskStatus;
  priority?: Priority;
  tags?: string[];
  query?: string;
}): Task[] {
  return store.tasks.filter(task => {
    if (filters.status && task.status !== filters.status) return false;
    if (filters.priority && task.priority !== filters.priority) return false;
    if (filters.tags?.length) {
      const hasMatchingTag = filters.tags.some(tag => task.tags.includes(tag));
      if (!hasMatchingTag) return false;
    }
    if (filters.query) {
      const q = filters.query.toLowerCase();
      const inTitle = task.title.toLowerCase().includes(q);
      const inDesc = task.description.toLowerCase().includes(q);
      if (!inTitle && !inDesc) return false;
    }
    return true;
  });
}

export function getAllTags(): string[] {
  return [...store.tags];
}

export async function addTag(tag: string): Promise<boolean> {
  if (store.tags.includes(tag)) return false;
  store.tags.push(tag);
  await saveStore();
  return true;
}

export async function removeTag(tag: string): Promise<boolean> {
  const index = store.tags.indexOf(tag);
  if (index === -1) return false;
  store.tags.splice(index, 1);
  await saveStore();
  return true;
}

export function getStats(): {
  total: number;
  byStatus: Record<TaskStatus, number>;
  byPriority: Record<Priority, number>;
  completionRate: string;
  avgCompletionTime: string | null;
} {
  const tasks = store.tasks;
  const total = tasks.length;

  const byStatus: Record<TaskStatus, number> = {
    pending: 0, in_progress: 0, completed: 0, cancelled: 0,
  };
  const byPriority: Record<Priority, number> = {
    low: 0, medium: 0, high: 0, critical: 0,
  };

  const completionTimes: number[] = [];

  for (const task of tasks) {
    byStatus[task.status]++;
    byPriority[task.priority]++;

    if (task.completedAt) {
      const created = new Date(task.createdAt).getTime();
      const completed = new Date(task.completedAt).getTime();
      completionTimes.push(completed - created);
    }
  }

  const completionRate = total > 0
    ? ((byStatus.completed / total) * 100).toFixed(1) + "%"
    : "N/A";

  let avgCompletionTime: string | null = null;
  if (completionTimes.length > 0) {
    const avgMs = completionTimes.reduce((a, b) => a + b, 0) / completionTimes.length;
    const avgHours = avgMs / (1000 * 60 * 60);
    avgCompletionTime = avgHours < 1
      ? `${(avgMs / (1000 * 60)).toFixed(0)} minutes`
      : `${avgHours.toFixed(1)} hours`;
  }

  return { total, byStatus, byPriority, completionRate, avgCompletionTime };
}

Step 3: The tools

Task tools

src/tools/task-tools.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import * as store from "../store/task-store.js";

export function registerTaskTools(server: McpServer): void {
  server.tool(
    "create_task",
    "Creates a new development task with title, description, priority, and tags. Returns the created task with its unique ID.",
    {
      title: z.string().min(1).max(200)
        .describe("Task title (e.g., 'Implement JWT authentication')"),
      description: z.string().min(1)
        .describe("Detailed description of the task"),
      priority: z.enum(["low", "medium", "high", "critical"]).default("medium")
        .describe("Task priority"),
      tags: z.array(z.string()).default([])
        .describe("Tags to categorize (e.g., ['bug', 'backend'])"),
    },
    async ({ title, description, priority, tags }) => {
      const task = await store.createTask(title, description, priority, tags);

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            message: `Task created: "${task.title}"`,
            task,
          }, null, 2),
        }],
      };
    }
  );

  server.tool(
    "update_task",
    "Updates the fields of an existing task. Only send the fields you want to change.",
    {
      taskId: z.string().describe("ID of the task to update"),
      title: z.string().min(1).max(200).optional()
        .describe("New title"),
      description: z.string().optional()
        .describe("New description"),
      priority: z.enum(["low", "medium", "high", "critical"]).optional()
        .describe("New priority"),
      status: z.enum(["pending", "in_progress", "completed", "cancelled"]).optional()
        .describe("New status"),
      tags: z.array(z.string()).optional()
        .describe("New tags (replaces the existing ones)"),
    },
    async ({ taskId, title, description, priority, status, tags }) => {
      const updates: Record<string, unknown> = {};
      if (title !== undefined) updates.title = title;
      if (description !== undefined) updates.description = description;
      if (priority !== undefined) updates.priority = priority;
      if (status !== undefined) updates.status = status;
      if (tags !== undefined) updates.tags = tags;

      if (Object.keys(updates).length === 0) {
        return {
          content: [{ type: "text" as const, text: "Error: no fields specified to update" }],
          isError: true,
        };
      }

      const task = await store.updateTask(taskId, updates as any);

      if (!task) {
        return {
          content: [{ type: "text" as const, text: `Error: task '${taskId}' not found` }],
          isError: true,
        };
      }

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            message: `Task updated: "${task.title}"`,
            updatedFields: Object.keys(updates),
            task,
          }, null, 2),
        }],
      };
    }
  );

  server.tool(
    "complete_task",
    "Marks a task as completed. Records the completion date automatically.",
    {
      taskId: z.string().describe("ID of the task to complete"),
    },
    async ({ taskId }) => {
      const task = await store.updateTask(taskId, { status: "completed" });

      if (!task) {
        return {
          content: [{ type: "text" as const, text: `Error: task '${taskId}' not found` }],
          isError: true,
        };
      }

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            message: `✅ Task completed: "${task.title}"`,
            completedAt: task.completedAt,
            task,
          }, null, 2),
        }],
      };
    }
  );

  server.tool(
    "delete_task",
    "Deletes a task permanently. This action can't be undone.",
    {
      taskId: z.string().describe("ID of the task to delete"),
    },
    async ({ taskId }) => {
      const task = await store.deleteTask(taskId);

      if (!task) {
        return {
          content: [{ type: "text" as const, text: `Error: task '${taskId}' not found` }],
          isError: true,
        };
      }

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            message: `Task deleted: "${task.title}"`,
            deletedTask: task,
          }, null, 2),
        }],
      };
    }
  );
}

Search tools

src/tools/search-tools.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import * as store from "../store/task-store.js";

export function registerSearchTools(server: McpServer): void {
  server.tool(
    "search_tasks",
    "Searches and filters tasks by status, priority, tags, or text. Combine filters for precise searches.",
    {
      status: z.enum(["pending", "in_progress", "completed", "cancelled"]).optional()
        .describe("Filter by status"),
      priority: z.enum(["low", "medium", "high", "critical"]).optional()
        .describe("Filter by priority"),
      tags: z.array(z.string()).optional()
        .describe("Filter by tags (any match)"),
      query: z.string().optional()
        .describe("Search text in title and description"),
      sortBy: z.enum(["created", "updated", "priority"]).default("created")
        .describe("Sort the results"),
      limit: z.number().int().positive().default(20)
        .describe("Maximum number of results"),
    },
    async ({ status, priority, tags, query, sortBy, limit }) => {
      let results = store.searchTasks({ status, priority, tags, query });

      const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };

      switch (sortBy) {
        case "created":
          results.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
          break;
        case "updated":
          results.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
          break;
        case "priority":
          results.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
          break;
      }

      const truncated = results.length > limit;
      results = results.slice(0, limit);

      const activeFilters: string[] = [];
      if (status) activeFilters.push(`status=${status}`);
      if (priority) activeFilters.push(`priority=${priority}`);
      if (tags?.length) activeFilters.push(`tags=${tags.join(",")}`);
      if (query) activeFilters.push(`query="${query}"`);

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            filters: activeFilters.length > 0 ? activeFilters : ["none (showing all)"],
            sortedBy: sortBy,
            totalResults: results.length,
            truncated,
            tasks: results,
          }, null, 2),
        }],
      };
    }
  );
}

Tag tools

src/tools/tag-tools.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import * as store from "../store/task-store.js";

export function registerTagTools(server: McpServer): void {
  server.tool(
    "manage_tags",
    "Manages the available tags: create new ones, list existing ones, or delete tags.",
    {
      action: z.enum(["list", "create", "delete"])
        .describe("Action to perform"),
      tag: z.string().optional()
        .describe("Tag name (required for create and delete)"),
    },
    async ({ action, tag }) => {
      switch (action) {
        case "list": {
          const tags = store.getAllTags();
          const tasks = store.getAllTasks();
          const tagCounts: Record<string, number> = {};

          for (const t of tags) {
            tagCounts[t] = tasks.filter(task => task.tags.includes(t)).length;
          }

          return {
            content: [{
              type: "text" as const,
              text: JSON.stringify({
                totalTags: tags.length,
                tags: tags.map(t => ({
                  name: t,
                  taskCount: tagCounts[t],
                })),
              }, null, 2),
            }],
          };
        }

        case "create": {
          if (!tag) {
            return {
              content: [{ type: "text" as const, text: "Error: 'tag' is required to create" }],
              isError: true,
            };
          }

          const created = await store.addTag(tag.toLowerCase());
          if (!created) {
            return {
              content: [{ type: "text" as const, text: `Tag '${tag}' already exists` }],
              isError: true,
            };
          }

          return {
            content: [{
              type: "text" as const,
              text: JSON.stringify({
                message: `Tag '${tag}' created`,
                allTags: store.getAllTags(),
              }, null, 2),
            }],
          };
        }

        case "delete": {
          if (!tag) {
            return {
              content: [{ type: "text" as const, text: "Error: 'tag' is required to delete" }],
              isError: true,
            };
          }

          const deleted = await store.removeTag(tag);
          if (!deleted) {
            return {
              content: [{ type: "text" as const, text: `Tag '${tag}' not found` }],
              isError: true,
            };
          }

          return {
            content: [{
              type: "text" as const,
              text: JSON.stringify({
                message: `Tag '${tag}' deleted`,
                remainingTags: store.getAllTags(),
              }, null, 2),
            }],
          };
        }
      }
    }
  );
}

Step 4: The resources

Task resources

src/resources/task-resources.ts:

import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import * as store from "../store/task-store.js";

export function registerTaskResources(server: McpServer): void {
  server.resource(
    "all-tasks",
    "tasks://all",
    {
      description: "Complete list of all tasks with their current status",
      mimeType: "application/json",
    },
    async (uri) => {
      const tasks = store.getAllTasks();

      return {
        contents: [{
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify({
            totalTasks: tasks.length,
            tasks: tasks.map(t => ({
              id: t.id,
              title: t.title,
              status: t.status,
              priority: t.priority,
              tags: t.tags,
              createdAt: t.createdAt,
            })),
            generatedAt: new Date().toISOString(),
          }, null, 2),
        }],
      };
    }
  );

  server.resource(
    "task-detail",
    new ResourceTemplate("tasks://task/{taskId}", {
      list: async () => {
        const tasks = store.getAllTasks();
        return tasks.map(t => ({
          uri: `tasks://task/${t.id}`,
          name: `${t.title} [${t.status}]`,
          description: `Task ${t.id}: ${t.title} — ${t.priority} priority`,
        }));
      },
    }),
    {
      description: "Complete detail of a specific task by its ID",
      mimeType: "application/json",
    },
    async (uri, params) => {
      const taskId = params.taskId as string;
      const task = store.getTaskById(taskId);

      if (!task) {
        throw new Error(`Task '${taskId}' not found`);
      }

      return {
        contents: [{
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify(task, null, 2),
        }],
      };
    }
  );
}

Stats resources

src/resources/stats-resources.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import * as store from "../store/task-store.js";

export function registerStatsResources(server: McpServer): void {
  server.resource(
    "task-stats",
    "tasks://stats",
    {
      description: "Task statistics: totals, by status, by priority, completion rate",
      mimeType: "application/json",
    },
    async (uri) => {
      const stats = store.getStats();
      const tags = store.getAllTags();
      const tasks = store.getAllTasks();

      const tagStats = tags.map(tag => ({
        tag,
        count: tasks.filter(t => t.tags.includes(tag)).length,
      })).sort((a, b) => b.count - a.count);

      return {
        contents: [{
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify({
            ...stats,
            topTags: tagStats.slice(0, 5),
            availableTags: tags,
            generatedAt: new Date().toISOString(),
          }, null, 2),
        }],
      };
    }
  );
}

Step 5: The entry point

src/index.ts:

#!/usr/bin/env node

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { loadStore } from "./store/task-store.js";
import { registerTaskTools } from "./tools/task-tools.js";
import { registerSearchTools } from "./tools/search-tools.js";
import { registerTagTools } from "./tools/tag-tools.js";
import { registerTaskResources } from "./resources/task-resources.js";
import { registerStatsResources } from "./resources/stats-resources.js";

const server = new McpServer({
  name: "taskflow-mcp-server",
  version: "1.0.0",
});

registerTaskTools(server);
registerSearchTools(server);
registerTagTools(server);
registerTaskResources(server);
registerStatsResources(server);

async function main() {
  await loadStore();

  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("TaskFlow MCP Server running on stdio");
  console.error(`Data file: ${process.env.TASKFLOW_DATA || "taskflow-data.json"}`);
}

main().catch((error) => {
  console.error("Fatal error:", error);
  process.exit(1);
});

Step 6: Compile and test

Compile

npm run build

If there are compilation errors, review them. The most common ones:

  • Import paths without .js: All local imports need the .js extension
  • Missing types: Verify that each function has explicit or inferred return types
  • strict mode violations: Verify there's no implicit any

Verify that it starts

node build/index.js

Expected output:

TaskFlow MCP Server running on stdio
Data file: taskflow-data.json

Test with MCP Inspector

npm run inspect

In the Inspector, verify:

Tools tab:

  • create_task — visible with all parameters
  • update_task — visible
  • complete_task — visible
  • delete_task — visible
  • search_tasks — visible with filters
  • manage_tags — visible

Resources tab:

  • tasks://all — empty list (no tasks yet)
  • tasks://stats — statistics with zeros
  • tasks://task/{taskId} — template visible

Functional test

  1. Create a task:
{
  "title": "Implement JWT authentication",
  "description": "Add login/register endpoints with JWT tokens",
  "priority": "high",
  "tags": ["feature", "backend"]
}
  1. Create another task:
{
  "title": "Fix bug in email validation",
  "description": "The current regex accepts emails without a domain",
  "priority": "critical",
  "tags": ["bug", "backend"]
}
  1. Read the tasks://all resource — you should see both tasks.

  2. Search tasks:

{
  "priority": "critical"
}

It should return only the bug task.

  1. Complete a task: Use the ID of the first task.

  2. Read tasks://stats — it should show 2 total tasks, 1 completed, 1 pending.


Step 7: Connect to Claude Code

claude mcp add taskflow -s user -- node $(pwd)/build/index.js

Verify

claude
/mcp

You should see:

MCP Servers:
  taskflow: connected
    Tools:
      - create_task
      - update_task
      - complete_task
      - delete_task
      - search_tasks
      - manage_tags
    Resources:
      - tasks://all
      - tasks://task/{taskId}
      - tasks://stats

Tests in Claude Code

Try these prompts:

"Create a high-priority task to implement rate limiting in the API, with the backend and feature tags"
"Show me all the pending tasks"
"What are the current statistics of my tasks?"
"Search for tasks with the 'bug' tag sorted by priority"
"Mark the rate limiting task as completed"

Notice how Claude Code automatically selects the correct tool or resource based on what you ask for.


Step 8: Final verification

Checklist

  • The project compiles without errors (npm run build)
  • The server starts (shows a log in stderr)
  • MCP Inspector shows 6 tools and 3 resources
  • create_task works and returns the ID
  • update_task updates fields correctly
  • complete_task marks as completed with a date
  • delete_task deletes and returns the deleted task
  • search_tasks filters by status, priority, tags, and text
  • manage_tags lists, creates, and deletes tags
  • tasks://all returns the complete list
  • tasks://task/{id} returns the detail
  • tasks://stats returns correct statistics
  • Claude Code connects and uses the tools/resources
  • The data persists between server restarts (JSON file)

Suggested extensions

If you want to take this project further, try:

Extension 1: Add prompts

Add a daily_standup prompt that generates a standup report based on the tasks:

server.prompt(
  "daily-standup",
  "Generates a daily standup report based on the tasks",
  {},
  async () => {
    const tasks = store.getAllTasks();
    const completed = tasks.filter(t => t.status === "completed").slice(-5);
    const inProgress = tasks.filter(t => t.status === "in_progress");
    const pending = tasks.filter(t => t.status === "pending" && t.priority === "critical");

    return {
      messages: [{
        role: "user" as const,
        content: {
          type: "text" as const,
          text: `Generate a daily standup report based on these tasks:

**Recently completed:**
${completed.map(t => `- ${t.title}`).join("\n") || "- None"}

**In progress:**
${inProgress.map(t => `- ${t.title} [${t.priority}]`).join("\n") || "- None"}

**Pending critical:**
${pending.map(t => `- ${t.title}`).join("\n") || "- None"}

Format: yesterday I did / today I'll do / blockers`,
        },
      }],
    };
  }
);

Extension 2: Add subtasks

Extend the Task model to support subtasks: a subtasks array with title and status. Add an add_subtask tool and update the detail resource to include subtasks.

Extension 3: Export to Markdown

Add an export_tasks tool that generates a Markdown file with all the tasks organized by status and priority.


Connection to Module 8

The TaskFlow MCP Server you built in this capsule has the same patterns you'll use in the capstone project of Module 8:

TaskFlow (Module 4)             →  Final Project (Module 8)
─────────────────────────────────────────────────────────────
In-memory store with JSON        →  Real database (SQLite/PostgreSQL)
6 basic tools                    →  Tools connected to real APIs
3 resources                      →  Dynamic resources with caching
Zod validation                   →  Same validation + more complex schemas
stdio transport                  →  Stdio + HTTP option for deploy
No tests                         →  Complete test suite (Module 7)
No authentication                →  Auth if it's an HTTP server

The patterns are identical — the store pattern, organizing tools into separate files, Zod validation, error handling with isError. What changes in Module 8 is the scale and the connection with real services.


Troubleshooting

"Error: Cannot find module '../store/task-store.js'"

Cause: Relative imports need the .js extension.

Solution: Verify that all local imports end in .js:

import * as store from "../store/task-store.js";  // ✅
import * as store from "../store/task-store";      // ❌

"The data is lost on restart"

Cause: The taskflow-data.json file isn't being created.

Solution: Verify write permissions in the current directory. The server creates the file in process.cwd().

"Claude Code doesn't show the tools"

Cause: A silent error while registering the tools.

Solution:

# Remove and re-add with an absolute path
claude mcp remove taskflow
claude mcp add taskflow -s user -- node $(pwd)/build/index.js

# Restart Claude Code
claude
/mcp

"The resource template doesn't list the tasks"

Cause: The ResourceTemplate's list callback runs before there are tasks.

Solution: It's normal — tasks are discovered after creating them. Create some tasks first and then verify that the resource template lists them.

"ID conflict when creating tasks quickly"

Cause: The randomUUID().slice(0, 8) generates short IDs that could theoretically collide.

Solution: For production, use the full UUID or a sequential ID generator.


Summary

In this capsule:

  • You built a complete MCP server (taskflow-mcp-server) with 6 tools and 3 resources
  • You organized the code into modules: tools/, resources/, store/
  • You implemented complete CRUD for tasks with Zod validation
  • You implemented search with combinable filters (status, priority, tags, text)
  • You implemented static and dynamic resources (URI templates with a list callback)
  • You implemented a store with persistence to JSON
  • You connected to Claude Code and verified that everything works end-to-end
  • You understood the direct connection with the capstone project of Module 8

This is the first "real" MCP server you build. It's no longer a prototype — it's a functional server with multiple capabilities that you could extend and use in your real work.


Additional resources

  1. MCP TypeScript SDK - Official SDK
  2. Zod Documentation - Validation reference
  3. MCP Inspector - Visual testing
  4. Claude Code MCP Configuration - Official configuration
  5. MCP Servers Examples - Reference servers
  6. Awesome MCP Servers - Community directory
  7. TypeScript Handbook - TypeScript reference
  8. Node.js crypto.randomUUID - ID generation

Next module: MCP Server in Python — the same concepts, a different language. Decorators instead of methods, Pydantic instead of Zod, FastMCP as a high-level abstraction. If you master TypeScript, Python will be natural.