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 server
  • StdioServerTransport — Transport that uses stdin/stdout for local communication
  • z — Zod to define and validate input schemas
  • fs/promises — Node.js asynchronous filesystem API
  • path — 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) and node_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_file tool 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:

  1. Resources tab — You should see project-structure. Click it to read it and verify that it returns the file structure.

  2. 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.

  3. 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 build without 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 (/mcp shows devfiles: 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

  1. MCP TypeScript SDK — Getting Started - Official quick-start guide
  2. MCP Python SDK — Getting Started - Python alternative
  3. MCP Inspector - Visual testing tool
  4. Claude Code MCP Configuration - How to configure servers in Claude Code
  5. Zod Documentation - Schema validation used in the SDK
  6. MCP Servers Examples - Official servers as a reference
  7. Awesome MCP Servers - Directory of community servers
  8. MCP Specification - Complete technical reference of the protocol