Module 3: Three Primitives — Resources, Tools, Prompts

Combining Primitives: How They Work Together in a Real Server

Combining Primitives: How They Work Together in a Real Server

Capsule description

You already know the 3 primitives separately: Resources to read data, Tools to execute actions, and Prompts to standardize interactions. But in a real MCP server, the primitives don't operate in silos — they work together as an integrated system.

A resource exposes a project's data. A tool modifies that data. A prompt guides the user to make the correct modification. The three primitives complement each other, and the key to a good MCP server is designing how they interact.

In this capsule you're going to see real combination patterns, you're going to design a coherent server where the primitives reinforce each other, and you're going to understand the design decisions that separate an amateur MCP server from a professional one.


The cohesion principle

The primitives as layers

In a well-designed MCP server, the primitives form complementary layers:

┌─────────────────────────────────────────────┐
│  Prompts (interaction layer)                │
│  "How the user asks for things"             │
│  ├── code-review                            │
│  ├── refactoring-plan                       │
│  └── bug-report                             │
├─────────────────────────────────────────────┤
│  Tools (action layer)                       │
│  "What actions the model can execute"       │
│  ├── create_file                            │
│  ├── update_record                          │
│  └── deploy_app                             │
├─────────────────────────────────────────────┤
│  Resources (data layer)                     │
│  "What data the model can see"              │
│  ├── file:///project/structure              │
│  ├── db://users/list                        │
│  └── config://app/settings                  │
└─────────────────────────────────────────────┘

The natural flow: Resources give context → tools act on that context → prompts standardize how things are asked.

Concrete example: MCP Server for task management

Imagine an MCP server that connects with a task system:

Resources:
├── tasks://all              → Lists all tasks
├── tasks://status/{status}  → Tasks filtered by status
└── tasks://stats            → Statistics (completed, pending)

Tools:
├── create_task     → Creates a new task
├── update_task     → Updates an existing task
├── complete_task   → Marks a task as completed
└── delete_task     → Deletes a task

Prompts:
├── daily-standup   → "What did I do yesterday, what will I do today, what's blocking me?"
├── sprint-review   → Sprint summary with completed tasks
└── task-breakdown  → Breaks a large task into subtasks

Do you see how they complement each other?

  1. The user uses the daily-standup prompt
  2. The prompt internally needs data → uses the tasks://status/in-progress resource
  3. The model generates the standup and suggests completing finished tasks → uses the complete_task tool

Combination patterns

Pattern 1: Read-Act-Report

The most common pattern. The model reads data, acts on it, and reports the result.

Resource (read) → Tool (act) → Resource (verify)

Example:
1. Resource "db://orders/pending" → Reads pending orders
2. Tool "process_order" → Processes an order
3. Resource "db://orders/123" → Verifies that the order was processed

TypeScript implementation:

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

// 1. Resource: read pending orders
server.resource(
  "pending-orders",
  "db://orders/pending",
  { description: "List of pending orders", mimeType: "application/json" },
  async (uri) => {
    const orders = await db.query("SELECT * FROM orders WHERE status = 'pending'");
    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(orders.rows, null, 2),
      }],
    };
  }
);

// 2. Tool: process an order
server.tool(
  "process_order",
  "Processes a pending order by changing its status to 'processing'",
  {
    orderId: z.string().describe("ID of the order to process"),
    notes: z.string().optional().describe("Processing notes"),
  },
  async ({ orderId, notes }) => {
    const result = await db.query(
      "UPDATE orders SET status = 'processing', notes = $1, processed_at = NOW() WHERE id = $2 RETURNING *",
      [notes || "", orderId]
    );

    if (result.rows.length === 0) {
      return {
        content: [{ type: "text", text: `Order ${orderId} not found` }],
        isError: true,
      };
    }

    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          message: `Order ${orderId} processed successfully`,
          order: result.rows[0],
        }, null, 2),
      }],
    };
  }
);

// 3. Resource template: verify an order
server.resource(
  "order-detail",
  new ResourceTemplate("db://orders/{orderId}", { list: undefined }),
  { description: "Detail of an order by ID", mimeType: "application/json" },
  async (uri, params) => {
    const order = await db.query("SELECT * FROM orders WHERE id = $1", [params.orderId]);
    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(order.rows[0] || { error: "Not found" }, null, 2),
      }],
    };
  }
);

Pattern 2: Prompt-Driven Workflow

The prompt guides a complete flow that uses resources and tools.

Prompt (guide) → Resource (context) → Tool (action)

Example:
1. Prompt "daily-standup" → Generates instructions for the standup
2. Resource "tasks://status/in-progress" → Provides current tasks as context
3. Tool "complete_task" → Completes tasks the user confirms as finished

TypeScript implementation:

// Prompt that orchestrates resources as context
server.prompt(
  "daily-standup",
  "Generates a daily standup report based on the current tasks",
  {
    teamMember: z.string().describe("Name of the team member"),
  },
  async ({ teamMember }) => {
    const inProgress = await db.query(
      "SELECT * FROM tasks WHERE assignee = $1 AND status IN ('in-progress', 'completed-today')",
      [teamMember]
    );

    const blockers = await db.query(
      "SELECT * FROM tasks WHERE assignee = $1 AND blocked = true",
      [teamMember]
    );

    return {
      messages: [
        {
          role: "user" as const,
          content: {
            type: "text" as const,
            text: `Generate a standup report for ${teamMember}.

**In-progress tasks:**
${JSON.stringify(inProgress.rows, null, 2)}

**Blockers:**
${JSON.stringify(blockers.rows, null, 2)}

**Standup format:**
1. ✅ **Yesterday I completed:** [tasks with status 'completed-today']
2. 🔄 **Today I'll work on:** [tasks with status 'in-progress']
3. 🚫 **Blockers:** [tasks with blocked = true]
4. 📝 **Notes:** [relevant observations]

If there are completed tasks, suggest marking them as done using the complete_task tool.`,
          },
        },
      ],
    };
  }
);

Pattern 3: Resource-Enriched Tools

Tools use resources internally to enrich their operations.

Tool (execute) → Internal resource (context) → Tool (enriched result)

Example:
1. Tool "smart_create_file" → Before creating, queries the project structure
2. Internal resource → Reads the .editorconfig, .prettierrc, tsconfig
3. Tool → Creates the file with the project's correct style

The smart_create_file tool internally reads .editorconfig and .prettierrc (as if they were resources) to apply the correct conventions before writing the file. The user doesn't know that the tool queries data — they only see that the file is created with the correct style.


Design decisions

How many primitives does your server need?

Not every MCP server needs all 3 primitives. The decision depends on the use case:

Read-only server (e.g., metrics dashboard):
  ✅ Resources: expose metrics, logs, state
  ❌ Tools: there are no actions to execute
  ⚠️ Prompts: optional, to standardize queries

Action server (e.g., deployment tool):
  ⚠️ Resources: deployment state, logs
  ✅ Tools: deploy, rollback, scale
  ⚠️ Prompts: deployment checklist template

Complete server (e.g., project management):
  ✅ Resources: tasks, sprints, metrics
  ✅ Tools: CRUD of tasks, assign, complete
  ✅ Prompts: standup, sprint review, planning

Naming conventions

Keep consistency in the names:

// Resources: nouns, with a descriptive scheme
"db://users/list"
"db://users/{id}"
"config://app/settings"
"metrics://api/latency"

// Tools: verbs in snake_case
"create_user"
"update_task"
"deploy_application"
"search_files"

// Prompts: descriptive names in kebab-case
"code-review"
"daily-standup"
"bug-report"
"feature-spec"

Granularity: one big tool or several small ones?

// ❌ One tool that does everything
server.tool("manage_user", "Creates, updates, or deletes users", {
  action: z.enum(["create", "update", "delete"]),
  // ... many conditional parameters
});

// ✅ One tool per action
server.tool("create_user", "Creates a new user", { ... });
server.tool("update_user", "Updates an existing user", { ... });
server.tool("delete_user", "Deletes a user by ID", { ... });

Rule: One tool per action. The model chooses better between specific tools than between actions within a generic tool.


Complete example: Notes MCP Server

Let's look at a complete server that combines the 3 primitives cohesively:

TypeScript

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

interface Note {
  id: string;
  title: string;
  content: string;
  tags: string[];
  createdAt: string;
  updatedAt: string;
}

const notes: Map<string, Note> = new Map();
let nextId = 1;

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

// === RESOURCES ===

server.resource(
  "all-notes",
  "notes://all",
  { description: "List of all notes", mimeType: "application/json" },
  async (uri) => ({
    contents: [{
      uri: uri.href,
      mimeType: "application/json",
      text: JSON.stringify(Array.from(notes.values()), null, 2),
    }],
  })
);

server.resource(
  "note-by-id",
  new ResourceTemplate("notes://note/{noteId}", {
    list: async () =>
      Array.from(notes.values()).map((n) => ({
        uri: `notes://note/${n.id}`,
        name: n.title,
        description: `Note: ${n.title}`,
      })),
  }),
  { description: "A specific note by ID", mimeType: "application/json" },
  async (uri, params) => {
    const note = notes.get(params.noteId as string);
    if (!note) throw new Error(`Note ${params.noteId} not found`);
    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(note, null, 2),
      }],
    };
  }
);

server.resource(
  "notes-stats",
  "notes://stats",
  { description: "Notes statistics", mimeType: "application/json" },
  async (uri) => {
    const allNotes = Array.from(notes.values());
    const tagCount: Record<string, number> = {};
    allNotes.forEach((n) => n.tags.forEach((t) => (tagCount[t] = (tagCount[t] || 0) + 1)));

    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify({
          totalNotes: allNotes.length,
          tags: tagCount,
          lastUpdated: allNotes.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0]?.updatedAt || null,
        }, null, 2),
      }],
    };
  }
);

// === TOOLS ===

server.tool(
  "create_note",
  "Creates a new note with title, content, and tags",
  {
    title: z.string().min(1).describe("The note's title"),
    content: z.string().describe("The note's content"),
    tags: z.array(z.string()).default([]).describe("The note's tags"),
  },
  async ({ title, content, tags }) => {
    const id = String(nextId++);
    const now = new Date().toISOString();
    const note: Note = { id, title, content, tags, createdAt: now, updatedAt: now };
    notes.set(id, note);

    return {
      content: [{
        type: "text",
        text: JSON.stringify({ message: `Note "${title}" created with ID ${id}`, note }, null, 2),
      }],
    };
  }
);

server.tool(
  "update_note",
  "Updates the content or tags of an existing note",
  {
    noteId: z.string().describe("ID of the note to update"),
    title: z.string().optional().describe("New title"),
    content: z.string().optional().describe("New content"),
    tags: z.array(z.string()).optional().describe("New tags"),
  },
  async ({ noteId, title, content, tags }) => {
    const note = notes.get(noteId);
    if (!note) {
      return { content: [{ type: "text", text: `Note ${noteId} not found` }], isError: true };
    }

    if (title) note.title = title;
    if (content) note.content = content;
    if (tags) note.tags = tags;
    note.updatedAt = new Date().toISOString();

    return {
      content: [{ type: "text", text: JSON.stringify({ message: "Note updated", note }, null, 2) }],
    };
  }
);

server.tool(
  "delete_note",
  "Deletes a note by its ID",
  { noteId: z.string().describe("ID of the note to delete") },
  async ({ noteId }) => {
    const note = notes.get(noteId);
    if (!note) {
      return { content: [{ type: "text", text: `Note ${noteId} not found` }], isError: true };
    }
    notes.delete(noteId);
    return {
      content: [{ type: "text", text: `Note "${note.title}" (ID: ${noteId}) deleted` }],
    };
  }
);

// === PROMPTS ===

server.prompt(
  "organize-notes",
  "Analyzes and suggests organization for the existing notes",
  {},
  async () => {
    const allNotes = Array.from(notes.values());
    return {
      messages: [{
        role: "user" as const,
        content: {
          type: "text" as const,
          text: `Analyze these ${allNotes.length} notes and suggest how to organize them better:

${JSON.stringify(allNotes, null, 2)}

**Suggest:**
1. Tags that are missing or could be consolidated
2. Notes that could be merged
3. Notes that should be split
4. A suggested category structure`,
        },
      }],
    };
  }
);

server.prompt(
  "summarize-notes",
  "Generates an executive summary of all the notes",
  {
    format: z.enum(["bullet-points", "paragraph", "table"]).default("bullet-points").describe("Summary format"),
  },
  async ({ format }) => {
    const allNotes = Array.from(notes.values());
    return {
      messages: [{
        role: "user" as const,
        content: {
          type: "text" as const,
          text: `Generate an executive summary of these notes in ${format} format:

${JSON.stringify(allNotes, null, 2)}

The summary should capture the key points of each note in 1-2 lines.`,
        },
      }],
    };
  }
);

The Python equivalent uses decorators (@server.resource, @server.tool, @server.prompt) but follows exactly the same cohesion pattern: resources to read, tools to act, prompts to guide.


Anti-patterns: what to avoid

Anti-pattern 1: Duplicate primitives

// ❌ Resource AND tool that do the same thing
server.resource("users", "db://users/all", {}, async () => { ... });
server.tool("list_users", "Lists all users", {}, async () => { ... });

// ✅ Resource for reading, tool only if it needs complex parameters
server.resource("users", "db://users/all", {}, async () => { ... });
server.tool("search_users", "Searches users with advanced filters", {
  query: z.string(),
  role: z.enum([...]),
  sortBy: z.string(),
}, async (args) => { ... });

Anti-pattern 2: Prompts that should be tools

// ❌ Prompt that executes an action
server.prompt("deploy", "Deploys the application", {}, async () => {
  await deployApp(); // ← side effect in a prompt
  return { messages: [...] };
});

// ✅ Prompt that guides, tool that executes
server.prompt("deploy-checklist", "Pre-deployment checklist", {}, async () => ({
  messages: [{ role: "user", content: { type: "text", text: "Check these items before deploy..." } }],
}));
server.tool("deploy_app", "Deploys the application", { ... }, async () => { ... });

Anti-pattern 3: Server without cohesion

// ❌ Disconnected primitives
server.resource("weather", ...);     // Weather
server.tool("create_user", ...);     // Users
server.prompt("sql-query", ...);     // SQL

// ✅ Cohesive primitives
server.resource("db://users/all", ...);     // User data
server.tool("create_user", ...);            // User management
server.prompt("user-report", ...);          // User reports

Troubleshooting

"The model doesn't combine primitives automatically"

Cause: The model doesn't know that the primitives are related.

Solution: Use descriptions that reference other primitives:

server.tool(
  "update_task",
  "Updates a task. Use the tasks://all resource to see the available tasks before updating.",
  { ... }
);

"The prompt doesn't have access to the current data"

Cause: The prompt generates static text without querying data.

Solution: Read the data inside the prompt's handler:

server.prompt("report", "Status report", {}, async () => {
  const data = await getCurrentData(); // ← reads data dynamically
  return { messages: [{ role: "user", content: { type: "text", text: `Data: ${JSON.stringify(data)}` } }] };
});

"Too many tools confuse the model"

Cause: The server exposes many tools and the model doesn't know which to choose.

Solution:

  • Limit to 10-15 tools per server
  • Use very specific names and descriptions
  • Group functionality into separate servers if necessary

Exercises

Exercise 1: Design primitives for a use case (Easy)

Design the primitives (resources, tools, prompts) for a bookmark management MCP server. List at least 2 of each type.

See solution
Resources:
├── bookmarks://all           → Lists all bookmarks
├── bookmarks://tag/{tag}     → Bookmarks filtered by tag
├── bookmarks://stats         → Statistics (total, per tag, most visited)

Tools:
├── add_bookmark     → Adds a new bookmark (url, title, tags)
├── delete_bookmark  → Deletes a bookmark by ID
├── tag_bookmark     → Adds/removes tags from a bookmark
├── check_links      → Checks which bookmarks have broken links

Prompts:
├── weekly-reading    → "Suggest 5 bookmarks to read this week based on my tags"
├── organize-bookmarks → "Analyze my bookmarks and suggest better tag organization"
├── find-related      → "Given a topic, find related bookmarks"

Exercise 2: Identify the combination pattern (Medium)

For each scenario, identify which combination pattern (Read-Act-Report, Prompt-Driven Workflow, Resource-Enriched Tools) applies and why:

  1. The user asks for a code review, the model reads the file, generates feedback, and optionally applies fixes
  2. The user runs a deployment tool that reads the config before deploying
  3. The model lists modified files, compares them with the previous version, and generates a changelog
See solution
  1. Prompt-Driven Workflow

    • Prompt: code review template with criteria
    • Resource: content of the file to review
    • Tool: apply suggested fixes
    • The flow starts with the prompt that guides everything
  2. Resource-Enriched Tools

    • Tool: deploy_application
    • Internal resource: config://deployment/settings
    • The tool queries resources internally to enrich its execution
  3. Read-Act-Report

    • Resource: list of modified files + previous versions
    • Tool: generate changelog (or it could be a prompt if it's just text)
    • Resource: verify that the changelog was generated correctly

Exercise 3: Implement a combined flow (Medium)

Implement a mini-server in TypeScript with 1 resource, 1 tool, and 1 prompt that work together to manage a shopping list:

See solution
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

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

interface Item { id: number; name: string; quantity: number; bought: boolean; }
const items: Item[] = [];
let nextId = 1;

// Resource: view the current list
server.resource("shopping-list", "shopping://list", {
  description: "Current shopping list",
  mimeType: "application/json",
}, async (uri) => ({
  contents: [{
    uri: uri.href, mimeType: "application/json",
    text: JSON.stringify({ total: items.length, pending: items.filter(i => !i.bought).length, items }, null, 2),
  }],
}));

// Tool: add an item
server.tool("add_item", "Adds an item to the shopping list", {
  name: z.string().describe("Product name"),
  quantity: z.number().default(1).describe("Quantity"),
}, async ({ name, quantity }) => {
  const item: Item = { id: nextId++, name, quantity, bought: false };
  items.push(item);
  return { content: [{ type: "text", text: `"${name}" (x${quantity}) added to the list` }] };
});

// Prompt: suggest a weekly menu based on the list
server.prompt("weekly-menu", "Suggests a weekly menu based on the items in the list", {}, async () => {
  const pending = items.filter(i => !i.bought);
  return {
    messages: [{
      role: "user" as const,
      content: {
        type: "text" as const,
        text: `I have these items in my shopping list:\n${JSON.stringify(pending, null, 2)}\n\nSuggest a weekly menu (Monday to Friday) using these ingredients. If something essential is missing, recommend adding it using the add_item tool.`,
      },
    }],
  };
});

Exercise 4: Refactor disconnected primitives (Hard)

The following server has disconnected primitives. Refactor it so they're cohesive:

server.resource("weather", "api://weather/current", {}, handler);
server.tool("create_user", "Creates a user", schema, handler);
server.tool("get_forecast", "Gets a forecast", schema, handler);
server.prompt("user-welcome", "Welcome message", {}, handler);
server.resource("db://users/count", {}, handler);
See solution

Separate into 2 cohesive servers:

// Server 1: Weather Server
const weatherServer = new McpServer({ name: "weather-server", version: "1.0.0" });

weatherServer.resource("current-weather", "weather://current", {
  description: "Current weather"
}, handler);

weatherServer.resource("forecast", "weather://forecast/{days}", {
  description: "Forecast for N days"
}, handler);

weatherServer.prompt("weather-report", "Weather report to plan the week", {}, handler);

// Server 2: Users Server
const usersServer = new McpServer({ name: "users-server", version: "1.0.0" });

usersServer.resource("users-count", "db://users/count", {
  description: "Number of registered users"
}, handler);

usersServer.resource("users-list", "db://users/all", {
  description: "List of users"
}, handler);

usersServer.tool("create_user", "Creates a new user", schema, handler);

usersServer.prompt("user-welcome", "Generates a personalized welcome message", {}, handler);

Each server now has primitives that reinforce each other within a coherent domain.


Summary

In this capsule you learned:

  • The 3 primitives work together as a system — not in isolation
  • Read-Act-Report: Resource reads data → Tool acts → Resource verifies
  • Prompt-Driven Workflow: Prompt guides → Resource gives context → Tool executes
  • Resource-Enriched Tools: Tool queries data internally before acting
  • A good MCP server has cohesive primitives — all related to the same domain
  • Naming conventions and granularity are important design decisions
  • Avoid anti-patterns: duplicate primitives, prompts with side effects, servers without cohesion

Next capsule: Mini-project — you're going to build your first complete MCP server with 1 resource, 1 tool, and 1 prompt working together.


Additional resources

  1. MCP Specification - How the primitives are defined in the protocol
  2. MCP TypeScript SDK Examples - Official examples of servers with multiple primitives
  3. MCP Servers Repository - Official servers as a design reference
  4. Awesome MCP Servers - Community servers with different combinations
  5. MCP Inspector - Inspect all of a server's primitives
  6. Domain-Driven Design Basics - Cohesion principles applicable to server design