Defining Tools, Resources & Prompts

13 min read Module 2 of 10 Topic 5 of 30

What you'll learn

  • Write tools with clear names and docstrings that agents can select correctly among many options
  • Implement a resource with a URI template for parameterized reads, including pagination for large collections
  • Register a prompt template and understand when prompts belong in an MCP server versus in the agent's own system prompt
  • Recognize and fix the specific failure modes of poor tool naming and documentation at scale
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

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.

Knowledge Check

3 questions to test your understanding

1 Why does tool naming and description quality matter more as an MCP server grows past a handful of tools?

2 A resource is registered with the URI template `crm://accounts/{account_id}`. What does the {account_id} segment represent?

3 A `list_accounts` resource returns 50,000 account IDs as one giant text block whenever it is read. What is the problem, and what should replace it?

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan