Module 2: Host-Client-Server Architecture

The Server: The Capability Provider

The Server: The Capability Provider

Capsule description

You've reached the layer that matters most to you as a developer: the MCP Server. The Host orchestrates, the Client communicates, but the Server is your code — the program you write, which exposes capabilities (tools, resources, prompts), responds to requests, and executes real operations. When in module 4 you build your first MCP server in TypeScript, everything you learn in this capsule will be your map.

In this capsule you're going to understand how a Server is structured internally, what it must do during initialization, how it responds to Client requests, how it handles its state, and what design patterns produce robust Servers. You're not going to write code yet (that comes in modules 4 and 5), but you're going to understand the complete anatomy of a Server.

Going back to the restaurant analogy: the Server is the kitchen. It has its chef (your business logic), its menu (registered capabilities), and its way of preparing dishes (request handlers). The waiter (Client) brings orders; the kitchen prepares them and returns the result.


Anatomy of an MCP Server

General structure

Every MCP Server has these components:

MCP Server
│
├── Capability registry
│   ├── Tools: functions the model can execute
│   ├── Resources: data the model can read
│   └── Prompts: reusable templates
│
├── Handlers
│   ├── initialize handler → Responds to the Client's handshake
│   ├── tools/list handler → Returns the list of available tools
│   ├── tools/call handler → Executes a specific tool
│   ├── resources/list handler → Returns the list of resources
│   ├── resources/read handler → Reads a specific resource
│   ├── prompts/list handler → Returns the list of prompts
│   └── prompts/get handler → Returns a specific prompt
│
├── Business logic
│   ├── Connections to external APIs
│   ├── Database access
│   ├── File operations
│   └── Any operation your Server provides
│
└── Transport
    ├── stdio (reads from stdin, writes to stdout)
    └── HTTP/SSE (listens on a port)

The Server's lifecycle

From when the process starts up until it ends:

1. STARTUP
   └── The process starts (launched by the Host)
       ├── Configures the transport (stdio or HTTP)
       └── Registers capabilities (tools, resources, prompts)

2. INITIALIZE (responds to the Client)
   └── Receives "initialize" from the Client
       ├── Returns capabilities and serverInfo
       └── Receives "notifications/initialized"

3. READY (ready to operate)
   └── Listens for requests from the Client
       ├── tools/list → Returns list
       ├── tools/call → Executes and returns result
       ├── resources/list → Returns list
       ├── resources/read → Reads and returns data
       ├── prompts/list → Returns list
       └── prompts/get → Returns prompt

4. SHUTDOWN
   └── The Client closes the connection
       └── The process ends

Capabilities: what a Server can expose

The 3 primitives

An MCP Server can expose up to 3 types of capabilities. Each has a distinct purpose:

Server capabilities:
│
├── 🔧 Tools (executable functions)
│   ├── The model invokes them actively
│   ├── They can have side effects (writing, creating, modifying)
│   ├── Each tool has an input schema (validated parameters)
│   └── Example: create_file, run_query, send_message
│
├── 📄 Resources (read-only data)
│   ├── The model reads them to get context
│   ├── They're read-only (they don't modify anything)
│   ├── Identified by URI (protocol://path)
│   └── Example: db://schema, config://settings
│
└── 💬 Prompts (templates)
    ├── Predefined templates with parameters
    ├── They help the model ask specific questions
    ├── The user selects them explicitly
    └── Example: analyze_table(name), review_code(file)

Important: Not every Server needs to expose all 3. A minimal Server can have just 1 tool. A complete Server can have dozens of tools, resources, and prompts.

In this capsule you'll see the 3 types at the architectural level. Module 3 will go deeper into each primitive with complete detail.


Example: database Server

Let's see how the 3 primitives translate into a concrete Server:

Database MCP Server:
│
├── 🔧 Tools:
│   ├── query(sql) → Executes a SQL query
│   ├── list_tables() → Lists the database's tables
│   ├── describe_table(name) → A table's schema
│   └── insert_record(table, data) → Inserts a record
│
├── 📄 Resources:
│   ├── db://schema → Complete database schema
│   ├── db://tables/users → Data from the users table
│   └── db://stats → Database statistics
│
└── 💬 Prompts:
    ├── analyze_table(name) → "Analyze the structure and
    │   data of the {name} table and suggest improvements"
    └── optimize_query(sql) → "Review this SQL query
        and suggest optimizations: {sql}"

How a Server registers capabilities

The registration process

When you write an MCP Server, the first step is to register which capabilities it exposes. Each SDK (TypeScript, Python) has its own way of doing it, but conceptually it's the same:

Registering a tool:
├── Name: "read_file"
├── Description: "Reads the contents of a file"
├── Input Schema:
│   ├── path (string, required): "Path to the file"
│   └── encoding (string, optional): "The file's encoding"
└── Handler: function that runs when the tool is invoked

In pseudocode (you'll see the real syntax in modules 4 and 5):

// Pseudocode - register a tool
server.register_tool({
  name: "read_file",
  description: "Reads the contents of a file",
  input_schema: {
    path: { type: "string", required: true },
    encoding: { type: "string", required: false, default: "utf-8" }
  },
  handler: function(args) {
    content = read_from_disk(args.path, args.encoding)
    return { type: "text", text: content }
  }
})
// Pseudocode - register a resource
server.register_resource({
  uri: "config://app-settings",
  name: "Application Settings",
  description: "Current application configuration",
  mime_type: "application/json",
  handler: function() {
    settings = load_settings()
    return { type: "text", text: JSON.stringify(settings) }
  }
})
// Pseudocode - register a prompt
server.register_prompt({
  name: "analyze_logs",
  description: "Analyze application logs for errors",
  arguments: [
    { name: "timeframe", description: "Time period to analyze", required: true }
  ],
  handler: function(args) {
    return {
      messages: [
        {
          role: "user",
          content: "Analyze the application logs from the last " + args.timeframe +
                   ". Focus on errors, warnings, and unusual patterns."
        }
      ]
    }
  }
})

Input Schema: the importance of validation

Each tool defines an inputSchema using JSON Schema. This isn't decorative — the Client uses these schemas to:

  1. Inform the model which parameters it can pass
  2. Validate that the arguments are correct before sending them
  3. Document the tool's API automatically
{
  "name": "create_user",
  "description": "Creates a new user in the database",
  "inputSchema": {
    "type": "object",
    "properties": {
      "name": {
        "type": "string",
        "description": "The user's full name"
      },
      "email": {
        "type": "string",
        "format": "email",
        "description": "The user's email (must be unique)"
      },
      "role": {
        "type": "string",
        "enum": ["admin", "editor", "viewer"],
        "description": "The user's role in the system"
      }
    },
    "required": ["name", "email"]
  }
}

The model (Claude) sees this schema and knows:

  • name and email are required
  • role has 3 valid values
  • email must have an email format

This lets Claude build correct arguments without the user having to specify each field.


How a Server responds to requests

Response format

Every response from a tool has the same structure:

{
  "content": [
    {
      "type": "text",
      "text": "The result of the operation..."
    }
  ],
  "isError": false
}

The content field is an array that can contain different types:

Content types:
│
├── text
│   { "type": "text", "text": "textual content" }
│   Use: most responses
│
├── image
│   { "type": "image", "data": "base64...", "mimeType": "image/png" }
│   Use: screenshots, charts
│
└── resource
    { "type": "resource", "resource": { "uri": "...", "text": "..." } }
    Use: referencing a resource

Successful response

{
  "content": [
    {
      "type": "text",
      "text": "File created successfully: /Users/dev/output.json\nSize: 1.2 KB"
    }
  ],
  "isError": false
}

Error response

{
  "content": [
    {
      "type": "text",
      "text": "Error: Could not connect to the database. Verify that PostgreSQL is running and the credentials are correct.\n\nDetails: Connection refused at localhost:5432"
    }
  ],
  "isError": true
}

The Server returns operation errors inside content with isError: true. This lets the Host present the error to the user in a readable way.


State handling in the Server

Stateless vs Stateful

MCP Servers can be stateless or stateful. The choice depends on the use case:

Stateless Server:
├── Each request is independent
├── Doesn't maintain information between requests
├── Example: Filesystem server (reads/writes files, no memory)
├── Advantage: Simple, predictable
└── When to use it: atomic operations

Stateful Server:
├── Maintains state between requests
├── Previous requests affect the following ones
├── Example: Database server (keeps the connection open)
├── Advantage: Efficient (doesn't reconnect every time)
└── When to use it: persistent connections, caches

State examples

State that a Server can maintain:
│
├── Database connection
│   └── Open the connection on startup, reuse it in each request
│
├── Result cache
│   └── Cache frequent queries to respond faster
│
├── Authentication session
│   └── Renewable API token, don't re-authenticate each request
│
├── Loaded configuration
│   └── Read config on startup, don't re-read each request
│
└── Counters / metrics
    └── Track how many operations have been executed

State and lifecycle

The Server's state exists as long as the process lives. When the Host closes the connection (shutdown), the process ends and the state is lost:

State lifecycle:
│
├── STARTUP → Initial state (empty or from config)
├── INITIALIZE → Can load persistent state
├── OPERATION → State accumulates (cache, connections)
└── SHUTDOWN → State is lost (unless you persist it)

If you need persistent state between sessions, your Server must save it explicitly (in a file, database, etc.).


Design patterns for Servers

Pattern 1: API wrapper Server

The most common pattern — your Server wraps an existing API:

API Wrapper Server:
│
├── Input: MCP request from the Client
├── Process: translates to an API call
├── Output: MCP response to the Client
│
│   Client → Server → External API
│                   ← Response
│            ← MCP Response
│   ← Presents to the user

Example: GitHub MCP Server
├── tools/call: create_issue(repo, title, body)
│   └── Translates to: POST https://api.github.com/repos/{repo}/issues
│   └── Returns: { content: [{ type: "text", text: "Issue #42 created" }] }

Pattern 2: database Server

The Server maintains a connection to the database and exposes operations:

Database Server:
│
├── STARTUP: opens connection to PostgreSQL
├── tools/call: query(sql)
│   └── Executes: connection.query(sql)
│   └── Returns: formatted results
├── resources/read: db://schema
│   └── Executes: query information_schema
│   └── Returns: the database's schema
└── SHUTDOWN: closes the connection

Pattern 3: filesystem Server

Operates directly with the filesystem:

Filesystem Server:
│
├── Doesn't maintain connections (stateless)
├── tools/call: read_file(path)
│   └── Reads: fs.readFile(path)
│   └── Returns: the file's content
├── tools/call: write_file(path, content)
│   └── Writes: fs.writeFile(path, content)
│   └── Returns: confirmation
└── Validation: verifies that paths are inside the allowed directories

Pattern 4: aggregation Server

Combines multiple data sources:

Aggregation Server:
│
├── tools/call: project_status()
│   ├── Reads: GitHub API → open PRs
│   ├── Reads: Jira API → active tickets
│   ├── Reads: CI/CD → latest build
│   └── Returns: consolidated summary

Security in the Server

Input validation

Your Server receives arguments from the Client, but you shouldn't trust them blindly:

Essential validations:
│
├── Path traversal
│   ├── Input: path = "../../etc/passwd"
│   ├── Validation: verify that path is inside the allowed directory
│   └── Without validation: the Server reads system files
│
├── SQL injection
│   ├── Input: sql = "'; DROP TABLE users; --"
│   ├── Validation: use prepared statements
│   └── Without validation: the database is corrupted
│
├── Size limits
│   ├── Input: content = (10GB file)
│   ├── Validation: limit the input size
│   └── Without validation: the Server consumes all the memory
│
└── Rate limiting
    ├── Input: 1000 requests per second
    ├── Validation: limit the requests
    └── Without validation: the external API blocks you

Principle of least privilege

Your Server should only have the permissions it needs:

✅ Good:
├── Filesystem server: can only read/write in /Users/dev/projects
├── Database server: can only execute SELECT (no DROP/ALTER)
└── GitHub server: only has read access to public repos

❌ Bad:
├── Filesystem server: can access the whole disk
├── Database server: has admin permissions
└── GitHub server: has full access to private repos

Server vs Host vs Client: clear responsibilities

Who does what?

"The user wants to count .py files"

Host (Claude Code):
├── ✅ Receives the user's request
├── ✅ Decides that it needs the Filesystem server
├── ✅ Decides to use the "search_files" tool
└── ✅ Presents the result to the user

Client:
├── ✅ Sends tools/call to the Server
├── ✅ Receives the JSON-RPC response
└── ✅ Delivers the result to the Host

Server:
├── ✅ Receives the search_files request
├── ✅ Searches for .py files in the directory
├── ✅ Formats the result
└── ✅ Returns the response to the Client

Who should NOT do what?

Server:
├── ❌ Decide whether the user can execute this operation (that's the Host's)
├── ❌ Present results to the user (that's the Host's)
└── ❌ Talk with other Servers (the Host coordinates that)

Troubleshooting

"My tool doesn't appear in Claude Code"

Cause: The tool isn't registered correctly in the Server, or the capabilities don't include tools.

Solution: Verify:

  1. That your Server returns "tools": {} in capabilities during initialize
  2. That the tool is registered with a name, description, and inputSchema
  3. That tools/list returns the tool in the list
// Verify: the tools/list response must include your tool
{
  "tools": [
    {
      "name": "my_tool",
      "description": "Tool description",
      "inputSchema": { "type": "object", "properties": {} }
    }
  ]
}

"The tool runs but returns empty"

Cause: Your handler returns an incorrect format.

Solution: Verify that you return the correct format:

// ✅ Correct
{
  "content": [
    { "type": "text", "text": "result here" }
  ]
}

// ❌ Incorrect (missing content array)
{
  "text": "result here"
}

// ❌ Incorrect (content is not an array)
{
  "content": { "type": "text", "text": "result" }
}

"Error: Tool execution failed"

Cause: Your handler threw an unhandled exception.

Solution: Always wrap your logic in try/catch and return errors as isError: true:

// Pseudocode
handler(args):
  try:
    result = do_operation(args)
    return { content: [{ type: "text", text: result }], isError: false }
  catch error:
    return { content: [{ type: "text", text: error.message }], isError: true }

"The Server crashes on startup"

Cause: An error in the transport configuration or in initialization.

Solution:

# Run the server manually to see errors
node ./my-server.js

# Errors go to stderr
# Common causes:
# - Port already in use (HTTP transport)
# - Missing dependencies
# - Environment variables not configured

"Resources don't update"

Cause: The Server caches resources and doesn't refresh them.

Solution: Implement change notifications:

// When a resource changes, the Server can notify the Client:
{
  "jsonrpc": "2.0",
  "method": "notifications/resources/updated",
  "params": {
    "uri": "db://users"
  }
}

Exercises

Exercise 1: Identify a Server's capabilities (Easy)

For each scenario, decide whether you need a Tool, a Resource, or a Prompt:

  1. Show the application's current configuration
  2. Create a new user in the database
  3. Template to analyze error logs
  4. List an API's endpoints
  5. Send a message to Slack
  6. Template to generate documentation for an endpoint
See solution
  1. Resource — config://app-settings — It's read-only data, not an action
  2. Tool — create_user(name, email) — It's an action with side effects
  3. Prompt — analyze_logs(timeframe) — It's a reusable template
  4. Resource — api://endpoints — It's read-only data
  5. Tool — send_message(channel, text) — It's an action with side effects
  6. Prompt — document_endpoint(path, method) — It's a reusable template

Quick rule:

  • Does it modify something? → Tool
  • Does it only read data? → Resource
  • Is it a template for the model? → Prompt

Exercise 2: Design a tool's inputSchema (Medium)

Design the complete JSON Schema for a tool called send_email that accepts:

  • to (string, required): the recipient's email
  • subject (string, required): the email's subject
  • body (string, required): the email's content
  • cc (array of strings, optional): emails in copy
  • priority (string, optional): "low", "normal", or "high"
See solution
{
  "name": "send_email",
  "description": "Sends an email to a recipient with a subject and body",
  "inputSchema": {
    "type": "object",
    "properties": {
      "to": {
        "type": "string",
        "format": "email",
        "description": "The recipient's email"
      },
      "subject": {
        "type": "string",
        "description": "The email's subject"
      },
      "body": {
        "type": "string",
        "description": "The email's content (plain text or HTML)"
      },
      "cc": {
        "type": "array",
        "items": {
          "type": "string",
          "format": "email"
        },
        "description": "List of emails in copy (optional)"
      },
      "priority": {
        "type": "string",
        "enum": ["low", "normal", "high"],
        "default": "normal",
        "description": "The email's priority"
      }
    },
    "required": ["to", "subject", "body"]
  }
}

Key points:

  • required only includes the 3 mandatory fields
  • cc uses "type": "array" with "items" that defines the type of each element
  • priority uses "enum" to limit the valid values
  • format: "email" helps the model generate valid emails

Exercise 3: Write Server responses (Medium)

Write the JSON response that a Server would return for each scenario:

  1. The count_records tool ran successfully and counted 1,247 users
  2. The delete_file tool failed because the file doesn't exist
  3. The db://schema resource returns the database's schema
See solution

1. Success of count_records:

{
  "content": [
    {
      "type": "text",
      "text": "Total records in the users table: 1,247"
    }
  ],
  "isError": false
}

2. Error of delete_file:

{
  "content": [
    {
      "type": "text",
      "text": "Error: The file '/Users/dev/output.log' does not exist. Verify the path and try again."
    }
  ],
  "isError": true
}

3. Resource db://schema:

{
  "contents": [
    {
      "uri": "db://schema",
      "mimeType": "application/json",
      "text": "{\n  \"tables\": [\n    {\"name\": \"users\", \"columns\": [\"id\", \"name\", \"email\"]},\n    {\"name\": \"posts\", \"columns\": [\"id\", \"title\", \"user_id\"]}\n  ]\n}"
    }
  ]
}

Note: Resources use contents (plural) with uri and mimeType. Tools use content (singular) with type and text.

Exercise 4: Design a complete Server conceptually (Hard)

Design an MCP Server for a personal notes system. Define:

  • At least 3 tools with their inputSchemas
  • At least 2 resources with their URIs
  • At least 1 prompt with its arguments
  • What state the Server would maintain
  • What security validations you'd implement
See solution

Tools:

  1. create_note(title, content, tags) — Creates a new note

    {
      "inputSchema": {
        "type": "object",
        "properties": {
          "title": { "type": "string", "description": "The note's title" },
          "content": { "type": "string", "description": "The note's content" },
          "tags": { "type": "array", "items": { "type": "string" }, "description": "Tags to organize" }
        },
        "required": ["title", "content"]
      }
    }
  2. search_notes(query, tags) — Searches notes by content or tags

    {
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": { "type": "string", "description": "Text to search for" },
          "tags": { "type": "array", "items": { "type": "string" }, "description": "Filter by tags" }
        },
        "required": ["query"]
      }
    }
  3. delete_note(id) — Deletes a note by ID

    {
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "description": "ID of the note to delete" }
        },
        "required": ["id"]
      }
    }

Resources:

  • notes://all — Lists all notes (title, date, tags)
  • notes://tags — Lists all existing tags with a count

Prompt:

  • summarize_notes(tag) — "Summarize all the notes with the {tag} tag. Identify common themes and key points."

Server state:

  • Connection to a local SQLite (stores notes)
  • Tags cache (avoids recalculating with each request)
  • In-memory search index (for fast searches)

Security validations:

  • Sanitize the notes' content (prevent script injection if rendered)
  • Limit the notes' size (maximum 100KB)
  • Validate that IDs are valid UUIDs (prevent injection)
  • Rate limiting on create_note (maximum 100 notes per minute)

Exercise 5: Identify Server patterns (Hard)

For each MCP Server described, identify which design pattern it uses (API wrapper, database, filesystem, or aggregation) and justify it:

  1. A Server that queries the OpenWeather API to give forecasts
  2. A Server that reads and writes YAML configuration files
  3. A Server that combines data from GitHub, Jira, and Slack to give a "project dashboard"
  4. A Server that runs queries on MongoDB
See solution
  1. API Wrapper — Wraps the OpenWeather API. Translates MCP requests into HTTP calls to the API, and formats responses.

  2. Filesystem — Operates directly with YAML files on disk. It's stateless, each operation reads/writes independently.

  3. Aggregation — Combines multiple sources (GitHub, Jira, Slack) into a consolidated result. Makes multiple API calls internally and presents a unified dashboard.

  4. Database — Maintains a connection to MongoDB, runs queries, and returns formatted results. It's stateful (keeps the connection open).

Key point: The patterns aren't exclusive — a Server can combine patterns. For example, a "project dashboard" Server (aggregation) can also cache results in a local file (filesystem).

Exercise 6: Security audit of a Server (Hard)

Given this pseudocode of an MCP Server, identify at least 3 security problems:

server.register_tool({
  name: "run_query",
  handler: function(args) {
    result = database.execute(args.sql)
    return { content: [{ type: "text", text: result }] }
  }
})

server.register_tool({
  name: "read_file",
  handler: function(args) {
    content = fs.readFile(args.path)
    return { content: [{ type: "text", text: content }] }
  }
})
See solution

Problem 1: SQL Injection

  • database.execute(args.sql) runs any SQL without validation
  • An attacker could run DROP TABLE users or SELECT * FROM passwords
  • Fix: Use prepared statements or limit to SELECT queries

Problem 2: Path Traversal

  • fs.readFile(args.path) reads any file on the system
  • args.path = "/etc/passwd" or args.path = "../../secrets.env" would be valid
  • Fix: Validate that path is inside an allowed directory

Problem 3: No error handling

  • If database.execute or fs.readFile fail, the exception isn't handled
  • The Server crashes instead of returning a clean error
  • Fix: Wrap in try/catch, return isError: true

Problem 4: No input validation

  • There's no inputSchema defined
  • The Server accepts any argument without validating type or format
  • Fix: Define an inputSchema with types and constraints

Problem 5: No size limits

  • read_file could try to read a 10GB file
  • run_query could return millions of records
  • Fix: Limit the size of files and query results

Summary

In this capsule you learned:

  • The MCP Server is your code — the program that exposes capabilities to the Host via the Client
  • It exposes 3 types of capabilities: Tools (functions), Resources (data), Prompts (templates)
  • Each tool has an inputSchema that validates parameters and guides the model
  • Responses follow a standard format with a content array and an isError flag
  • Servers can be stateless (filesystem) or stateful (database)
  • There are 4 common patterns: API wrapper, database, filesystem, aggregation
  • Security is the Server's responsibility: validate inputs, limit access, handle errors
  • The Server doesn't decide which requests to serve — the Host decides that

Next capsule: Complete request-response flow — bring the 3 layers together in an end-to-end flow by tracing a complete request + mini-project: diagram your setup's architecture.


Additional resources

  1. MCP Server Development Guide - Official documentation on MCP Servers
  2. MCP Tools Specification - Tools specification
  3. MCP Resources Specification - Resources specification
  4. MCP Prompts Specification - Prompts specification
  5. JSON Schema Reference - Reference for writing inputSchemas
  6. MCP Server Examples - Official repository of reference MCP servers
  7. OWASP Input Validation Cheat Sheet - Security guide for input validation

Next capsule: Complete request-response flow — trace a request from the user to the Server and back, bringing Host, Client, and Server together in a complete diagram. Includes the module's mini-project.