Quick Takeaways
What you'll learn in this article
- 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
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
| Name | Value |
|---|---|
| Tools | 4 |
| Resources | 2 |
| Prompts | 2 |
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
Server Side
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.
| transport | latency | complexity |
|---|---|---|
| Stdio | 1 | 2 |
| Streamable HTTP | 15 | 7 |
| SSE (Deprecated) | 20 | 8 |
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.
| primitive | usage |
|---|---|
| Tools | 78 |
| Resources | 45 |
| Prompts | 22 |
Most servers start with tools. You should too. Resources and prompts add depth, but tools are what make MCP powerful.
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
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
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)
Raw JSON Schema
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'}`,
},
],
}
}
)
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.
| type | count |
|---|---|
| Static Resource | 1 |
| Dynamic Template | 1 |
| Total URIs | 2 |
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.`,
},
},
],
}
}
)
| Name | Value |
|---|---|
| Tools registered | 4 |
| Resources registered | 2 |
| Prompts registered | 2 |
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.
Connect
Inspector launches your server and completes capability negotiation
Explore Tools
Click the Tools tab to see create-task, list-tasks, update-task, get-project-stats
Test a Tool
Fill in parameters for create-task and click Execute
Check Resources
Switch to Resources tab and browse project://tasks
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.
| feature | usefulness |
|---|---|
| Tool testing | 95 |
| Resource browsing | 85 |
| Prompt preview | 75 |
| Log monitoring | 90 |
| Schema inspection | 88 |
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.
Testing the Connection
Try these prompts in Claude Desktop to verify everything works:
-
"Create a task for implementing user authentication with high priority" โ Claude should invoke create-task and return the new task ID.
-
"What tasks do I have?" โ Claude should call list-tasks and show the results.
-
"Mark task [ID] as in-progress" โ Claude should use update-task to change the status.
-
"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
VS Code
Both VS Code and Cursor load your tools at session start. The AI can then invoke them just like in Claude Desktop.
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,
}
}
})
| pattern | reliability |
|---|---|
| Zod validation | 98 |
| Business logic checks | 92 |
| Try-catch handlers | 95 |
| isError flag | 88 |
| Stderr logging | 97 |
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.
MCP Launch
Anthropic releases Model Context Protocol specification and SDK
Streamable HTTP
New transport replaces deprecated SSE, adds session management
Structured Output
Tools can return machine-readable data alongside text
Tasks and Auth
Experimental tasks primitive, step-up authorization, OAuth enhancements
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:
- All tools appear in the Tools tab with correct schemas
- Creating a task returns the expected response format
- Listing tasks with filters returns the correct subset
- Updating a nonexistent task returns isError: true
- Resources reflect the current state after tool invocations
- Prompts generate well-structured messages
| method | speed | coverage |
|---|---|---|
| MCP Inspector | 95 | 70 |
| Vitest unit tests | 60 | 95 |
| Manual in Claude | 30 | 50 |
| E2E integration | 20 | 98 |
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
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.
| Name | Value |
|---|---|
| Database access | 28 |
| API wrappers | 32 |
| DevOps/Infrastructure | 18 |
| File system | 12 |
| Knowledge/Search | 10 |
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.
| feature | status |
|---|---|
| Stdio transport | 100 |
| Streamable HTTP | 100 |
| SSE (deprecated) | 20 |
| Elicitation | 90 |
| Structured output | 85 |
| Tasks | 40 |
| OAuth 2.1 | 75 |
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

