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, tagsupdate_task— Update fields of an existing taskcomplete_task— Mark a task as completeddelete_task— Delete a tasksearch_tasks— Search tasks with filters (status, priority, tags, text)manage_tags— Create, list, and delete tags
Resources:
tasks://all— Complete list of taskstasks://task/{id}— Detail of a specific tasktasks://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.jsextension - 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 parametersupdate_task— visiblecomplete_task— visibledelete_task— visiblesearch_tasks— visible with filtersmanage_tags— visible
Resources tab:
tasks://all— empty list (no tasks yet)tasks://stats— statistics with zerostasks://task/{taskId}— template visible
Functional test
- Create a task:
{
"title": "Implement JWT authentication",
"description": "Add login/register endpoints with JWT tokens",
"priority": "high",
"tags": ["feature", "backend"]
}
- Create another task:
{
"title": "Fix bug in email validation",
"description": "The current regex accepts emails without a domain",
"priority": "critical",
"tags": ["bug", "backend"]
}
-
Read the
tasks://allresource — you should see both tasks. -
Search tasks:
{
"priority": "critical"
}
It should return only the bug task.
-
Complete a task: Use the ID of the first task.
-
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
- MCP TypeScript SDK - Official SDK
- Zod Documentation - Validation reference
- MCP Inspector - Visual testing
- Claude Code MCP Configuration - Official configuration
- MCP Servers Examples - Reference servers
- Awesome MCP Servers - Community directory
- TypeScript Handbook - TypeScript reference
- 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.