Which tools Claude can see
Availability is whether a tool is in Claude's context at all, and it is set by the tools option and by bare names in disallowed_tools. A tool that is not available is never attempted, because the model does not know it exists.
Last updated: 20 Oct, 2026 · Claude Agent SDK
The error lesson left the desk with one tool and no way to take it away again. A real session has the built-in tools as well as yours, and two of the options decide which of them Claude is told about.
One file changes here: shop_desk.py gains a Read of its own and an offered method, and its find_tools and run are replaced with versions that know about built-in tools.
A built-in the desk serves itself
A built-in tool runs inside Claude Code rather than in an MCP server, so the stand-in has to answer it directly. One small Read is enough to show the difference, and the later rules need a path to match:
def read_file(args):
"""What the desk serves for a Read call: the file's lines, numbered."""
lines = open(args["file_path"]).read().splitlines()
return "\n".join(f"{number:>5} {line}" for number, line in enumerate(lines, 1))
BUILT_IN = {"Read": read_file} def offered(self):
# the tools option is the availability layer: None means every built-in the
# desk has, a list names the ones to keep, and [] leaves Claude none
asked = self.options.tools
if asked is None or isinstance(asked, dict):
return list(BUILT_IN)
return [name for name in asked if name in BUILT_IN]offered reads the three shapes the option can take, which the built-in tools lesson printed as flags: None for the default set, a list of names to keep, and the preset dict.
Two kinds of tool in one list
async def find_tools(self):
# a built-in runs inside Claude Code, so the desk serves it itself
tools = {name: ("built-in", name) for name in self.offered()}
for server in self.options.mcp_servers:
await self.mcp(server, "initialize", {"protocolVersion": "2025-06-18",
"capabilities": {}, "clientInfo": {"name": "desk", "version": "1"}})
for tool in (await self.mcp(server, "tools/list", {}))["tools"]:
tools[f"mcp__{server}__{tool['name']}"] = (server, tool["name"])
# a bare name in disallowed_tools never reaches the model at all
return {name: where for name, where in tools.items()
if name not in self.options.disallowed_tools}Two things are new. A built-in is recorded against "built-in" rather than a server name, so run knows where to send it. And a bare name in disallowed_tools is dropped at the end, so it never reaches the model.
async def run(self, name, args, call):
where, tool = self.tools[name]
# a built-in answers here; anything else is an MCP tools/call
if where == "built-in":
try:
return BUILT_IN[tool](args), False
except Exception as error:
return f"{type(error).__name__}: {error}", True
result = await self.mcp(where, "tools/call", {"name": tool, "arguments": args})
return result["content"][0]["text"], result.get("isError", False)A failing built-in is turned into an error result here, the same way the in-process server turns a raised exception into one.
View the code here
import asyncio
import itertools
import json
import re
from claude_agent_sdk import (AssistantMessage, ClaudeAgentOptions, ResultMessage,
SystemMessage, TextBlock, ToolResultBlock, ToolUseBlock,
Transport, UserMessage, query)
ORDER = re.compile(r"\b[A-Z]\d+\b")
SESSIONS = itertools.count(1)
def read_file(args):
"""What the desk serves for a Read call: the file's lines, numbered."""
lines = open(args["file_path"]).read().splitlines()
return "\n".join(f"{number:>5} {line}" for number, line in enumerate(lines, 1))
BUILT_IN = {"Read": read_file}
class ShopModel:
"""Stands in for Claude: reads the conversation, picks the next step."""
def __init__(self, script=None):
self.script = list(script or [])
def reply(self, said, results, tools):
# a scripted reply lets a lesson force a step the stand-in would not pick
if self.script:
return self.script.pop(0)
if results:
return {"text": " ".join(results)}
# the newest message first, then the last id named earlier in the chat
orders = ORDER.findall(said[-1]) or ORDER.findall(" ".join(said))[-1:]
wanted = "refund_order" if "refund" in said[-1].lower() else "lookup_order"
tool = next((name for name in tools if name.endswith(wanted)), None)
if orders and tool:
return {"calls": [(tool, {"order_id": order}) for order in orders]}
return {"text": f"I cannot help with order {orders[0]} yet." if orders
else "Which order is this about?"}
class ShopDesk(Transport):
"""Stands in for the Claude Code binary: it runs the loop over JSON lines."""
def __init__(self, options=None, model=None):
self.options = options or ClaudeAgentOptions()
self.model = model or ShopModel()
# a counted id, not a UUID, so the printed output is the same every run
self.session_id = f"session-{next(SESSIONS)}"
self.inbox, self.outbox = asyncio.Queue(), asyncio.Queue()
self.prompts, self.said = asyncio.Queue(), []
self.ids, self.setup = itertools.count(1), {}
self.ready, self.tools = False, {}
self.waiting = {}
async def connect(self):
self.ready = True
def is_ready(self):
return self.ready
async def write(self, data):
# one JSON line at a time, which is what the SDK writes to a real process
await self.inbox.put(json.loads(data))
async def end_input(self):
# for a real process this would close its standard input
await self.inbox.put(None)
async def close(self):
self.ready = False
async def send(self, message):
await self.outbox.put(message)
async def read_messages(self):
# the listener and the worker run alongside the SDK's own read loop
asyncio.create_task(self.listen())
self.worker = asyncio.create_task(self.work())
while (message := await self.outbox.get()) is not None:
if isinstance(message, Exception):
raise message
yield message
async def listen(self):
while (message := await self.inbox.get()) is not None:
# a control request is the SDK asking a question; initialize is first
if message["type"] == "control_request":
if message["request"]["subtype"] == "initialize":
self.setup = message["request"]
await self.respond(message["request_id"])
# a control response answers a question the desk itself asked
elif message["type"] == "control_response":
answer = message["response"]
self.waiting.pop(answer["request_id"]).set_result(answer)
elif message["type"] == "user":
await self.prompts.put(message["message"]["content"])
# the input has ended: finish the work in hand, then close the stream
await self.prompts.put(None)
await self.worker
await self.send(None)
async def respond(self, request_id):
# the stand-in says yes to every request the SDK makes of it
await self.send({"type": "control_response", "response": {
"subtype": "success", "request_id": request_id, "response": {}}})
async def ask(self, request):
# a future per request, so the answer can be awaited where it was asked
request_id = f"desk_{next(self.ids)}"
self.waiting[request_id] = asyncio.get_running_loop().create_future()
await self.send({"type": "control_request", "request_id": request_id,
"request": request})
return await self.waiting[request_id]
async def work(self):
while (prompt := await self.prompts.get()) is not None:
await self.turn(prompt)
async def start(self, prompt):
self.said.append(prompt)
# the tool list is fetched once per desk, not once per question
if not self.tools:
self.tools = await self.find_tools()
await self.send({"type": "system", "subtype": "init", "model": "shop-desk",
"session_id": self.session_id, "tools": list(self.tools)})
async def turn(self, prompt):
await self.start(prompt)
# keep asking the model until it answers instead of calling a tool
results, turns = [], 0
while "calls" in (step := self.model.reply(self.said, results, self.tools)):
turns += 1
results = [await self.use(name, args) for name, args in step["calls"]]
await self.say(step["text"])
await self.finish("success", turns + 1, step["text"])
def offered(self):
# the tools option is the availability layer: None means every built-in the
# desk has, a list names the ones to keep, and [] leaves Claude none
asked = self.options.tools
if asked is None or isinstance(asked, dict):
return list(BUILT_IN)
return [name for name in asked if name in BUILT_IN]
async def mcp(self, server, method, params):
message = {"jsonrpc": "2.0", "id": next(self.ids), "method": method,
"params": params}
answer = await self.ask({"subtype": "mcp_message", "server_name": server,
"message": message})
return answer["response"]["mcp_response"]["result"]
async def find_tools(self):
# a built-in runs inside Claude Code, so the desk serves it itself
tools = {name: ("built-in", name) for name in self.offered()}
for server in self.options.mcp_servers:
await self.mcp(server, "initialize", {"protocolVersion": "2025-06-18",
"capabilities": {}, "clientInfo": {"name": "desk", "version": "1"}})
for tool in (await self.mcp(server, "tools/list", {}))["tools"]:
tools[f"mcp__{server}__{tool['name']}"] = (server, tool["name"])
# a bare name in disallowed_tools never reaches the model at all
return {name: where for name, where in tools.items()
if name not in self.options.disallowed_tools}
async def use(self, name, args, parent=None):
# one id ties the request and its result together, as the message pair does
call = f"toolu_{next(self.ids)}"
block = {"type": "tool_use", "id": call, "name": name, "input": args}
await self.send(self.message("assistant", [block], parent))
text, failed = await self.run(name, args, call)
result = {"type": "tool_result", "tool_use_id": call, "content": text,
"is_error": failed}
await self.send(self.message("user", [result], parent))
return text
async def run(self, name, args, call):
where, tool = self.tools[name]
# a built-in answers here; anything else is an MCP tools/call
if where == "built-in":
try:
return BUILT_IN[tool](args), False
except Exception as error:
return f"{type(error).__name__}: {error}", True
result = await self.mcp(where, "tools/call", {"name": tool, "arguments": args})
return result["content"][0]["text"], result.get("isError", False)
async def say(self, text):
# with include_partial_messages on, the text goes out a word at a time
if self.options.include_partial_messages:
for word in text.split(" "):
delta = {"type": "text_delta", "text": word + " "}
await self.send({"type": "stream_event", "uuid": f"ev_{next(self.ids)}",
"session_id": self.session_id, "event": {
"type": "content_block_delta", "index": 0,
"delta": delta}})
# the whole message still follows, so code written earlier keeps working
await self.send(self.message("assistant", [{"type": "text", "text": text}]))
async def finish(self, subtype, turns, text):
ok = subtype == "success"
result = {"type": "result", "subtype": subtype, "is_error": not ok,
"duration_ms": 0, "duration_api_ms": 0, "num_turns": turns,
"session_id": self.session_id, "total_cost_usd": 0.0,
"usage": {"input_tokens": len(" ".join(self.said).split()),
"output_tokens": len(text.split())}}
# cost is zero and usage counts words, because no model was called
result.update({"result": text} if ok else {"errors": [text]})
await self.send(result)
def message(self, kind, content, parent=None):
body = {"role": kind, "content": content}
if kind == "assistant":
body.update(model="shop-desk", id=f"msg_{next(self.ids)}")
return {"type": kind, "message": body, "parent_tool_use_id": parent,
"session_id": self.session_id}
def describe(message):
# the two messages with no content blocks are read from their own fields
if isinstance(message, SystemMessage):
return f"system {message.subtype}"
if isinstance(message, ResultMessage):
return f"result {message.subtype}: {message.result or message.errors}"
# a type this course does not use prints its name rather than failing
if not isinstance(message, (AssistantMessage, UserMessage)):
return type(message).__name__
return describe_block(message.content[0])
def describe_block(block):
if isinstance(block, TextBlock):
return f"assistant {block.text}"
if isinstance(block, ToolUseBlock):
return f"assistant calls {block.name} {block.input}"
# a tool result sits on the user's side, and its error flag is worth seeing
if isinstance(block, ToolResultBlock):
flag = " (error)" if block.is_error else ""
return f"user result{flag}: {block.content}"
return type(block).__name__
async def watch(prompt, options, model=None):
"""Run one question through a fresh desk and print every message that comes back."""
async for message in query(prompt=prompt, options=options,
transport=ShopDesk(options, model)):
print(describe(message))
from claude_agent_sdk import create_sdk_mcp_server, tool
ORDERS = {"A17": "Order A17 shipped on 3 March."}
# the description is what Claude reads to decide this is the right tool
@tool("lookup_order", "Look up an order's shipping status by its id, such as A17.",
{"order_id": str})
async def lookup_order(args):
order = args["order_id"]
# an unknown id is answered in words, not raised, so Claude can pass it on
status = ORDERS.get(order, f"No order {order} was found.")
return {"content": [{"type": "text", "text": status}]}
shop = create_sdk_mcp_server("shop", tools=[lookup_order])
Printing the tools a session offers
The init message carries the names, so one filter over the stream answers the question the MCP page calls discovering the available tools:
from claude_agent_sdk import ClaudeAgentOptions, SystemMessage, query
from shop_desk import ShopDesk
from shop_tools import shop
async def offered(options):
"""The tool names in the init message: what this session offers Claude."""
# the init message is the first one out, so the loop returns on it
async for message in query(prompt="Hello", options=options,
transport=ShopDesk(options)):
if isinstance(message, SystemMessage):
return message.data["tools"]
print(await offered(ClaudeAgentOptions(mcp_servers={"shop": shop})))['Read', 'mcp__shop__lookup_order']
Both kinds are there: the built-in by its bare name, the MCP tool by its full name. Adding the two options to the same helper shows the availability layer at work:
# the first run keeps no built-ins; the second drops the Read by name
print(await offered(ClaudeAgentOptions(mcp_servers={"shop": shop}, tools=[])))
print(await offered(ClaudeAgentOptions(mcp_servers={"shop": shop},
disallowed_tools=["Read"])))['mcp__shop__lookup_order'] ['mcp__shop__lookup_order']
What the three lists say about this session
- The default run offers
Readandmcp__shop__lookup_order: the desk's own built-in and the shop's tool. - tools=[] leaves the MCP tool alone. The option is about built-ins, so your own tools are untouched.
- disallowed_tools=["Read"] removes the built-in by name. The result is the same as leaving it out of
tools, which is what the two-layer table in the built-in tools lesson said.
The built-in doing real work
The desk's Read reads a real file. This one sits next to the script:
Mon to Fri, 9 to 5
Sat, 10 to 2from shop_desk import ShopModel, watch
# the model is scripted here, so the run shows the tool rather than the choosing
reader = ShopModel(script=[{"calls": [("Read", {"file_path": "opening-hours.txt"})]}])
await watch("When are you open?", ClaudeAgentOptions(mcp_servers={"shop": shop}),
model=reader)system init
assistant calls Read {'file_path': 'opening-hours.txt'}
user result: 1 Mon to Fri, 9 to 5
2 Sat, 10 to 2
assistant 1 Mon to Fri, 9 to 5
2 Sat, 10 to 2
result success: 1 Mon to Fri, 9 to 5
2 Sat, 10 to 2The result carries the file's two lines, numbered, which is the shape the real Read returns according to the tools reference. The desk's version is a few lines of Python, not the real tool, and it exists so the permissions part has a file path to write a rule about.
Taking a tool away against refusing its calls
| Not available | Available but not approved | |
|---|---|---|
| Set by | tools, or a bare disallowed_tools name | allowed_tools, the permission mode, a scoped deny rule |
| Claude knows it exists | No | Yes |
| Cost of a wrong choice | None; it cannot be chosen | A turn spent on a call that is refused |
| Good for | A tool this agent has no business with | A tool that needs a decision per call |
Two reasons to shorten the list
- Fewer wrong turns. A tool Claude cannot see is a tool it cannot pick by mistake.
- A smaller context. Every definition in context costs tokens on every turn. The tool search lesson comes back to this with the numbers.
Bash(rm *) leaves the tool in Claude's context, so Claude may spend a turn trying a call that is then refused. To take a tool away, use its bare name.Related
- Previous: A tool that raises
- Next: External MCP servers
- Reference: Configure allowed tools and the permissions page
- Pass
tools=["Read"]and compare the list with the default run. - Add
disallowed_tools=["mcp__shop__lookup_order"]and ask where A17 is. - Point the scripted
Readat a file that does not exist and read the error result.
This is what real progress feels like.