S14. MCP Tools — External Tools, Standard Protocol
S14. MCP Tools — External Tools, Standard Protocol
The Problem
The base tools in earlier chapters are written directly in code.py. We could integrate a documentation system and deployment platform by adding search_docs, deploy_status, and trigger_deploy, but every service would require another set of tool definitions, parameter schemas, and call handlers.
MCP separates those responsibilities. A server provides a tool list and invocation endpoint. The harness connects to it, assigns model-facing names, applies permission checks, and gives the discovered tools to the model.
The Solution
This chapter starts from s04's five base tools and hooks, then adds three parts:
MCPClientstores the tool definitions and call handlers returned by a server.connect_mcpconnects to one server and obtains its tool list.assemble_tool_poolcombines the base tools with tools from every connected server.
The docs and deploy servers are in-process stand-ins for tools/list, tools/call, and a dynamic tool pool. This chapter does not implement a real MCP transport.
How It Works
1. The base agent loop stays the same
Before each model call, the harness assembles the current tool pool:
def agent_loop(messages: list):
while True:
tools, handlers = assemble_tool_pool()
response = client.messages.create(
model=MODEL,
system=assemble_system_prompt(),
messages=messages,
tools=tools,
max_tokens=8000,
)
...After a new server connects, the next assemble_tool_pool() call adds its tools to the model input. Tool results are still appended to messages as tool_result blocks.
2. MCPClient stores discovery results and call handlers
class MCPClient:
def register(self, tool_defs, handlers):
self.tools = list(tool_defs)
self._handlers = dict(handlers)
def call_tool(self, tool_name, args):
handler = self._handlers.get(tool_name)
if not handler:
return f"MCP error: unknown tool '{tool_name}'"
try:
return str(handler(**args))
except Exception as error:
return f"MCP error: {type(error).__name__}: {error}"register() represents the discovered tool list. call_tool() represents the invocation boundary. Errors return to the model instead of terminating the agent loop.
3. connect_mcp only connects and discovers
def connect_mcp(name: str) -> str:
if name in mcp_clients:
return f"MCP server '{name}' already connected"
factory = MOCK_SERVERS.get(name)
if not factory:
return f"Unknown server '{name}'"
server = factory()
mcp_clients[name] = server
...Initially, the model sees the five base tools and connect_mcp. After connect_mcp(name="docs"), the harness stores the docs client. The next model call also sees:
mcp__docs__search
mcp__docs__get_version4. Prefixes separate tools from different servers
Several servers may expose search or status. The harness uses:
mcp__{server}__{tool}normalize_mcp_name() replaces characters outside the model tool-name alphabet with underscores. Tool-pool assembly also checks normalized-name collisions and the 64-character limit:
prefixed = f"mcp__{safe_server}__{safe_tool}"
if prefixed in origins:
raise ValueError("MCP tool name collision after normalization")As a result, docs.one/get.version and docs_one/get_version cannot silently map to the same name.
5. Tool definitions and handlers enter the pool together
tools.append({
"name": prefixed,
"description": tool_def.get("description", ""),
"input_schema": schema,
})
handlers[prefixed] = (
lambda *, client=server, tool=raw_name, **kwargs:
client.call_tool(tool, kwargs)
)The model sees the prefixed name. The handler calls MCPClient with the server's original tool name. Default arguments capture the current client and tool so every lambda does not point to the last item in the loop.
6. The host decides permissions
An MCP server may provide readOnlyHint or destructiveHint, but those hints come from the server and are not authorization. This chapter uses a host-side policy:
MCP_HOST_POLICY = {
("docs", "search"): "allow",
("docs", "get_version"): "allow",
("deploy", "status"): "allow",
("deploy", "trigger"): "confirm",
}permission_hook() looks up this policy using the normalized tool name. An unconfigured external tool requires confirmation by default. A description containing readOnly does not make a tool trusted.
7. Input errors stay at the tool boundary
The model may omit a required argument or send a field the server does not accept. Both execute_tool() and MCPClient.call_tool() catch those errors and return an error tool_result:
MCP error: TypeError: <lambda>() missing 1 required argument: 'query'The model can correct its arguments on the next turn without terminating the lesson script.
What Changed from s04
| Component | s04 | s14 | |---|---|---| | Base tools | Five fixed tools | Unchanged | | Tool source | Definitions in code.py | Base tools plus discovered MCP tools | | Tool pool | Fixed TOOLS | Built each turn by assemble_tool_pool() | | External tool names | None | mcp{server}{tool} | | Permission | Shell and path checks | Adds a host-side MCP policy | | MCP transport | None | In-process server stand-ins demonstrate the boundary |
This chapter does not carry Task, Background, Cron, Team, or Worktree. They join MCP in the s15 Integrated Harness.
Try It Out
cd learn-claude-code
python s14_mcp_plugin/code.pyEnter:
Connect to the docs server, search for agent hooks, and tell me the current documentation API version.A typical tool trace is:
connect_mcp(name="docs")
mcp__docs__search(query="agent hooks")
mcp__docs__get_version()Then enter:
Connect to the deploy server and check the web service status. Do not trigger a deployment.status runs under the host policy. trigger requires user confirmation.
What's Next
MCP is still an independent course branch here. s15 Integrated Harness combines the base tools, hooks, skills, context, memory, tasks, background work, cron, teams, and MCP in one runtime.
S14 — Complete teaching code
#!/usr/bin/env python3
"""
s14: MCP Tools - discover external tools and add them to the agent loop.
Run: python s14_mcp_plugin/code.py
Need: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY
connect_mcp("docs")
|
v
+------------------+ tools/list +------------------+
| Agent Harness | <----------------- | MCP server |
| | | docs |
| built-in tools | tools/call | |
| + MCP tools | -----------------> | search |
+--------+---------+ | get_version |
| +------------------+
v
+-----------------------------------------------+
| bash | read | write | edit | glob | connect |
| mcp__docs__search | mcp__docs__get_version |
+-----------------------------------------------+
"""
import glob
import os
import re
import subprocess
from pathlib import Path
try:
import readline
readline.parse_and_bind("set bind-tty-special-chars off")
except ImportError:
pass
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
BASE_SYSTEM = (
f"You are a coding agent at {WORKDIR}. Use built-in and connected MCP "
"tools to solve tasks. Call connect_mcp before using a server."
)
# -- From s04: base tools --
def run_bash(command: str) -> str:
try:
result = subprocess.run(
command,
shell=True,
cwd=WORKDIR,
capture_output=True,
text=True,
timeout=120,
)
output = (result.stdout + result.stderr).strip()
output = output[:50000] if output else "(no output)"
if result.returncode:
return f"Error: command exited with status {result.returncode}\n{output}"
return output
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
except OSError as exc:
return f"Error: {type(exc).__name__}: {exc}"
def run_read(path: str, limit: int | None = None) -> str:
try:
lines = (WORKDIR / path).resolve().read_text(encoding="utf-8").splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)
except Exception as exc:
return f"Error: {exc}"
def run_write(path: str, content: str) -> str:
try:
target = (WORKDIR / path).resolve()
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return f"Wrote {len(content)} bytes to {path}"
except Exception as exc:
return f"Error: {exc}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
target = (WORKDIR / path).resolve()
content = target.read_text(encoding="utf-8")
count = content.count(old_text)
if count != 1:
return f"Error: Expected 1 occurrence, found {count}"
target.write_text(content.replace(old_text, new_text), encoding="utf-8")
return f"Edited {path}"
except Exception as exc:
return f"Error: {exc}"
def run_glob(pattern: str) -> str:
try:
matches = [
match
for match in glob.glob(pattern, root_dir=WORKDIR)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR.resolve())
]
return "\n".join(matches[:200]) if matches else "(no matches)"
except Exception as exc:
return f"Error: {exc}"
BASE_TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"limit": {"type": "integer"}},
"required": ["path"]}},
{"name": "write_file", "description": "Write content to a file.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"content": {"type": "string"}},
"required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text once.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"old_text": {"type": "string"},
"new_text": {"type": "string"}},
"required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files by glob pattern.",
"input_schema": {"type": "object",
"properties": {"pattern": {"type": "string"}},
"required": ["pattern"]}},
]
BASE_HANDLERS = {
"bash": run_bash,
"read_file": run_read,
"write_file": run_write,
"edit_file": run_edit,
"glob": run_glob,
}
# -- New in s14: MCP discovery and dispatch --
class MCPClient:
"""Small in-process stand-in for MCP tools/list and tools/call."""
def __init__(self, name: str):
self.name = name
self.tools: list[dict] = []
self._handlers: dict[str, callable] = {}
def register(self, tool_defs: list[dict], handlers: dict[str, callable]):
names = [tool.get("name") for tool in tool_defs]
if any(not isinstance(name, str) or not name for name in names):
raise ValueError("Every MCP tool needs a non-empty name")
if len(set(names)) != len(names):
raise ValueError(f"Duplicate MCP tool name on server {self.name!r}")
missing = [name for name in names if name not in handlers]
if missing:
raise ValueError(f"Missing MCP handlers: {', '.join(missing)}")
self.tools = list(tool_defs)
self._handlers = dict(handlers)
def call_tool(self, tool_name: str, args: dict) -> str:
handler = self._handlers.get(tool_name)
if not handler:
return f"MCP error: unknown tool '{tool_name}'"
try:
return str(handler(**args))
except Exception as exc:
return f"MCP error: {type(exc).__name__}: {exc}"
mcp_clients: dict[str, MCPClient] = {}
mcp_tool_policies: dict[str, str] = {}
_DISALLOWED_CHARS = re.compile(r"[^a-zA-Z0-9_-]")
# Authorization comes from host configuration, never server descriptions.
MCP_HOST_POLICY = {
("docs", "search"): "allow",
("docs", "get_version"): "allow",
("deploy", "status"): "allow",
("deploy", "trigger"): "confirm",
}
def normalize_mcp_name(name: str) -> str:
"""Replace characters outside the model tool-name alphabet."""
normalized = _DISALLOWED_CHARS.sub("_", name)
if not normalized:
raise ValueError("MCP names cannot normalize to an empty string")
return normalized
def _mock_server_docs() -> MCPClient:
server = MCPClient("docs")
server.register(
tool_defs=[
{
"name": "search",
"description": "Search the documentation.",
"inputSchema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
"annotations": {"readOnlyHint": True},
},
{
"name": "get_version",
"description": "Get the documentation API version.",
"inputSchema": {"type": "object", "properties": {}},
"annotations": {"readOnlyHint": True},
},
],
handlers={
"search": lambda query: f"[docs] Found 3 results for '{query}'",
"get_version": lambda: "[docs] API v2.1.0",
},
)
return server
def _mock_server_deploy() -> MCPClient:
server = MCPClient("deploy")
server.register(
tool_defs=[
{
"name": "trigger",
"description": "Trigger a deployment.",
"inputSchema": {
"type": "object",
"properties": {"service": {"type": "string"}},
"required": ["service"],
},
"annotations": {"destructiveHint": True},
},
{
"name": "status",
"description": "Check deployment status.",
"inputSchema": {
"type": "object",
"properties": {"service": {"type": "string"}},
"required": ["service"],
},
"annotations": {"readOnlyHint": True},
},
],
handlers={
"trigger": lambda service: f"[deploy] Triggered: {service}",
"status": lambda service: f"[deploy] {service}: running (v1.4.2)",
},
)
return server
MOCK_SERVERS = {
"docs": _mock_server_docs,
"deploy": _mock_server_deploy,
}
def connect_mcp(name: str) -> str:
if name in mcp_clients:
return f"MCP server '{name}' already connected"
factory = MOCK_SERVERS.get(name)
if not factory:
return f"Unknown server '{name}'. Available: {', '.join(MOCK_SERVERS)}"
server = factory()
mcp_clients[name] = server
names = ", ".join(tool["name"] for tool in server.tools)
print(f" [mcp] connected: {name} -> {names}")
return (
f"Connected to MCP server '{name}'. "
f"Discovered {len(server.tools)} tools: {names}"
)
def run_connect_mcp(name: str) -> str:
return connect_mcp(name)
CONNECT_TOOL = {
"name": "connect_mcp",
"description": "Connect to an MCP server and discover its tools.",
"input_schema": {
"type": "object",
"properties": {"name": {"type": "string", "enum": ["docs", "deploy"]}},
"required": ["name"],
},
}
BUILTIN_TOOLS = [*BASE_TOOLS, CONNECT_TOOL]
BUILTIN_HANDLERS = {**BASE_HANDLERS, "connect_mcp": run_connect_mcp}
def assemble_tool_pool() -> tuple[list[dict], dict[str, callable]]:
"""Combine built-in tools with every connected server tool."""
global mcp_tool_policies
tools = list(BUILTIN_TOOLS)
handlers = dict(BUILTIN_HANDLERS)
policies: dict[str, str] = {}
origins = {
tool["name"]: f"built-in tool {tool['name']!r}"
for tool in tools
}
for server_name, server in mcp_clients.items():
safe_server = normalize_mcp_name(server_name)
for tool_def in server.tools:
raw_name = tool_def["name"]
safe_tool = normalize_mcp_name(raw_name)
prefixed = f"mcp__{safe_server}__{safe_tool}"
if len(prefixed) > 64:
raise ValueError(f"MCP tool name is longer than 64 characters: {prefixed}")
origin = f"MCP tool {server_name!r}/{raw_name!r}"
if prefixed in origins:
raise ValueError(
"MCP tool name collision after normalization: "
f"{prefixed!r} maps both {origins[prefixed]} and {origin}"
)
schema = tool_def.get("inputSchema", {})
if not isinstance(schema, dict) or schema.get("type", "object") != "object":
raise ValueError(f"Invalid input schema for {origin}")
origins[prefixed] = origin
tools.append({
"name": prefixed,
"description": tool_def.get("description", ""),
"input_schema": schema,
})
handlers[prefixed] = (
lambda *, client=server, tool=raw_name, **kwargs:
client.call_tool(tool, kwargs)
)
policies[prefixed] = MCP_HOST_POLICY.get(
(server_name, raw_name), "confirm"
)
mcp_tool_policies = policies
return tools, handlers
def assemble_system_prompt() -> str:
if not mcp_clients:
return BASE_SYSTEM
return BASE_SYSTEM + "\n\nConnected MCP servers: " + ", ".join(mcp_clients)
# -- From s04: hooks and permission checks --
HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def register_hook(event: str, callback):
HOOKS[event].append(callback)
def trigger_hooks(event: str, *args):
for callback in HOOKS[event]:
result = callback(*args)
if result is not None:
return result
return None
def permission_hook(block):
if block.name == "bash":
command = block.input.get("command", "")
for pattern in DENY_LIST:
if pattern in command:
return f"Permission denied by deny list: {pattern}"
if any(keyword in command for keyword in DESTRUCTIVE):
print(f"\n[permission] {block.name}({block.input})")
if input("Allow? [y/N] ").strip().lower() not in {"y", "yes"}:
return "Permission denied by user"
if block.name in {"read_file", "write_file", "edit_file"}:
raw_path = block.input.get("path", "")
if not (WORKDIR / raw_path).resolve().is_relative_to(WORKDIR.resolve()):
print(f"\n[permission] {block.name}({block.input})")
if input("Allow? [y/N] ").strip().lower() not in {"y", "yes"}:
return "Permission denied by user"
if block.name.startswith("mcp__"):
policy = mcp_tool_policies.get(block.name, "confirm")
if policy != "allow":
print(f"\n[permission] External tool {block.name}({block.input})")
if input("Allow? [y/N] ").strip().lower() not in {"y", "yes"}:
return "Permission denied by user"
return None
def log_hook(block):
preview = str(list(block.input.values())[:2])[:60]
print(f"[hook] {block.name}({preview})")
return None
def large_output_hook(block, output):
if len(str(output)) > 100000:
print(f"[hook] Large output from {block.name}: {len(str(output))} chars")
return None
def context_hook(query: str):
print(f"[hook] UserPromptSubmit: working in {WORKDIR}")
return None
def summary_hook(messages: list):
tool_count = sum(
1
for message in messages
for block in (
message.get("content")
if isinstance(message.get("content"), list)
else []
)
if isinstance(block, dict) and block.get("type") == "tool_result"
)
print(f"[hook] Stop: session used {tool_count} tool calls")
return None
register_hook("UserPromptSubmit", context_hook)
register_hook("PreToolUse", permission_hook)
register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
register_hook("Stop", summary_hook)
def execute_tool(block, handlers: dict[str, callable]) -> str:
blocked = trigger_hooks("PreToolUse", block)
if blocked:
return str(blocked)
handler = handlers.get(block.name)
if not handler:
return f"Unknown tool: {block.name}"
try:
output = str(handler(**block.input))
except Exception as exc:
output = f"Error: {type(exc).__name__}: {exc}"
trigger_hooks("PostToolUse", block, output)
return output
# -- Agent loop with a dynamic tool pool --
def agent_loop(messages: list):
while True:
try:
tools, handlers = assemble_tool_pool()
response = client.messages.create(
model=MODEL,
system=assemble_system_prompt(),
messages=messages,
tools=tools,
max_tokens=8000,
)
except Exception as exc:
messages.append({
"role": "assistant",
"content": [{
"type": "text",
"text": f"[Error] {type(exc).__name__}: {exc}",
}],
})
trigger_hooks("Stop", messages)
return
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
trigger_hooks("Stop", messages)
return
results = []
for block in response.content:
if block.type != "tool_use":
continue
print(f"> {block.name}")
output = execute_tool(block, handlers)
print(output[:300])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
print("s14: MCP tools")
print("Enter a question, press Enter to send. Type q to quit.\n")
history = []
while True:
try:
query = input("s14 >> ")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in {"q", "exit", ""}:
break
trigger_hooks("UserPromptSubmit", query)
history.append({"role": "user", "content": query})
agent_loop(history)
for block in history[-1].get("content", []):
if getattr(block, "type", None) == "text":
print(block.text)
elif isinstance(block, dict) and block.get("type") == "text":
print(block.get("text", ""))
print()
Try it — MCP Tools scenario
The agent discovers external MCP tools and exposes them through a normalized tool namespace.