With the scaffold running, this lesson fills it in with representative examples of each primitive, following naming, documentation, and pagination conventions that matter once a server has more than one or two capabilities, and that become essential once it has dozens.
Tools: Actions with Side Effects or Computation
Tool names should be specific and verb-first (crm_search_accounts, not search), and docstrings should describe when to use the tool, not just what it does, since that is what the model uses to disambiguate between similar tools.
from pydantic import BaseModel, Field
class CreateTicketInput(BaseModel):
title: str = Field(..., min_length=5, max_length=200)
description: str = Field(..., max_length=5000)
priority: str = Field("normal", description="One of: low, normal, high, urgent")
assignee_email: str | None = Field(None, description="Email of the engineer to assign, if known")
@mcp.tool(name="support_create_ticket")
async def create_ticket(input: CreateTicketInput) -> dict:
"""
Create a new support ticket in the internal ticketing system.
Use this when a user reports a bug or issue that needs engineering follow-up,
not for general questions (use support_search_kb for those instead).
"""
ticket = await ticketing_client.create(
title=input.title,
description=input.description,
priority=input.priority,
assignee=input.assignee_email,
)
return {"ticket_id": ticket.id, "url": ticket.url, "status": ticket.status}
The Failure Modes of Poor Tool Naming, Concretely
It is worth walking through what actually goes wrong, since “naming matters” is easy to agree with abstractly and easy to ignore in practice. Consider a server with both get_account and fetch_account_details, registered by two different engineers who did not coordinate, functionally near-identical but described slightly differently. A model choosing between them has no principled basis for the choice, it will sometimes pick one, sometimes the other, for functionally identical requests, and if the two implementations have subtly diverged (one includes billing history, one doesn’t), the caller gets inconsistent results depending on which the model happened to select that turn. The fix is not clever prompting, it is server design discipline: a single, canonical tool per capability, a documented naming convention the whole team follows (verb_object, domain-prefixed per Module 6’s namespacing), and code review that specifically checks new tools against the existing catalog for overlap before merging. This is a governance problem as much as a naming problem, and it is worth raising explicitly in whatever process governs who can add tools to a shared server.
Resources: Addressable, Readable Data
Resources use URI templates (RFC 6570) so one registration serves many instances, identified by the parameterized part of the URI.
@mcp.resource("crm://accounts/{account_id}")
async def get_account(account_id: str) -> str:
"""Fetch a CRM account record by ID, returned as formatted text the model can read directly."""
account = await crm_client.get_account(account_id)
return (
f"Account: {account.name}\n"
f"Tier: {account.tier}\n"
f"ARR: ${account.arr:,.0f}\n"
f"Owner: {account.owner_email}\n"
f"Last contact: {account.last_contact_date}"
)
Pagination for Large Collections
A resource (or a tool) that returns an unbounded collection is a common early mistake: it wastes context window on data the model will not use, increases latency, and in extreme cases can exceed a client’s payload limits entirely. The fix mirrors standard REST API pagination: accept a page size and a continuation cursor, return a bounded page, and include enough information for the caller to fetch the next page.
class ListAccountsInput(BaseModel):
page_size: int = Field(50, ge=1, le=200)
cursor: str | None = Field(None, description="Opaque continuation token from a previous call, omit for the first page")
@mcp.tool(name="crm_list_accounts")
async def list_accounts(input: ListAccountsInput) -> dict:
"""
List CRM accounts, paginated. Returns up to page_size accounts per call
and a next_cursor to fetch the following page; next_cursor is null when
there are no more results.
"""
page = await crm_client.list_accounts(limit=input.page_size, cursor=input.cursor)
return {
"accounts": [a.model_dump() for a in page.items],
"next_cursor": page.next_cursor, # null once exhausted
"total_count": page.total_count,
}
flowchart LR
Model["Model calls\ncrm_list_accounts(page_size=50)"] --> Page1["Page 1: 50 accounts\n+ next_cursor='abc'"]
Model2["Model calls\ncrm_list_accounts(cursor='abc')"] --> Page2["Page 2: 50 accounts\n+ next_cursor=null"]
Page1 -.->|"model decides whether\nit needs another page"| Model2
style Model fill:#EEF0F7,stroke:#6366F1,color:#0F172A
style Model2 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
style Page1 fill:#f0fdf9,stroke:#0D9488,color:#0F172A
style Page2 fill:#f0fdf9,stroke:#0D9488,color:#0F172A
Critically, the tool’s description should tell the model that pagination exists and how to use it ("a next_cursor to fetch the following page"), since a model that does not know pagination is available may simply treat the first page as the complete result set and reason incorrectly about totals, an easy mistake to miss in testing with a small dataset that fits on one page, and a real correctness bug once the underlying collection grows past it in production.
Prompts: Reusable, Parameterized Templates
Prompts belong in the MCP server (rather than the agent’s own system prompt) when the same organization-standard prompt needs to be available consistently across many different agents and clients, for example, a standardized incident postmortem format used regardless of which team’s agent is invoking it.
@mcp.prompt()
def draft_account_health_summary(account_id: str, tone: str = "executive") -> str:
"""
Standardized prompt for summarizing account health, used consistently
across sales, support, and success agents so every team gets the same format.
"""
return (
f"Using the CRM data for account {account_id} (see crm://accounts/{account_id}), "
f"write a 3-paragraph account health summary in a {tone} tone. Cover: "
f"current ARR and tier, recent engagement trend, and one recommended next action."
)
flowchart LR
Agent["Agent"] -->|tools/call\nsupport_create_ticket| Tool["Tool: side effect"]
Agent -->|resources/read\ncrm://accounts/A1042| Res["Resource: read-only data"]
Agent -->|prompts/get\ndraft_account_health_summary| Prompt["Prompt: reusable template"]
style Agent fill:#EEF0F7,stroke:#6366F1,color:#0F172A
style Tool fill:#f0fdf9,stroke:#0D9488,color:#0F172A
style Res fill:#fff7ed,stroke:#f59e0b,color:#0F172A
style Prompt fill:#fff7ed,stroke:#f59e0b,color:#0F172A
The reverse question is worth asking too: when should a standardized prompt live in the agent’s own system prompt instead of an MCP server’s prompt registry? The deciding factor is scope of reuse. If the standardized format is specific to one agent application (a particular support bot’s tone of voice, say), it belongs in that application’s own system prompt, adding it to a shared MCP server just adds indirection for a consumer base of one. Prompts earn their place in an MCP server specifically when multiple independent agents, potentially built by different teams on different frameworks, need the identical template, the same cross-cutting reuse argument that justifies tools and resources living in a shared server in the first place.
The next lesson formalizes input and output validation with Pydantic (and Zod on the TypeScript side) so tools reject malformed input at the protocol boundary instead of failing deep inside business logic.