- name
- mcp-tool-design-patterns
- description
- Designs effective MCP tools and resources following best practices including clear descriptions, bounded inputs/outputs, proper annotations (readOnlyHint, destructiveHint, idempotentHint), hierarchical resource templates, progressive discovery patterns, and avoiding anti-patterns like tool bloat, vague contracts, and unbounded responses.
- license
- MIT
- compatibility
- opencode
- metadata
- {"version":"1.0.0","domain":"coding","role":"implementation","scope":"implementation","output-format":"code","triggers":"mcp tool design, resource design, tool descriptions, mcp schema, idempotent hint, resource templates, bounded responses","related-skills":"mcp-server-fastmcp-python, mcp-client-integration","archetypes":"tactical, strategic","anti_triggers":"brainstorming, vague ideation","response_profile":{"verbosity":"medium","directive_strength":"high","abstraction_level":"operational"}}
# MCP Tool Design Patterns
Designs effective Model Context Protocol (MCP) tools and resources that enable AI models to interact with external systems reliably and safely.
## TL;DR Checklist
- [ ] **Progressive Discovery:** Core tools only (5–10), add depth via resources and templates
- [ ] **Strict Schemas:** Use Pydantic models with constraints, not open dicts
- [ ] **Clear Descriptions:** Every tool and resource must be unambiguous (not "Get data")
- [ ] **Bounded Responses:** Paginate lists, limit array sizes, define max response sizes
- [ ] **Annotations:** Mark tools with readOnlyHint, destructiveHint, idempotentHint where applicable
- [ ] **Hierarchical Resources:** Organize by parent/child relationships (DNS-like URIs)
- [ ] **Avoid Tool Bloat:** > 15 tools reduces model accuracy; use resources instead
---
## When to Use
Use this skill when:
- Designing a new MCP server with tools and resources
- Creating tool schemas that AI models will call
- Building resource hierarchies for progressive discovery
- Reviewing existing tools for anti-patterns
- Planning how to expose system functionality via MCP
- Setting up pagination, filtering, or streaming patterns
---
## When NOT to Use
Avoid this skill for:
- Implementing MCP client-side integration (use `mcp-client-integration` instead)
- Setting up MCP server frameworks (use `mcp-server-fastmcp-python` instead)
- Debugging protocol-level MCP issues
- Tasks that don't involve tool/resource design
---
## Core Concepts
### Tool Anatomy
Every MCP tool is a callable unit of work with:
1. **Name** — Lowercase, kebab-case, descriptive (not `get_stuff`, use `fetch-customer-invoice`)
2. **Description** — 1–2 sentences, specific and unambiguous
3. **Input Schema** — Pydantic BaseModel with strict field constraints
4. **Annotations** — Hints about tool behavior (readOnly, destructive, idempotent)
5. **Output** — Structured, bounded response (never unbounded arrays)
### Resource Anatomy
Resources expose data that can be accessed via URIs:
1. **URI Template** — Hierarchical path like `dns://api.example.com/users/{id}/settings`
2. **Description** — What the resource exposes and how to filter/paginate
3. **MIME Type** — Expected format (`text/plain`, `application/json`, `text/html`)
4. **Read-Only Hint** — Indicates if resource can be modified
### Tool vs Resource Decision Tree
```
Does it accept input and perform an action?
→ Tool (call `search-users`, `send-email`, `create-invoice`)
Does it represent structured data accessible via a URI?
→ Resource (access `dns://api.example.com/users/{id}`)
Is it mostly discovery (listing possibilities)?
→ Resource template (let client explore `dns://api.example.com/users`)
Is it a long-running operation?
→ Tool (tools can include status polling hints)
Should the model explore it progressively?
→ Resource + Resource Template (start with core tools, add depth via templates)
```
---
## Design Pattern 1: Progressive Discovery
Start with 5–10 core tools. Use resources and templates to enable the model to discover additional functionality as needed.
**Why?** AI models perform better with focused tool sets. Too many tools → lower accuracy and higher latency.
### Pattern Structure
```
Core Tools (5–10)
↓
Resource Templates
↓
(Model explores templates as needed)
```
### Example: Customer Management System
**Core Tools:**
- `search-customers` — Find customers by name or ID
- `fetch-customer-details` — Get full profile for a specific customer
- `create-invoice` — Generate an invoice
- `send-email` — Send notification emails
**Resource Templates:**
- `dns://crm.example.com/customers` — List all customers (auto-discovery)
- `dns://crm.example.com/customers/{customer_id}` — Specific customer data
- `dns://crm.example.com/customers/{customer_id}/invoices` — Customer's invoices
- `dns://crm.example.com/customers/{customer_id}/settings` — Customer preferences
**Model Interaction:**
```
1. Model calls search-customers
2. Gets customer ID from result
3. Model discovers resource template: dns://crm.example.com/customers/{customer_id}/invoices
4. Model reads invoices via resource (no additional tool call needed)
```
---
## Design Pattern 2: Hierarchical Resource Organization
Organize resources using DNS-like hierarchies to reflect the data model:
```
dns://api.example.com/
/users/{id}/
/settings
/notifications
/billing/
/invoices/{invoice_id}
/payments/{payment_id}
/teams/{team_id}/
/members
/projects/{project_id}
/issues/{issue_id}
```
**Benefits:**
- Predictable naming — models can guess resource URIs
- Clear relationships — `/users/{id}/settings` clearly belongs to a user
- Composable — tools and resources work together naturally
- Discoverable — templates enable progressive exploration
---
## Design Pattern 3: Bounded Responses with Pagination
Never return unbounded arrays. Always paginate and limit sizes.
### Pattern: Cursor-Based Pagination
```python
from pydantic import BaseModel, Field
from typing import List, Optional
class PaginatedResponse(BaseModel):
"""Paginated results with cursor for next batch."""
items: List[dict] = Field(..., max_items=100)
next_cursor: Optional[str] = Field(None, description="Cursor for next page")
has_more: bool = Field(False, description="Whether more results exist")
```
### Tool Example: List Customers with Pagination
```python
from pydantic import BaseModel, Field
from typing import Optional
class ListCustomersInput(BaseModel):
"""List customers with pagination."""
limit: int = Field(10, ge=1, le=100, description="Max results per page")
cursor: Optional[str] = Field(None, description="Pagination cursor from previous response")
filter_status: Optional[str] = Field(None, description="Filter by status: active, inactive, trial")
class ListCustomersOutput(BaseModel):
"""Paginated customer list."""
customers: List[dict] = Field(..., description="Up to 'limit' customers")
next_cursor: Optional[str] = Field(None, description="Pass to next call for more results")
total_available: int = Field(..., description="Approximate total count")
async def list_customers(input: ListCustomersInput) -> ListCustomersOutput:
"""
List customers with optional filtering and pagination.
Always limits results to prevent response bloat.
Use cursor for pagination, not offset (more efficient).
"""
# Enforce max limit even if client requests more
safe_limit = min(input.limit, 100)
# Fetch one extra to detect if more results exist
results = await db.query(
"SELECT * FROM customers WHERE status = ?",
input.filter_status or "active",
limit=safe_limit + 1,
offset_cursor=input.cursor
)
has_more = len(results) > safe_limit
items = results[:safe_limit]
next_cursor = None
if has_more:
next_cursor = items[-1]['id'] # Use last item's ID as cursor
return ListCustomersOutput(
customers=items,
next_cursor=next_cursor,
total_available=await db.count("SELECT COUNT(*) FROM customers")
)
```
---
## Design Pattern 4: Strict Input Schemas with Constraints
Use Pydantic to enforce constraints at the boundary. Never accept open-ended dicts.
### ❌ BAD: Unbounded Input
```python
# ❌ NEVER DO THIS
class SearchInput(BaseModel):
filters: dict # Anything goes — model doesn't know constraints
options: dict # Unbounded — server must validate everything
# Problems:
# - Model can pass invalid filters
# - Server has to guess what's allowed
# - No IDE autocomplete or documentation
```
### ✅ GOOD: Constrained Input
```python
from pydantic import BaseModel, Field
from enum import Enum
class CustomerStatus(str, Enum):
ACTIVE = "active"
INACTIVE = "inactive"
TRIAL = "trial"
class SearchCustomersInput(BaseModel):
"""Search and filter customers."""
name: Optional[str] = Field(
None,
min_length=2,
max_length=100,
description="Customer name to search for (partial match OK)"
)
status: CustomerStatus = Field(
CustomerStatus.ACTIVE,
description="Filter by account status"
)
country_code: Optional[str] = Field(
None,
regex="^[A-Z]{2}$",
description="2-letter ISO country code (US, UK, FR, etc)"
)
limit: int = Field(
10,
ge=1,
le=100,
description="Results per page (1-100)"
)
async def search_customers(input: SearchCustomersInput) -> List[dict]:
"""
Search customers with strict, validated filters.
Benefits:
- Model knows exactly what's allowed
- Constraints enforced before DB call
- Clear error messages if invalid
"""
# All input is already validated by Pydantic
# No defensive checks needed
results = await db.search_customers(
name=input.name,
status=input.status,
country=input.country_code,
limit=input.limit
)
return results
```
---
## Design Pattern 5: Tool Annotations (Hints)
Use annotations to communicate tool behavior to the model:
### readOnlyHint
Indicates a tool doesn't mutate server state.
```python
from mcp.server.models import Tool
read_only_tool = Tool(
name="fetch-customer-profile",
description="Retrieve customer profile information",
inputSchema={...},
readOnlyHint=True # ← Tells model this is safe to call multiple times
)
```
**When to use:**
- Query/search tools
- Read-only data fetches
- Status checks
- Analytics/reporting tools
**Effect:** Models can call these freely without worrying about side effects.
---
### destructiveHint
Indicates a tool makes irreversible changes.
```python
destroy_tool = Tool(
name="delete-customer-account",
description="Permanently delete a customer account and all associated data",
inputSchema={...},
destructiveHint=True # ← Tells model this requires careful reasoning
)
```
**When to use:**
- Delete operations
- Account closures
- Data purges
- Billing cancellations
**Effect:** Models treat these with extra caution, may ask for confirmation.
---
### idempotentHint
Indicates a tool is safe to retry.
```python
idempotent_tool = Tool(
name="create-invoice",
description="Create an invoice. Safe to retry with same inputs.",
inputSchema={...},
idempotentHint=True # ← Tells model retries are safe
)
```
**When to use:**
- Operations where duplicate calls produce the same result
- Tools that check for existing resources before creating
- Upsert operations (create or update)
- Idempotent state transitions
**Example Implementation:**
```python
async def create_invoice(input: CreateInvoiceInput) -> CreateInvoiceOutput:
"""
Create or fetch an invoice.
Idempotent: calling twice with same input returns same invoice_id.
Model can safely retry on transient errors.
"""
# Check if invoice already exists
existing = await db.query(
"SELECT id FROM invoices WHERE customer_id = ? AND reference_id = ?",
input.customer_id,
input.reference_id
)
if existing:
return CreateInvoiceOutput(invoice_id=existing[0]['id'], created=False)
# Create new invoice
new_id = await db.insert("invoices", {...})
return CreateInvoiceOutput(invoice_id=new_id, created=True)
```
---
### openWorldHint
Indicates a tool or resource supports unbounded discovery.
```python
# Use for resource templates that can explore many possibilities
list_resource = ResourceTemplate(
uriTemplate="dns://api.example.com/items",
description="List all items. Supports dynamic filtering.",
mimeType="application/json",
openWorldHint=True # ← Model can explore unknown items dynamically
)
```
**When to use:**
- Resources that support arbitrary filtering
- APIs with unknown/dynamic data
- Exploration-heavy workflows
---
## Design Pattern 6: Stateless vs Stateful Tools
### Stateless Tool (Preferred)
Returns complete results without requiring previous context.
```python
# ✅ GOOD: Stateless, self-contained
class FetchInvoiceInput(BaseModel):
invoice_id: str
class FetchInvoiceOutput(BaseModel):
id: str
customer_id: str
total: float
status: str
items: List[dict]
async def fetch_invoice(input: FetchInvoiceInput) -> FetchInvoiceOutput:
"""Fetch full invoice details. Works regardless of call history."""
return await db.fetch_invoice(input.invoice_id)
```
**Advantages:**
- Can be called in any order
- Result is always the same
- Easier for models to reason about
- Composable with other tools
---
### Stateful Tool (Use Rarely)
Maintains context from previous calls (conversational flow).
```python
# ⚠️ ONLY if necessary: Stateful, context-dependent
class UpdateInvoiceInput(BaseModel):
invoice_id: str = None # Optional if using context
field: str # Which field to update
value: str # New value
async def update_invoice(input: UpdateInvoiceInput, context: Dict) -> dict:
"""Update invoice. Depends on 'current_invoice' in context."""
invoice_id = input.invoice_id or context.get('current_invoice')
if not invoice_id:
raise ValueError("No current invoice in context")
# Update...
```
**When Stateful is OK:**
- Multi-step workflows with required sequence
- Tools that operate on "current selection"
- Interactive UIs or terminal-like interfaces
**Better Alternative:** Use tool parameters instead of context.
---
## Anti-Patterns & Fixes
### Anti-Pattern 1: Tool Bloat (>15 Tools)
**Problem:** Having 20+ tools in a single server
```python
# ❌ BAD: Too many tools
tools = [
"get_user",
"get_users",
"search_users",
"create_user",
"update_user",
"update_user_profile",
"update_user_settings",
"delete_user",
"ban_user",
"get_user_invoices",
"get_user_payments",
# ... 10 more ...
]
# Model gets confused about which tool to use
# Token usage explodes with tool descriptions
# Response time suffers
```
**Solution:** Use Progressive Discovery
```python
View on GitHub