Module 3: Three Primitives — Resources, Tools, Prompts
Mini-Project: MCP Server with 1 Resource, 1 Tool, 1 Prompt
Mini-Project: MCP Server with 1 Resource, 1 Tool, 1 Prompt
Capsule description
This is the moment. You've learned what Resources, Tools, and Prompts are. You've seen how they combine. Now you're going to build your first MCP server with the 3 primitives working together.
The server you're going to build is deliberately minimal: 1 resource, 1 tool, and 1 prompt. It's not a production server — it's your first prototype, the seed you'll scale up in modules 4-8. The goal is for you to experience the complete cycle: create a server, register primitives, connect it to Claude Code, and verify that it works.
When you finish this capsule, you'll have gone from "I understand MCP's primitives" to "I built an MCP server that works." That's an important milestone.
What are we going to build?
The server: DevFiles
An MCP server called devfiles-server that helps manage development files:
devfiles-server
├── Resource: project-structure
│ → Lists the files and folders of a project directory
│ → URI: files://project/structure
│
├── Tool: create_file
│ → Creates a new file with specified content
│ → Includes validation and error handling
│
└── Prompt: refactoring-plan
→ Template to request a refactoring plan for a file
→ Includes criteria and a standardized format
Why these 3 primitives
- Resource (project-structure): It's the most basic thing a developer needs — to see what files they have. The model needs this context to help.
- Tool (create_file): Creates new files — the simplest action with real side effects. You can verify it worked by looking at your filesystem.
- Prompt (refactoring-plan): Standardizes how to ask for a refactoring — something developers do frequently and where the quality of the prompt matters.
Prerequisites
Before starting, verify:
# Node.js v18+
node --version
# npm
npm --version
# npx
npx --version
# Claude Code
claude --version
If something is missing, install it before continuing. Don't leave the setup for later.
Step 1: Create the project
File structure
# Create the project directory
mkdir devfiles-server
cd devfiles-server
# Initialize the project
npm init -y
Install dependencies
# MCP SDK for TypeScript
npm install @modelcontextprotocol/sdk
# Zod for schema validation
npm install zod
# TypeScript and Node types
npm install -D typescript @types/node
Configure TypeScript
Create the tsconfig.json file:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true
},
"include": ["src/**/*"]
}
Configure package.json
Update your package.json:
{
"name": "devfiles-server",
"version": "1.0.0",
"description": "Minimal MCP server with 1 resource, 1 tool, 1 prompt",
"type": "module",
"main": "build/index.js",
"scripts": {
"build": "tsc",
"start": "node build/index.js",
"dev": "tsc && node build/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.22.0"
},
"devDependencies": {
"typescript": "^5.3.0",
"@types/node": "^20.0.0"
}
}
Create the code structure
mkdir src
Your project should look like this:
devfiles-server/
├── package.json
├── tsconfig.json
├── node_modules/
└── src/
└── index.ts ← all the code will go here
Step 2: Implement the server
Create the src/index.ts file with the complete server code. Let's go section by section.
2.1: Imports and server setup
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import * as fs from "fs/promises";
import * as path from "path";
const server = new McpServer({
name: "devfiles-server",
version: "1.0.0",
});
What each import does:
McpServer— The SDK's main class for creating an MCP serverStdioServerTransport— Transport that uses stdin/stdout for local communicationz— Zod to define and validate input schemasfs/promises— Node.js asynchronous filesystem APIpath— Utilities for handling file paths
2.2: Resource — Project structure
const PROJECT_DIR = process.env.PROJECT_DIR || process.cwd();
server.resource(
"project-structure",
"files://project/structure",
{
description: "File and folder structure of the current project",
mimeType: "application/json",
},
async (uri) => {
async function getStructure(dir: string, prefix: string = ""): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const result: string[] = [];
const filtered = entries.filter(
(e) => !e.name.startsWith(".") && e.name !== "node_modules"
);
for (const entry of filtered) {
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(PROJECT_DIR, fullPath);
if (entry.isDirectory()) {
result.push(`📁 ${prefix}${entry.name}/`);
const children = await getStructure(fullPath, prefix + " ");
result.push(...children);
} else {
const stats = await fs.stat(fullPath);
const sizeKB = (stats.size / 1024).toFixed(1);
result.push(`📄 ${prefix}${entry.name} (${sizeKB} KB)`);
}
}
return result;
}
try {
const structure = await getStructure(PROJECT_DIR);
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(
{
projectDir: PROJECT_DIR,
totalItems: structure.length,
structure: structure,
},
null,
2
),
},
],
};
} catch (error) {
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(
{
error: `Could not read ${PROJECT_DIR}: ${error instanceof Error ? error.message : "unknown error"}`,
},
null,
2
),
},
],
};
}
}
);
Key points:
- The resource reads the directory structure recursively
- Filters hidden files (
.git,.env) andnode_modules - Shows the size of each file
- Handles errors gracefully (doesn't crash if the directory doesn't exist)
2.3: Tool — Create file
server.tool(
"create_file",
"Creates a new file in the project with the specified content. Creates intermediate directories if they don't exist.",
{
filePath: z
.string()
.describe(
"Relative path of the file to create (e.g., 'src/utils/helpers.ts')"
),
content: z.string().describe("Full content of the file"),
overwrite: z
.boolean()
.default(false)
.describe(
"If true, overwrites the file if it already exists. Default: false"
),
},
async ({ filePath, content, overwrite }) => {
// Security validation
if (filePath.includes("..")) {
return {
content: [
{
type: "text" as const,
text: "❌ Security error: the path can't contain '..' (path traversal)",
},
],
isError: true,
};
}
const absolutePath = path.resolve(PROJECT_DIR, filePath);
// Verify that the path is inside the project
if (!absolutePath.startsWith(PROJECT_DIR)) {
return {
content: [
{
type: "text" as const,
text: "❌ Security error: the path must be inside the project directory",
},
],
isError: true,
};
}
// Check if the file already exists
if (!overwrite) {
try {
await fs.access(absolutePath);
return {
content: [
{
type: "text" as const,
text: `❌ The file '${filePath}' already exists. Use overwrite: true to overwrite it.`,
},
],
isError: true,
};
} catch {
// The file doesn't exist — we can continue
}
}
try {
// Create intermediate directories
const dir = path.dirname(absolutePath);
await fs.mkdir(dir, { recursive: true });
// Write the file
await fs.writeFile(absolutePath, content, "utf-8");
// Verify it was created correctly
const stats = await fs.stat(absolutePath);
return {
content: [
{
type: "text" as const,
text: JSON.stringify(
{
success: true,
message: `✅ File created: ${filePath}`,
details: {
path: filePath,
absolutePath,
size: `${(stats.size / 1024).toFixed(1)} KB`,
characters: content.length,
lines: content.split("\n").length,
},
},
null,
2
),
},
],
};
} catch (error) {
return {
content: [
{
type: "text" as const,
text: `❌ Error creating the file: ${error instanceof Error ? error.message : "unknown error"}`,
},
],
isError: true,
};
}
}
);
Key points:
- Security validation: prevents path traversal with
.. - Verifies that the path is inside the project directory
- Creates intermediate directories automatically
- Explicit behavior with
overwrite(doesn't overwrite by default) - Returns details of the created file: size, lines, characters
2.4: Prompt — Refactoring plan
server.prompt(
"refactoring-plan",
"Generates a detailed refactoring plan for a project file",
{
filePath: z
.string()
.describe(
"Path of the file to refactor (e.g., 'src/index.ts')"
),
goal: z
.string()
.default("improve readability and maintainability")
.describe(
"Refactoring goal (e.g., 'separate responsibilities', 'improve performance')"
),
level: z
.enum(["conservative", "moderate", "aggressive"])
.default("moderate")
.describe(
"Refactoring level: conservative (minimal changes), moderate (balance), aggressive (complete restructuring)"
),
},
async ({ filePath, goal, level }) => {
const absolutePath = path.resolve(PROJECT_DIR, filePath);
let fileContent: string;
try {
fileContent = await fs.readFile(absolutePath, "utf-8");
} catch {
fileContent = `[Could not read the file: ${filePath}. Verify that it exists.]`;
}
const levelDescription = {
conservative:
"Apply minimal changes: renaming, extracting constants, cleaning up dead code. Doesn't change the general structure.",
moderate:
"Balance between improvement and stability: extract functions, separate responsibilities, improve types. Changes the internal structure but keeps the API.",
aggressive:
"Complete restructuring if necessary: change design patterns, split into modules, rewrite sections. Can change the API.",
};
const lineCount = fileContent.split("\n").length;
return {
messages: [
{
role: "user" as const,
content: {
type: "resource" as const,
resource: {
uri: `file:///${absolutePath}`,
text: fileContent,
mimeType: "text/plain",
},
},
},
{
role: "user" as const,
content: {
type: "text" as const,
text: `Generate a refactoring plan for this file.
**File:** ${filePath} (${lineCount} lines)
**Goal:** ${goal}
**Level:** ${level} — ${levelDescription[level]}
**The plan must include:**
### 1. Analysis of the current state
- What the file does (main purpose)
- Identified problems (code smells, complexity, duplication)
- Metrics: functions/classes, lines per function, nesting level
### 2. Proposed changes
For each change:
- **What:** description of the change
- **Why:** what problem it solves
- **Impact:** high/medium/low
- **Risk:** what could break
### 3. Execution order
- Ordered list of steps (do the lowest-risk ones first)
- Dependencies between steps
### 4. Suggested code
- Snippets of how it would look after the refactoring
- Before/after for the main changes
### 5. Necessary tests
- What tests to add BEFORE refactoring (safety net)
- What tests to add AFTER (verify new behavior)
### 6. Estimate
- Estimated time for each step
- Total time
Use the create_file tool if you need to create new files as part of the plan.`,
},
},
],
};
}
);
Key points:
- Reads the file's content automatically
- Includes it as an embedded resource in the prompt
- Three refactoring levels for different needs
- Detailed and consistent output format
- Connects with the
create_filetool to materialize the plan
2.5: Connect the transport and start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("DevFiles MCP Server running on stdio");
console.error(`Project directory: ${PROJECT_DIR}`);
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
Note: We use console.error (not console.log) because stdout is reserved for MCP communication via stdio. Logs go to stderr.
Step 3: Compile and verify
Compile the project
npm run build
You should see the build/ folder with the compiled index.js file.
Verify that it compiles without errors
# If there are TypeScript errors, fix them before continuing
# The most common errors:
# - Import paths without the .js extension
# - Missing types
# - strict mode violations
Verify that the server starts
# Run it directly (it should print to stderr and wait for input on stdin)
node build/index.js
# If you see "DevFiles MCP Server running on stdio", it works
# Press Ctrl+C to exit
Step 4: Test with MCP Inspector
Before connecting to Claude Code, test with MCP Inspector — a visual tool to interact with MCP servers:
# Run MCP Inspector pointing to your server
npx @modelcontextprotocol/inspector node build/index.js
In the Inspector:
-
Resources tab — You should see
project-structure. Click it to read it and verify that it returns the file structure. -
Tools tab — You should see
create_file. Test with:{ "filePath": "test-file.txt", "content": "Hello from MCP Inspector!" }Verify that the file was created in your filesystem.
-
Prompts tab — You should see
refactoring-plan. Test with:{ "filePath": "src/index.ts", "goal": "improve readability", "level": "conservative" }Verify that it generates the complete template with the file's content.
Inspector troubleshooting
"Can't connect to the server":
# Verify that the build exists
ls build/index.js
# Verify that the server starts manually
node build/index.js
# It should print to stderr and wait
"Resource returns an error":
# Verify that the project directory exists
echo $PROJECT_DIR
ls $(pwd)
# Make sure to run from the correct directory
cd /your/project/directory
npx @modelcontextprotocol/inspector node /path/to/devfiles-server/build/index.js
"Tool create_file fails":
# Verify write permissions
touch test-perms.txt && rm test-perms.txt
# If it fails, there's a permissions problem in the directory
Step 5: Connect to Claude Code
Add the server to Claude Code
# From your server's directory:
claude mcp add devfiles -s user -- node /absolute/path/to/devfiles-server/build/index.js
Important: Use the absolute path to the index.js file. To get it:
# From the server's directory
echo "$(pwd)/build/index.js"
Verify the connection
# Open Claude Code
claude
# Verify that the server is connected
/mcp
You should see:
MCP Servers:
devfiles: connected
Tools:
- create_file
Resources:
- project-structure (files://project/structure)
Prompts:
- refactoring-plan
Test each primitive in Claude Code
Test the Resource:
You: "What files are in my project?"
Claude Code should use the project-structure resource and show you the structure.
Test the Tool:
You: "Create a file called src/utils/constants.ts with the project's basic constants"
Claude Code should use create_file and create the file. Verify that it exists:
cat src/utils/constants.ts
Test the Prompt:
You: Use the refactoring-plan prompt to analyze src/index.ts with the moderate level
Claude Code should generate a detailed refactoring plan using the template.
Step 6: Experiment
Now that your server works, experiment:
Experiment 1: Combined flow
Ask Claude Code to use the 3 primitives in sequence:
"First show me the project structure.
Then create a file src/README.md with basic documentation.
Finally, use the refactoring prompt to analyze index.ts."
Notice how Claude Code orchestrates the 3 primitives automatically.
Experiment 2: Configure PROJECT_DIR
You can point your server to any directory:
# Remove the current configuration
claude mcp remove devfiles
# Re-add pointing to another directory
PROJECT_DIR=/path/to/other/project claude mcp add devfiles -s user -- node /path/to/devfiles-server/build/index.js
Experiment 3: Add a second resource
Add a resource that reads the project's package.json. You'll see this as an exercise further down — try it on your own before seeing the solution. Recompile (npm run build) and restart Claude Code so it detects the change.
Alternative implementation in Python
If you prefer Python, the setup is simpler. The Python SDK uses decorators instead of methods:
mkdir devfiles-server-py && cd devfiles-server-py
python -m venv venv && source venv/bin/activate
pip install mcp
The code structure follows the same pattern — @server.resource() for resources, @server.tool() for tools, @server.prompt() for prompts. You can see complete examples in capsules 02-04 of this module, where each primitive includes implementations in both languages.
# Connect to Claude Code
claude mcp add devfiles -s user -- python /absolute/path/to/server.py
Final verification
Success checklist
Before considering the mini-project done, verify:
- The server compiles without errors (
npm run buildwithout warnings) - The server starts (prints to stderr and waits for input)
- MCP Inspector shows the 3 primitives (Resources, Tools, Prompts tabs)
- The Resource returns data (file structure in JSON)
- The Tool creates files (verify in your filesystem)
- The Tool handles errors (test with an existing file and
overwrite: false) - The Prompt generates the template (with the file's content included)
- Claude Code connects (
/mcpshowsdevfiles: connected) - The 3 primitives work in Claude Code (test each one)
If something in the checklist fails, review the Troubleshooting section below.
Troubleshooting
"Error: Cannot find module @modelcontextprotocol/sdk"
Cause: The dependencies weren't installed correctly.
Solution:
rm -rf node_modules package-lock.json
npm install
npm run build
"Server starts but Claude Code shows 0 tools"
Cause: The server registers but the primitives aren't declared before connect().
Solution: Verify that all calls to server.resource(), server.tool(), and server.prompt() are before server.connect(transport) in the code.
"Error: EPERM operation not permitted"
Cause: The process doesn't have permissions to write in the directory.
Solution:
# macOS: verify the terminal's permissions
# System Preferences → Privacy & Security → Files and Folders
# Linux: verify the directory's permissions
chmod 755 /your/directory
"The prompt doesn't include the file's content"
Cause: The file's path is relative and doesn't resolve correctly.
Solution: Verify that PROJECT_DIR is configured correctly and that the file exists at that path:
ls -la $(pwd)/src/index.ts
"Inspector works but Claude Code doesn't"
Cause: A difference in how the server is invoked.
Solution:
# Test exactly the same command Claude Code will run:
node /absolute/path/build/index.js
# If it fails, the problem is the path or permissions
# If it works, re-add to Claude Code with the same exact path
Extension exercises
Exercise 1: Add a package.json resource (Easy)
Add a second resource that returns the content of the project's package.json. Verify that it appears in MCP Inspector and Claude Code.
See solution
server.resource(
"package-info",
"files://project/package",
{
description: "Content of the project's package.json",
mimeType: "application/json",
},
async (uri) => {
try {
const content = await fs.readFile(
path.join(PROJECT_DIR, "package.json"),
"utf-8"
);
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: content,
}],
};
} catch {
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({ error: "package.json not found" }),
}],
};
}
}
);
Add this code after the existing resource, recompile with npm run build, and restart Claude Code.
Exercise 2: Add a search tool (Medium)
Add a search_in_files tool that searches for a text in all the project's files and returns the matches with line number.
See solution
server.tool(
"search_in_files",
"Searches for a text in all the project's files and returns the matches",
{
query: z.string().describe("Text to search for"),
extension: z.string().optional().describe("Filter by extension (e.g., '.ts', '.py')"),
},
async ({ query, extension }) => {
const results: Array<{ file: string; line: number; text: string }> = [];
async function searchDir(dir: string): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await searchDir(fullPath);
} else {
if (extension && !entry.name.endsWith(extension)) continue;
try {
const content = await fs.readFile(fullPath, "utf-8");
const lines = content.split("\n");
lines.forEach((line, idx) => {
if (line.includes(query)) {
results.push({
file: path.relative(PROJECT_DIR, fullPath),
line: idx + 1,
text: line.trim(),
});
}
});
} catch {
// Skip binary files or ones without permissions
}
}
}
}
await searchDir(PROJECT_DIR);
return {
content: [{
type: "text" as const,
text: JSON.stringify({
query,
totalMatches: results.length,
results: results.slice(0, 50),
truncated: results.length > 50,
}, null, 2),
}],
};
}
);
Exercise 3: Connect to another directory (Medium)
Configure your server so it works with 2 different projects simultaneously in Claude Code. Hint: you can add the same server with different names and different PROJECT_DIR.
See solution
# Server for project 1
claude mcp add devfiles-frontend -s user -- \
sh -c "PROJECT_DIR=/path/to/frontend node /path/to/devfiles-server/build/index.js"
# Server for project 2
claude mcp add devfiles-backend -s user -- \
sh -c "PROJECT_DIR=/path/to/backend node /path/to/devfiles-server/build/index.js"
# Verify
claude
/mcp
# You should see:
# devfiles-frontend: connected
# devfiles-backend: connected
# Now you can ask:
# "Show me the frontend structure" → uses devfiles-frontend
# "Create a file in the backend" → uses devfiles-backend
The trick is to use sh -c to pass the PROJECT_DIR environment variable to the server. Each server instance operates on its own directory.
Exercise 5: Implement in Python (Hard)
Replicate the complete devfiles-server server in Python using FastMCP. Use @server.resource() for the project structure, @server.tool() for create_file with the same security validations, and @server.prompt() for the refactoring plan. Connect it to Claude Code and verify that it works.
See solution
Use the Python examples from capsules 02-04 as a base. The general structure:
from mcp.server.fastmcp import FastMCP
import os, json
from pathlib import Path
PROJECT_DIR = os.environ.get("PROJECT_DIR", os.getcwd())
server = FastMCP("devfiles-server")
@server.resource("files://project/structure")
async def project_structure() -> str:
# Scan directory recursively, filter .git and node_modules
...
@server.tool()
async def create_file(file_path: str, content: str, overwrite: bool = False) -> str:
# Validate path traversal, check existence, create dirs, write
...
@server.prompt()
async def refactoring_plan(file_path: str, goal: str = "improve readability") -> str:
# Read file, generate template with detailed instructions
...
if __name__ == "__main__":
server.run()
Connect it: claude mcp add devfiles-py -s user -- python /path/to/server.py
What's next
Summary of what you achieved
In this mini-project:
- ✅ You created an MCP project from scratch with TypeScript and the official SDK
- ✅ You implemented the 3 primitives: Resource, Tool, and Prompt
- ✅ You compiled and verified that the server works
- ✅ You tested with MCP Inspector (visual testing)
- ✅ You connected to Claude Code and verified the 3 primitives
- ✅ You experimented with combined flows
This is a milestone. You built your first functional MCP server. You're no longer just an MCP user — you're a creator.
Connection to the following modules
What you did (Module 3):
→ Minimal server: 1 resource, 1 tool, 1 prompt
→ Basic validation
→ stdio transport
→ In-memory data
What comes in Module 4 (MCP Server in TypeScript):
→ Complete server: multiple resources, tools, prompts
→ Advanced Zod schemas with full validation
→ Robust error handling
→ Transports: stdio + HTTP/SSE
→ Connection with real APIs and databases
What comes in Module 8 (Final Project):
→ Production-ready server
→ Complete testing
→ Professional documentation
→ Deploy and distribution
This module's minimal server is the seed. In module 4, you'll expand it with advanced TypeScript. In module 8, you'll turn it into a server you could publish.
Summary
In this capsule:
- You built your first MCP server (
devfiles-server) with the 3 primitives - Resource (
project-structure): exposes the project's file structure - Tool (
create_file): creates files with security validation - Prompt (
refactoring-plan): generates refactoring plans with the file's context - You tested with MCP Inspector and Claude Code
- You verified that the primitives work together in a real flow
Next module: MCP Server in TypeScript — scale your minimal server up to a complete server with multiple typed tools, advanced Zod schemas, and real transports.
Additional resources
- MCP TypeScript SDK — Getting Started - Official quick-start guide
- MCP Python SDK — Getting Started - Python alternative
- MCP Inspector - Visual testing tool
- Claude Code MCP Configuration - How to configure servers in Claude Code
- Zod Documentation - Schema validation used in the SDK
- MCP Servers Examples - Official servers as a reference
- Awesome MCP Servers - Directory of community servers
- MCP Specification - Complete technical reference of the protocol