Skip to content

How an MCP-connected assistant chooses tools

  • Lesson
  • Intermediate
  • 15 min read
  • Checked 15 Aug 2026

Suppose a user asks: “Has ABC Technologies sent the July invoice?” The connected application has Gmail, Drive, database, and Slack tools.

An MCP tool definition includes a unique name, a description, and an input schema. It may also include an output schema and annotations.1 A client discovers these definitions through tools/list.

{
"name": "search_gmail",
"description": "Search email messages and attachments; not Drive files.",
"inputSchema": {
"type": "object",
"properties": {
"vendor": { "type": "string" },
"month": { "type": "string" }
},
"required": ["vendor", "month"]
}
}

The description helps a model distinguish similar tools. It does not grant authority, prove the server is trusted, or guarantee correct selection. Clients must treat tool annotations from an untrusted server as untrusted metadata.1

Part Responsibility
MCP server Advertise tools; authenticate and authorize each call; validate input; rate-limit; sanitize output; execute or reject
MCP client Carry protocol messages; validate schemas and results; apply timeouts; show sensitive inputs and request confirmation
Host application Select connections and visible tools, enforce consent and application policy, coordinate the model
Model Interpret the request and, in a model-controlled design, propose a tool name and arguments
Resource service Enforce the caller’s permission on the exact tenant, mailbox, file, or record even if earlier checks fail

The MCP architecture assigns orchestration, consent, and application security boundaries to the host.2 The tools specification also requires servers to validate tool input, implement access control and rate limits, and sanitize output.1 Defense in depth matters: a client-side allowlist does not replace server-side authorization.

The tools specification describes model-controlled use, but it does not mandate one interaction pattern. A host can use a fixed sequence, let a model choose, or combine both.1

  1. 01Host exposes allowed tools
  2. 02Model or code proposes a call
  3. 03Client validates and asks for consent
  4. 04Server revalidates access and input
  5. 05Server executes or rejects
  6. 06Client validates the result
  7. 07Continue or stop

For the invoice question, a fixed search order is easy to draw:

search Gmail → if absent, search Drive → report evidence

That is a workflow, not an agent. A model-directed loop earns its extra cost only when the next source cannot be known until earlier evidence arrives.

If the model proposes send_slack, the host should reject it for this read-only request. “The invoice was not found” is not permission to create a message, approve a payment, or contact the vendor.

Write each description as an operating contract:

Include Example
What it searches or changes “Search email messages and attachments”
When to use it “Use when a sender may have emailed the item”
When not to use it “Does not search files uploaded directly to Drive”
Input meaning and format month is a month name, vendor is the legal vendor name
Output contract Matching IDs, source links, and timestamps

Do not place secrets, authorization logic, or mutable business policy only in prose. Enforce those conditions where the tool executes.

This standard-library example omits JSON-RPC so the client-side boundary stays visible. The host exposes only two read tools, validates the declared fields and domain constraints, derives the tenant from an authenticated session, searches Gmail, then falls back to Drive. The write tool exists but cannot run through this path.

The example is not an MCP server implementation. A real server must repeat input validation and enforce the authenticated caller’s access to the requested mailbox or file. Never trust a tenant_id or user identity supplied by the model.

src/examples/agents/mcp_invoice_search.py
"""A small MCP-style tool loop with client-side policy.
This is a standard-library simulation. Real MCP clients exchange JSON-RPC
messages with servers. The model proposes; the host and client preflight the
request; the server must independently authorize and validate it again.
"""
from dataclasses import dataclass
from typing import Any, Callable
@dataclass(frozen=True)
class ToolSpec:
name: str
description: str
input_schema: dict[str, Any]
read_only: bool
@dataclass(frozen=True)
class UserSession:
user_id: str
tenant_id: str
scopes: frozenset[str]
@dataclass(frozen=True)
class ToolCallResult:
matches: tuple[dict[str, str], ...] = ()
error_code: str | None = None
error_message: str | None = None
@property
def is_error(self) -> bool:
return self.error_code is not None
MONTHS = (
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
)
TOOL_SPECS = {
"search_gmail": ToolSpec(
name="search_gmail",
description=(
"Search email messages and attachments. Use for items a sender may "
"have emailed; do not use for files uploaded directly to Drive."
),
input_schema={
"type": "object",
"properties": {
"vendor": {"type": "string", "maxLength": 120},
"month": {"type": "string", "enum": list(MONTHS)},
},
"required": ["vendor", "month"],
"additionalProperties": False,
},
read_only=True,
),
"search_drive": ToolSpec(
name="search_drive",
description=(
"Search files stored in Drive, including manually uploaded invoices; "
"do not use to search email messages."
),
input_schema={
"type": "object",
"properties": {
"vendor": {"type": "string", "maxLength": 120},
"month": {"type": "string", "enum": list(MONTHS)},
},
"required": ["vendor", "month"],
"additionalProperties": False,
},
read_only=True,
),
"send_slack": ToolSpec(
name="send_slack",
description="Send a Slack message. This creates an external side effect.",
input_schema={
"type": "object",
"properties": {
"channel": {"type": "string"},
"message": {"type": "string"},
},
"required": ["channel", "message"],
},
read_only=False,
),
}
EMAIL_FIXTURES = [
{
"tenant_id": "tenant-a",
"vendor": "ABC Technologies",
"month": "June",
"id": "mail-104",
},
{
"tenant_id": "tenant-b",
"vendor": "ABC Technologies",
"month": "July",
"id": "mail-private",
},
]
DRIVE_FIXTURES = [
{
"tenant_id": "tenant-a",
"vendor": "ABC Technologies",
"month": "July",
"id": "drive-208",
},
]
def _search(rows: list[dict[str, str]], args: dict[str, str]) -> list[dict[str, str]]:
return [
{key: row[key] for key in ("id", "vendor", "month")}
for row in rows
if row["tenant_id"] == args["tenant_id"]
and row["vendor"].casefold() == args["vendor"].casefold()
and row["month"].casefold() == args["month"].casefold()
]
HANDLERS: dict[str, Callable[[dict[str, str]], list[dict[str, str]]]] = {
"search_gmail": lambda args: _search(EMAIL_FIXTURES, args),
"search_drive": lambda args: _search(DRIVE_FIXTURES, args),
}
def invoke_read_tool(
name: str,
arguments: dict[str, Any],
allowed_tools: frozenset[str],
session: UserSession,
) -> ToolCallResult:
"""Perform client-side checks and return an MCP-style structured result."""
if name not in allowed_tools:
return ToolCallResult(error_code="TOOL_NOT_ALLOWED", error_message=name)
spec = TOOL_SPECS.get(name)
if spec is None:
return ToolCallResult(error_code="UNKNOWN_TOOL", error_message=name)
if not spec.read_only:
return ToolCallResult(error_code="APPROVAL_REQUIRED", error_message=name)
if "invoice:read" not in session.scopes:
return ToolCallResult(error_code="FORBIDDEN", error_message="missing invoice:read")
if not session.user_id or not session.tenant_id:
return ToolCallResult(error_code="INVALID_SESSION", error_message="missing identity")
if name not in HANDLERS:
return ToolCallResult(error_code="TOOL_UNAVAILABLE", error_message=name)
required = set(spec.input_schema["required"])
if set(arguments) != required:
return ToolCallResult(
error_code="INVALID_ARGUMENTS",
error_message=f"expected exactly {sorted(required)}",
)
normalized: dict[str, str] = {}
properties = spec.input_schema["properties"]
for key, value in arguments.items():
rules = properties[key]
if rules.get("type") == "string" and not isinstance(value, str):
return ToolCallResult(error_code="INVALID_ARGUMENTS", error_message=key)
cleaned = value.strip()
if key == "month":
cleaned = cleaned.title()
if not cleaned or len(cleaned) > rules.get("maxLength", len(cleaned)):
return ToolCallResult(error_code="INVALID_ARGUMENTS", error_message=key)
if "enum" in rules and cleaned not in rules["enum"]:
return ToolCallResult(error_code="INVALID_ARGUMENTS", error_message=key)
normalized[key] = cleaned
# Tenant identity comes from the authenticated session, never model arguments.
normalized["tenant_id"] = session.tenant_id
return ToolCallResult(matches=tuple(HANDLERS[name](normalized)))
def find_invoice(
vendor: str,
month: str,
session: UserSession,
) -> tuple[str | None, list[str]]:
"""Use a known read-only fallback order and keep an inspectable trace."""
allowed = frozenset({"search_gmail", "search_drive"})
arguments = {"vendor": vendor, "month": month}
trace: list[str] = []
for name in ("search_gmail", "search_drive"):
result = invoke_read_tool(name, arguments, allowed, session)
if result.is_error:
trace.append(f"{name}: rejected ({result.error_code})")
return None, trace
trace.append(f"{name}: {len(result.matches)} match(es)")
if result.matches:
return result.matches[0]["id"], trace
return None, trace
if __name__ == "__main__":
current_user = UserSession(
user_id="user-17",
tenant_id="tenant-a",
scopes=frozenset({"invoice:read"}),
)
invoice_id, calls = find_invoice("ABC Technologies", "July", current_user)
print("\n".join(calls))
print(f"invoice: {invoice_id or 'not found'}")

Run it on Windows from an activated Python 3.10+ virtual environment:

Terminal window
python --version
python src/examples/agents/mcp_invoice_search.py

On macOS or Linux:

Terminal window
python3 --version
python3 src/examples/agents/mcp_invoice_search.py

Expected trace:

search_gmail: 0 match(es)
search_drive: 1 match(es)
invoice: drive-208

Create evaluation cases for:

  • the correct first tool and arguments;
  • confusing tool pairs with overlapping names;
  • an empty result followed by the permitted fallback;
  • a write tool proposed during a read-only request;
  • malformed arguments and unknown tool names;
  • prompt injection inside a tool result;
  • a revoked scope or changed tool list.

Record every proposed call, policy decision, result ID, latency, and stop reason. Redact credentials and sensitive content from traces.

MCP tells the host which capabilities a server exposes. Clear names, descriptions, and schemas help a model propose a call. The host and client control exposure, consent, and request checks; the server independently validates authorization and input before it executes or rejects. MCP carries the messages—neither the model nor a tool description grants authority.

Next: bounded agent systems, prompt-injection boundaries, or the MCP glossary note.

  1. Model Context Protocol, tools specification, 28 July 2026, defines discovery, tool schemas, error results, client checks, and mandatory server-side validation and access controls. 2 3 4

  2. Model Context Protocol, architecture, 28 July 2026, assigns connection permissions, consent, user authorization decisions, context aggregation, and orchestration to the host while keeping servers independently responsible for their capabilities.