Introduction

MCP defines a set of primitives — building blocks that servers and clients use to communicate. On the server side, there are three primitives: Tools, Resources, and Prompts. On the client side, there are two: Sampling and Elicitation. Each primitive serves a distinct purpose, and understanding them is the foundation for building and using MCP servers effectively.

Key Concepts

  • Tools: Executable functions that the LLM can invoke. A tool has a name, description, input schema (defined using JSON Schema), and returns results. Tools are the most commonly used primitive — they let the LLM take actions like querying a database, creating a file, or calling an API.
  • Resources: Data sources that the server exposes for the LLM to read. Resources have URIs (like file:///path/to/doc.md or postgres://db/users) and return content (text or binary). Unlike tools, resources are read-only and do not perform actions.
  • Prompts: Reusable prompt templates that servers can expose. Prompts accept arguments and return structured message sequences that can be fed directly to the LLM. They are useful for standardizing complex prompts across an organization.
  • Sampling: A client-side primitive that allows the server to request LLM completions through the client. This enables agentic patterns where the server itself needs the LLM's help.
  • Elicitation: A client-side primitive that allows the server to request structured input from the user through the client. This enables interactive workflows like confirmation dialogs.

Real World Context

A PostgreSQL MCP server might expose all three server primitives. It provides tools like run_query for executing SQL statements. It provides resources like postgres://mydb/tables that list all tables in a database. And it provides prompts like analyze_schema that generates a prompt template for analyzing a database schema, accepting the database name as an argument.

When the LLM needs to understand the database structure, it reads the resource. When it needs to query data, it calls the tool. When a user asks for a schema analysis, the host retrieves the prompt template and uses it to structure the LLM's response.

Deep Dive

Server Primitives

The three server primitives form a clear hierarchy of interaction:

text
Primitive    Control     Purpose              Discovery Method
─────────────────────────────────────────────────────────────
Tools        Model       Execute actions       tools/list
Resources    Application Expose data           resources/list
Prompts      User        Template messages     prompts/list

Tools are model-controlled: the LLM decides when and how to call them. Resources are application-controlled: the host application decides when to read them and include them in the LLM's context. Prompts are user-controlled: they are typically triggered by a user selecting a prompt from a menu.

Tools in Detail

A tool definition includes a name, description, and input schema:

json
{
  "name": "create_file",
  "description": "Create a new file with the specified content",
  "inputSchema": {
    "type": "object",
    "properties": {
      "path": {
        "type": "string",
        "description": "The file path to create"
      },
      "content": {
        "type": "string",
        "description": "The content to write to the file"
      }
    },
    "required": ["path", "content"]
  }
}

The LLM reads this definition and can then decide to call the tool by providing the required parameters.

Resources in Detail

Resources expose data through URIs:

json
{
  "uri": "file:///project/README.md",
  "name": "Project README",
  "description": "The project's README file",
  "mimeType": "text/markdown"
}

The client reads a resource by sending a resources/read request with the resource URI. The server returns the content in the response.

Prompts in Detail

Prompts are reusable templates with arguments:

json
{
  "name": "code_review",
  "description": "Generate a code review for a file",
  "arguments": [
    {
      "name": "file_path",
      "description": "Path to the file to review",
      "required": true
    },
    {
      "name": "focus_area",
      "description": "Area to focus on (security, performance, style)",
      "required": false
    }
  ]
}

When a user selects this prompt, the host retrieves it with the arguments filled in. The server returns a sequence of messages that the host feeds to the LLM.

Client Primitives

Client primitives enable the server to request actions from the client:

text
Sampling     - Server asks the client's LLM for a completion
Elicitation  - Server asks the user for structured input

Sampling enables agentic patterns where an MCP server can ask the LLM to help with a task. For example, a code analysis server might use sampling to ask the LLM to summarize its findings before returning results.

Elicitation enables interactive workflows. A server performing a destructive operation might use elicitation to ask the user for confirmation before proceeding.

Tasks (Experimental)

The MCP specification also defines an experimental Tasks primitive — a cross-cutting utility that enables durable execution with deferred results and status tracking. Tasks allow servers to handle long-running operations asynchronously. Since this feature is still experimental, implementations may vary, but it is worth knowing it exists as the protocol evolves.

Discovery Methods

Clients discover what a server offers using three list methods:

text
tools/list      → Returns array of tool definitions
resources/list  → Returns array of resource definitions
prompts/list    → Returns array of prompt definitions

These methods are called after initialization to build the initial catalog of available capabilities. If the server supports listChanged notifications, the client can update its catalog dynamically.

Common Pitfalls

  1. Using tools for read-only data — If the LLM just needs to read data (like a file or database schema), expose it as a resource rather than a tool. Resources are simpler, cacheable, and do not carry the risk of side effects.
  2. Confusing who controls each primitive — Tools are controlled by the model (the LLM decides when to call them). Resources are controlled by the application (the host decides when to include them). Prompts are controlled by the user (triggered by explicit selection). Mixing up control boundaries leads to poor user experiences.
  3. Ignoring input schemas for tools — The input schema is how the LLM knows what parameters to provide. Vague or missing schemas lead to incorrect tool calls and errors.

Best Practices

  1. Write clear tool descriptions — The LLM reads your tool descriptions to decide whether and how to use them. A vague description like "do stuff" will produce poor results. Be specific: "Execute a read-only SQL query against the connected PostgreSQL database and return the results as a table."
  2. Use resources for context, tools for actions — If something is read-only and provides context, make it a resource. If it performs an action or has side effects, make it a tool.
  3. Design prompts for common workflows — Expose prompt templates for tasks your users perform repeatedly, like code reviews, schema analyses, or report generation.

Summary

  • MCP defines three server primitives: Tools (executable functions), Resources (data sources), and Prompts (reusable templates).
  • Client primitives include Sampling (server requests LLM completions) and Elicitation (server requests user input).
  • Each primitive has a discovery method: tools/list, resources/list, and prompts/list.
  • Tools are model-controlled, resources are application-controlled, and prompts are user-controlled.

Code Examples

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

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

// Tool: executable action
server.tool(
  "add_numbers",
  "Add two numbers together",
  { a: z.number(), b: z.number() },
  async ({ a, b }) => ({
    content: [{ type: "text", text: String(a + b) }],
  })
);

// Resource: read-only data
server.resource(
  "status",
  "app://status",
  async (uri) => ({
    contents: [{
      uri: uri.href,
      mimeType: "text/plain",
      text: "Server is running",
    }],
  })
);

// Prompt: reusable template
server.prompt(
  "summarize",
  "Summarize the given text",
  { text: z.string() },
  async ({ text }) => ({
    messages: [{
      role: "user",
      content: { type: "text", text: `Please summarize: ${text}` },
    }],
  })
);
✓ Completed