Domain 4 โ Facts
Tools & MCP ยท the things to remember, in plain words
Each item is one rule that decides exam questions. Read the short headline, say the rule to yourself, then read the line to check. Open why if the reason is not clear, and follow the question link to see the rule in action. Collected from the lesson, the practice questions and two full third-party mock exams. They help you remember; they do not guarantee a score.
Back to Domain 4 lesson ยท Domain 4 โ Questions ยท Domain Q&A
Built-in tools: search and edit
๐ The story: find and refactor in 3 scenarios
- Edit, replace_all, or Read + Write? Ask: how many places should change? Every copy of the same text in one file โ one
Editwithreplace_all: true. One place with unique text โ a normalEdit. Edit fails as not unique and only one copy should change โReadthe whole file, change that spot,Writeit back (the exam's fallback every time). Concept drill ยท Q297 ยท Concept drill ยท Q299 ยท Concept drill ยท Q311 ยท โ wrong choice to avoid: replace_all when only one copy should change; 12 or 31 separate Edit calls; Bash sedwhy
Editreplaces exact text, and itsold_stringmust appear exactly once, or Edit refuses. Scenario 1: renameuserDataโcustomerData, 12 times in one file, all should change โreplace_all: true(one call, not 12 Edits). Scenario 2: a host name appears 31 times invalues.yamland every one must change โ same,replace_all: true. Scenario 3: the same 5-line retry block is pasted in several tests, and only the one intest_refund_partialshould change.replace_allwould change all of them, and longerold_stringstill matches several copies โReadthe file, change that one block,Writethe full file back.Writealways replaces the whole file; it never merges. - Edit says "not unique"? If every copy should change โ repeat the Edit with
replace_all: true. If only one copy should change โRead+Writethe whole file. The exam marks "retry with a longerold_string" wrong, even though the docs list it as one option. Concept drill ยท Q299 ยท Concept drill ยท Q311 ยท โ wrong choice to avoid: widening old_string again and again on identical blockswhy
The official exam guide (Task 2.5) names it: "When Edit fails due to non-unique text matches, using Read + Write as a fallback for reliable file modifications." The error is a safety stop: Edit won't guess which match you meant. With identical or near-identical blocks (pasted tests, generated config, an interface in three places), more context still doesn't single one out, so the exam's fallback is the full rewrite: read the file, change the one block in memory, write it back. Splitting files, editing by line number with sed, or reporting failure to a human are distractors.
- Explore a codebase: which tool first? Go from cheap to expensive:
Globfinds files by name or path (**/*.test.ts),Grepfinds which files and lines contain text (charge(), thenReadonly the files that matter. Build understanding step by step instead of reading everything. โ wrong choice to avoid: reading every file into the main session, or pasting a list of 30 pathswhy
Every file you
Readcosts context. Glob and Grep return short lists, so you can narrow down first. Scenario: "how does authorization work across 30 files?" โ Grep for the policy class, Read the 3โ4 central files, then follow the imports. For a very large discovery phase, run it in a subagent (like Explore) so only the summary comes back to the main conversation. - Grep missed some callers? Text search finds names, not meaning. When callers must change (you remove a function or change its parameters) and it is re-exported under another name (
export { charge as pay }), Grep forcharge(misses thepay(callers. Grep the export too, then each alias. A pure rename doesn't need this: update the export line and alias callers keep working. โ wrong choice to avoid: one Grep for the original name, reported as completewhy
Scenario: you must change
charge()and find all callers. One file re-exports it aspay, another importspayand calls it. A single Grep for the old name looks complete but isn't. Search for the export statement, note every alias, and search for each alias.
Tool definitions & selection
- What does the model see? Here's the thing: the model never sees your code. A tool is just
name+description+input_schema(JSON Schema), plus optionalstrict: true. The model reads those; your dispatcher runs the real function. You resendtools[]on every request, because the API remembers nothing. โ wrong choice to avoid: thinking the model reads or runs your function codewhy
Claude reads those three strings and decides from them whether to propose a call and which arguments to send. Your dispatcher (the code that routes tool calls) gets the proposed
tool_useblock, checks it, runs the function and sends back the result. The Messages API keeps no session, so the model only knows the tools that were in the last request. - What goes in a description? Think of the description as the model's only menu text. Say what the tool does, when to use it, what the input points to, what comes back, and when NOT to use it. "Get data" can't tell an order lookup from a refund. โ wrong choice to avoid: a description that only repeats the tool name
why
The model picks tools by reading descriptions, nothing else. So put the deciding details there: the purpose, when to call it, what the input identifies (an order ID, not a customer name), and what it returns. A description that only repeats the name gives the model nothing, so two such tools get picked almost at random. Add the "not for refunds" line too, so close cases land on the right tool.
- Model picks the wrong tool? If the model mixes up
search_ordersandget_order, the first fix is to rewrite the descriptions: when to use each, when not to, and merge tools that truly overlap. Wrong answers: a stronger prompt, fine-tuning, few-shot examples, a third chooser tool, or forcingtool_choice. Practice Q&A ยท Q21 ยท Practice Q&A ยท Q63 ยท โ wrong choice to avoid: a stronger prompt, fine-tuning, or forcing tool_choicewhy
The confusion lives in the tool definitions, so fix it there: search finds candidate order IDs,
get_orderreads one ID you already know. A prompt or few-shot examples put the rule in a second place that drifts out of sync. Fine-tuning is too early while the interface is unclear, a third tool adds one more decision, and forcingtool_choiceonly works when your code already knows the route. Fix the contract first, then test the real selection errors. - Your tools ignored for built-ins? If the agent picks Grep and Bash over your relevant MCP tools 87% of the time, your one-line descriptions are losing to well-documented built-ins. Expand them. Don't cut to 5โ6 tools; there is no magic tool count. โ wrong choice to avoid: reducing the tool set to 5โ6 tools per session
why
Built-ins ship with full parameter docs and examples; your MCP tools say only "Analyzes dependencies". The model picks mainly by description, so write what each tool does, its inputs and outputs, and when it beats the generic tool. Too many tools can hurt selection, but fourteen tools is not proof of overload and there is no universal 5โ6 limit. Removing tools is the fix for out-of-role tools, not for relevant ones that are under-described.
- Tool doesn't fit the role? Simple rule: relevant but ignored โ describe it better; out of role โ remove it. A search agent calling
summariseorsentiment_analysisshould not have those tools. A prompt telling it to ignore them, orPreToolUsehooks blocking them, are the wrong answers. โ wrong choice to avoid: a system-prompt instruction to ignore the analysis tools, or PreToolUse hooks that block themwhy
A raw-data search worker should only collect evidence; analysis belongs to a specialist agent with its own tools. Giving it analysis tools and hoping it ignores them is a gamble. Hooks that block the calls are extra safety, but the useless tools are still on offer. Scoping which tools each agent gets is deterministic (it works every time), so move the analysis tools to the agent whose job they serve.
- Contract or invoice? A contract that mentions invoice dates is still a contract. Fix the descriptions:
extract_contract_fieldscovers agreements with payment terms, andextract_invoice_fieldsis for standalone invoices only. Renaming, toggling allowedTools, or a prompt rule are the wrong fixes. โ wrong choice to avoid: removing the invoice tool from allowedTools per document type, or a system-prompt routing rulewhy
A supplier agreement with a payment schedule is a contract; its invoice clauses don't turn it into a bill. Today both descriptions only repeat their names, so the model has nothing to decide with when both words appear. Put the boundary where the model chooses, in the descriptions. Renaming explains no boundary, toggling allowedTools assumes a reliable upstream classifier the scenario never has, and a prompt rule is weaker than the description the model reads while building the call.
- Right tool, wrong SQL? If the model picks the right tool but writes the wrong SQL dialect (
string_agginstead ofLISTAGG), say Snowflake SQL in the tool description. Not a validation layer, not auto-translation, not a function list in the system prompt. โ wrong choice to avoid: a validation layer or auto-translation instead of naming the dialectwhy
Picking the tool and writing a good argument are two different steps. Here the pick was right, but "Accepts SQL" never said which dialect. Put the rule next to the tool it governs: say it expects Snowflake SQL and give an example like
LISTAGG. A validator or translator is fragile extra machinery that hides missing information, and a system-prompt list costs tokens on every turn even when the tool isn't used. - Where does the description come from? With
@mcp.tool(), your docstring becomes thedescriptionthe model reads (unless you passdescription=), and your type hints become theinputSchema. What the function returns is the result of one call, never the description. โ wrong choice to avoid: thinking the return value becomes the descriptionwhy
The decorator registers a normal Python function with the MCP server object and builds the advertised metadata from it. You still write the function body yourself. A lazy one-line docstring gives you exactly the under-described tool that loses to built-ins.
- How to make a clean schema? Use
required,enumandadditionalProperties: falseso bad arguments fail early. Name tools verb + domain (get_invoice,issue_credit). Keep results short, because every tool result uses up context window.why
requiredstops an empty{},enumlimits a field to legal values, andadditionalProperties: falserejects extra fields instead of silently ignoring them. So bad input fails at validation, not during execution. Verb-plus-domain names show at a glance which tools read and which change things. Every result is added to the conversation and paid for on every later turn, so return what the model needs and keep the rest in logs.
tool_choice, strict & the tool_use โ tool_result contract
- Need one exact tool? To guarantee one specific tool, name it:
{"type":"tool","name":"generate_report"}. Thetool_choiceoptions areauto(model decides),any(some tool, model picks which), the named form (that tool), andnone(no tools).anymay pick a different tool. โ wrong choice to avoid: setting tool_choice toanyon every turnwhy
tool_choiceis the request field that limits what the model may pick.autoallows a text answer or a tool call.anyforces some call, but the model might chooseweb_searchinstead ofgenerate_report. Only the named form fixes which tool runs. A sentence in the system prompt is only advice and enforces nothing. - Force once, then release?
tool_choicegoes with each request, so forcegenerate_reporton turn one and sendautoafter that. Forcing never runs anything. And while forcing is on, the model can't reply in text, so it can't ask the user a question. โ wrong choice to avoid: leaving the tool forced on later turnswhy
The API is stateless, so every request carries its own
tool_choice. Your app forces the tool on request one, runs the approved call, appends the result, and sends the next request withauto. Forcing still only makes the model propose atool_useblock; your harness (the code running the loop) executes it. Leave a forced oranysetting on, and the model can never answer in plain text, not even with a clarifying question. - What does strict check?
"strict": truesits in the tool definition next tonameandinput_schema. The Claude API then won't let the model produce{}, wrong types, or extra fields. ButO-999still passes: strict checks the shape, not whether the order exists or who owns it. โ wrong choice to avoid: believing strict checks that the record exists or belongs to the userwhy
Strict mode makes the API keep the generated arguments inside your declared schema, on supported models. It checks structure only: a perfect-looking
order_idlikeO-999is accepted even if no such order exists or it belongs to another customer. Only your backend can check those business facts. Strict is a Claude API setting; an MCP server connected through.mcp.jsonnever supplies it. - Strict on, still validate? Yes.
strictadds no validator code to your app. Runjsonschemaontool_use.inputbefore dispatch. If the tool name is unknown or the arguments are invalid, send back an errortool_result; never execute it. โ wrong choice to avoid: skipping validation because strict is onwhy
Your dispatcher checks the input against the schema, looks the name up in a list of approved functions, and checks permission from the trusted session. Anything that fails becomes a
tool_resultwithis_error: trueand a helpful message tied to the call ID. The model can then recover, and nothing unexpected runs. This is the harness's job on every request, with or without strict. - What's the tool-call order? The order follows who has what:
tools[]sent โ model returnsstop_reason: "tool_use"โ harness validates and authorizes โ executes โtool_resultin a user-role message. You can't validate arguments that don't exist yet. Practice Q&A ยท Q65 ยท โ wrong choice to avoid: validating the input before the model has returned arguments, and sending the result in an assistant-role messagewhy
The model can only propose a call after it sees the definitions, and your harness can only check arguments after the model writes them. When the response comes back with
stop_reason: "tool_use", the harness checks and authorizes the input, runs the backend, and returns the result in the next user-role message. Validating before the proposal, or sendingtools[]after the call, is an impossible order. Atool_resultin an assistant-role message is the wrong role. - Where does the result go? First append the assistant's full content, then send
{"type":"tool_result","tool_use_id":"toolu_1","content":"โฆ","is_error":false}in a user message. Your app wrote that block, even though the role saysuser. Practice Q&A ยท Q65 ยท Practice Q&A ยท Q26 ยท โ wrong choice to avoid: dropping the assistant's tool_use content, or sending the result with another rolewhy
The history must keep the assistant's complete response, including its
tool_useblock. Then comes a user-role message holding thetool_result, matched bytool_use_id. Theuserrole means its place in the conversation, not who wrote it; your app builds the block from what the backend returned. Drop the assistant content or use another role, and the pairing the API expects breaks. - Several calls at once? If one response has several
tool_useblocks, run each allowed call and return onetool_resultpertool_use_id, all in one user message. A blocked or failed call still gets an error result, never silence. Practice Q&A ยท Q26 ยท โ wrong choice to avoid: returning one combined result for both callswhy
One response can ask for an order lookup and a ticket lookup with two different IDs. Check and authorize each, run the allowed ones (in parallel when safe), and put one result block per ID in the next user message. Running only the first leaves a request unanswered; merging both into one result loses which answer belongs to which call. A refused or failed call still gets an
is_errorresult, because a missing pair breaks the conversation. - Why did the whole run die? If one timeout kills the whole run, your harness threw an exception instead of returning
is_error: truewith a message.is_error: truedoes not end the loop; it gives the model a chance to recover. Practice Q&A ยท Q70 ยท โ wrong choice to avoid: believing that a tool_result with is_error true terminates the loopwhy
An error result is just information: the model reads it and may retry with new input, explain, or stop, while your code limits the retries. An uncaught exception escaping the dispatcher kills the loop before the model ever sees a result. So a run that dies on a timeout means missing error handling in the harness, not a problem with the error flag. Catch expected failures, return a clear error tied to the call ID, and keep sensitive details in server logs.
- What does stop_reason say?
stop_reasontells your harness what to do next.tool_useโ run the tools and continue.end_turnโ this turn is done (maybe just a question back, not task success).max_tokensโ cut off, so never run partial arguments. There is nostop_turn. โ wrong choice to avoid: treatingend_turnas task success, or a made-upstop_turnwhy
tool_usemeans the model is waiting for tool results.end_turnmeans this assistant turn finished, which may be a question to the user rather than a finished task.max_tokensmeans generation stopped early, so any tool arguments may be incomplete and must not be run.stop_turnis not a value; seeing it in an answer means someone misremembered the field. - Who grants permission? Never the model. Approval is server-side state, not an argument like
approved: true. Who owns an order comes from the session, not model input. Retries reuse an idempotency key from trusted state. Valid JSON never creates permission. โ wrong choice to avoid: trusting anapproved: trueargument from the modelwhy
The model can write any well-formed argument, so
approved: trueproves nothing. A customer's refund confirmation is a record your backend saved when they clicked the confirm button. Ownership comes from the logged-in session, not from acustomer_idthe model typed. When a change is retried, the backend reuses its own idempotency key (an ID that makes a repeated request do nothing new), so a network hiccup can't issue two refunds.
MCP: roles, primitives, discovery, execution
- Host, client, or server? The host is the app talking to the model (Claude Code, Desktop, your backend). The client is the connector inside the host, one per server. The server is the process that offers tools, resources and prompts. Your own backend can be the MCP client. Practice Q&A ยท Q23 ยท โ wrong choice to avoid: thinking only Claude Code can be an MCP host
why
The host owns the conversation with the model. Inside it, an MCP client keeps one protocol connection per server, sends discovery and call requests, and gets results. The server is a separate process that actually implements the tools. If you write your own agent loop, your backend is the host and embeds the client; nothing about the roles is specific to Claude Code.
- Who starts each primitive? Tools are started by the model, resources by the application, prompts by the user. "Controlled" means who starts it, not who has authority. Sampling is the wrong answer for "application-controlled". Practice Q&A ยท Q23 ยท Practice Q&A ยท Q68 ยท โ wrong choice to avoid: swapping the initiators (tools user-chosen, prompts model-invoked)
why
Remember one example: the model asks for an order lookup (tool), the host attaches a refund-policy document (resource), the user picks an incident-summary template (prompt). Host permissions still govern all three. Sampling is the server asking for model output, so it isn't application-attached context; reading a document is a resource, not sampling.
- What does model-controlled mean? It means the model has the initiative: it decides when to ask for a tool. Running the tool stays with the server, behind your harness's checks. The model never holds the JSON-RPC connection or registers tools. Practice Q&A ยท Q67 ยท โ wrong choice to avoid: the model holding the JSON-RPC connection and dispatching tools/call itself
why
Claude decides when to propose
get_order. The host's client carries the request over the connection it owns, and the server reads the order only after host and server checks pass. The model doesn't run server code, manage the transport, or register and clean up tools; those are host, client and server jobs. - Who runs an MCP tool? The connected MCP server runs it. A
tool_usenamingmcp__github__create_issuetravels as JSON-RPC through the host's client to that server. Not the model, not the client, not Anthropic (onlyweb_search_โฆserver tools run there). Practice Q&A ยท Q61 ยท โ wrong choice to avoid: the host's MCP client executing the toolwhy
The model proposes a call; the host turns it into a
tools/callJSON-RPC request; the client carries it; the server runs itscreate_issuecode (maybe calling GitHub's API) and sends the result back through the client. The client is the messenger, not the worker. Anthropic-hosted server tools like web search are a separate route; an ordinary MCP server your host connects to never moves into Anthropic's infrastructure. - Who discovers the tools? The client does: it calls
tools/list, then the host filters what the model may see. The server returnsinputSchema; the host maps it to Claude'sinput_schema.tools/callruns a tool; it never lists. Practice Q&A ยท Q62 ยท โ wrong choice to avoid: the model discovering each server's tools by calling tools/listwhy
When it connects, the host's MCP client sends
tools/listand gets the server's catalog, each entry with aninputSchema. The host decides which tools the model may see and converts them intotools[]. The model never does discovery and can't pick a tool it wasn't shown. The server advertises its tools; it doesn't discover the host's. - What's the startup order? For stdio, each step needs the one before: host spawns the child process โ
initializewithprotocolVersionand capability negotiation โnotifications/initializedโtools/listโ approved tools show up asmcp__tickets__โฆ. Practice Q&A ยท Q66 ยท โ wrong choice to avoid: calling tools/list right after spawning, before the initialization handshakewhy
No process, nothing to talk to. No
initializehandshake, no agreedprotocolVersionor capabilities. The client sendsnotifications/initializedbefore it asks for the catalog. Only then doestools/listreturn definitions, which the host exposes with prefixes likemcp__tickets__createso two servers can't clash. - Two IDs, two conversations? Yes.
tools/callsendsparams.nameandparams.argumentsand gets backcontent[]+isError; your adapter mapsisErrorโis_error. The JSON-RPCid: 3andtoolu_1are separate matchings that never replace each other. Practice Q&A ยท Q61 ยท โ wrong choice to avoid: using the JSON-RPC id as the tool_use_idwhy
Two exchanges run at once: Claude's, where the
tool_useblock carriestoolu_1, and the MCP server's, where the JSON-RPC request carriesid: 3. Your adapter holds both. It sendstools/call, gets the result, and builds atool_resultwithtool_use_id: toolu_1andis_errorcopied fromisError. Only the adapter knows which pairs belong together. - Planner keeps listing catalogs? Then publish the slow-changing catalogs as MCP resources, read by URI with
resources/read. A cached tool result still costs a call. The host must read and attach resources itself; they are never injected automatically. โ wrong choice to avoid: caching the listing tool results so repeated calls return fasterwhy
Resources are made for this: the server publishes catalogs by URI, and the host reads them and attaches a summary up front. The planner sees what data exists without spending exploration calls. Caching makes the listing tool faster, but the model still has to call it. The host chooses which resources to read and checks access and freshness; large catalogs need an index or a summary.
- Sampling vs elicitation? Sampling (
sampling/createMessage) is the server asking the client's model for text in the middle of a tool call; it is deprecated in the 2026-07-28 revision. Elicitation is the server asking the user something through the client. Neither one is reading a document. Practice Q&A ยท Q23 ยท โ wrong choice to avoid: calling document reading samplingwhy
With sampling a server could, for example, summarize notes before returning its result. It's a separate request with MCP field names like
maxTokens, not access to the outer conversation. Protocol revision 2026-07-28 deprecates it in favor of direct model-provider integration; the tools/resources/prompts mapping stays the same. Reading a document into context is a resource. - Why use MCP at all? MCP turns NรM custom integrations into N+M: one server per system, one client per host. It does not make tokens cheaper, inference faster, or replace the Claude API. One app with its own tools โ plain
tools[]. Two or more hosts sharing a tool โ MCP. Check community servers before building. Practice Q&A ยท Q22 ยท โ wrong choice to avoid: building a custom MCP server with exactly the endpoints the team needswhy
Wiring three systems into three apps by hand is nine integrations that drift apart. With a standard protocol, each system exposes one server and each app embeds one client. That's a picture of integration effort, not a promise of anything else. Before building a server for Jira or GitHub, look at maintained community servers and build custom only for team-specific workflows they can't cover.
- Forty agents, one tool? 40 agents across 3 hosts need ticketing โ one MCP server, owned by the ticketing team, with a per-agent allowlist. Not copied registries, not full access for everyone, not 40 stdio servers each holding credentials. Practice Q&A ยท Q64 ยท โ wrong choice to avoid: a central catalog service with every agent granted the full tool set
why
You need reuse and least privilege (each agent gets only what it needs) at the same time, and only a central server with per-agent limits gives both. Forty copied modules are forty places to patch. A catalog that gives everyone every tool ignores access limits. Forty stdio servers multiply credential exposure. Hiding a tool from a list is not backend authorization, so the server still checks permissions when it runs.
Transports, config, scopes & secrets
- Local helper for one developer? Use stdio: the host starts the server as a child process and talks JSON over stdin/stdout, with logs on stderr. Not Streamable HTTP, WebSockets, or gRPC. Practice Q&A ยท Q24 ยท โ wrong choice to avoid: WebSockets or gRPC (not MCP transports)
why
The host pipes JSON-RPC into the server's standard input and reads replies from its standard output. Normal logs must go to stderr, or they corrupt the message stream. The helper can still call a backend over the network. A localhost HTTP server would work but adds a listening endpoint and a separate service nothing here needs; WebSockets and gRPC are not standard MCP transports.
- When Streamable HTTP? Use it for remote, shared servers with many clients and OAuth. HTTP doesn't mean remote: local/remote is where the server runs, stdio/HTTP is how messages travel. The old HTTP+SSE transport is a different thing. โ wrong choice to avoid: treating HTTP as meaning remote
why
Streamable HTTP carries MCP messages over HTTP, with JSON replies or an SSE stream. It fits when many hosts share one server over the network with authentication. Keep the two axes apart: a local server can still be reached over HTTP, and a stdio child can call remote backends. The older HTTP+SSE transport is a separate legacy option, not another name for it.
- What does .mcp.json look like?
.mcp.jsonโmcpServers.<name>with eithertype: "stdio",command,argsortype: "http",url. CLI:claude mcp add --transport stdio --scope project orders -- python3 server.py.npx/uvxlaunch a package, not an endpoint. โ wrong choice to avoid: calling annpxentry a remote endpointwhy
A stdio entry says what to start (
commandplusargs); an HTTP entry says where to connect (url). They are two options for one server name, not two registrations. In the CLI,--separates Claude Code's own options from the command that starts the server. Withnpxoruvx, the host runs a package as a local child process, so the entry is still stdio even if the package talks to a remote service. - Which scope for a team server? Project scope:
.mcp.json, in git, for the whole team. Local is just you in this repo; user (~/.claude.json) is you across all your projects. A team Jira server goes in project.mcp.json, not in each developer's~/.claude.json. โ wrong choice to avoid: adding the team integration to each developer's ~/.claude.jsonwhy
Scope decides who gets the server. Local is private to you in one checkout. Project is the version-controlled
.mcp.jsonevery clone picks up. User is your personal~/.claude.jsonfor all your projects. A team integration should be shared and reviewed like code, so it goes in project scope; user scope is for personal or experimental servers and would make every developer set up the team tool by hand. - How to share without the secret? Commit the server in
.mcp.jsonwith${GITHUB_TOKEN}and document the variable in the README. Each developer's own shell supplies the value. Wrong answers: a shared token in.env, a placeholder value, or per-user~/.claude.json. โ wrong choice to avoid: a shared service-account token in a gitignored .env filewhy
Variable expansion lets you share the tool without sharing the credential: the config is identical for everyone and no secret goes into git. A shared service-account token in
.envis a shared secret protected only by a fragile.gitignore. A placeholder value makes each developer override it by hand. Per-user config means clones of the repo don't get the tool at all. - Why does Claude Code ask first? An MCP server is third-party code, so project-scoped servers ask for approval on first use. Their tools show up as
mcp__server__toolfor allow/deny rules. A.mcp.jsonconnection never setstools[i].strict. โ wrong choice to avoid: thinking.mcp.jsoncan turn on strictwhy
A
.mcp.jsonchecked into a repo can launch any code on a developer's machine, so Claude Code asks before using a project-scoped server the first time. After that, its tools appear asmcp__<server>__<tool>, which is what permission allow and deny rules match. The connection touches no Claude API request fields:strictis set per tool by the app that buildstools[], never by MCP config.
Errors, retries & security
- Which error kind? Four kinds, four fixes: transient (timeout, 503, 429 โ retry with backoff), validation (bad input โ fix it, then retry), business (policy โ don't retry, explain), permission (escalate). A generic "Operation failed" is the anti-pattern. โ wrong choice to avoid: one generic "Operation failed" message
why
Transient clears by itself, so wait and resend the same call. Validation needs corrected input before a new attempt. Business is a rule the system will enforce again, so explain instead of retrying. Permission needs a different person or scope. A generic message hides all four, and the model has to guess, often retrying something that can never work.
- Would a retry work? Ask: could the exact same call succeed later? HTTP 503 โ
transient,isRetryable: true. Restricted journal โbusiness,false. Malformed DOI โvalidation,false. Don't call everything transient just because each one stopped the tool. โ wrong choice to avoid: marking all three failures transient and retryable because each stopped the toolwhy
A 503 at peak hours can pass later, so it is retryable. A journal you have no licence for will refuse forever; that's a policy limit, so business, not retryable. A DOI that fails the format check fails it every time, so validation, not retryable; a corrected DOI is a new attempt with new input.
- Hit a 429? Return
errorCategory: "transient", isRetryable: true, retryAfterMs. Not hidden backoff inside the server, not queuing all 50, notbusiness/false. The harness schedules the wait; the error fields only inform it. โ wrong choice to avoid: automatic retry with exponential backoff hidden inside the MCP serverwhy
HTTP 429 means the upstream limits your request rate, and that clears after a delay. A generic failure hides this, so the agent gives up on twenty queries that would have worked. With the category, the retry flag and a delay, the agent can space out the rest, prioritize, or report partial progress. Hidden server backoff or queuing the whole batch takes those choices away and blocks the agent. Your application code enforces the bounded wait.
- Nothing found, or failed? Keep them apart. No matches โ
{"results": [], "isError": false}. Timeout โisError: true, errorCategory: "transient", isRetryable: true. Never retry a successful empty result. A plain status string is the wrong answer. โ wrong choice to avoid: a status string of "empty" versus "failed" instead of the structured error fieldswhy
A lookup that finished and found nothing is a success with zero matches. A timeout is a failure that might work later. If both return an empty array, the agent can't tell them apart: it retries a valid empty result, or reports "nothing found" when it actually hit an outage. Use the protocol's
isErrorflag plus structured category and retry fields, not a homemade status string. - What should a failure report? A subagent's failed query sends back three things: the failure type, the query it tried, and any partial results. Not silent retries, and not "no results found" in place of the error. โ wrong choice to avoid: reporting only the attempted query and stopping there
why
The coordinator handles errors, so it needs enough to decide. The type tells it whether a retry makes sense. The query lets it retry smartly or rephrase. The partial results stop it from redoing work that already worked. Silent retries hide the failure, and calling a failure an empty result mixes up two different situations. Sending only the query leaves out the type it needs to choose retry or reroute.
- Protocol or your convention? Only
isErroris the MCP field (is_errorin Claude).errorCategory,isRetryable,retryAfterMsare your app's conventions. Subagents retry transient errors themselves and pass up only what they can't fix. โ wrong choice to avoid: thinkingerrorCategoryis an MCP protocol fieldwhy
MCP gives you one error mechanism:
isErroron the tool result, which your adapter maps to Claude'sis_error. The richer fields are names your team picks and puts inside the result content, so their spelling is your choice, not an MCP rule. Split the work: the subagent does limited local retries of transient failures and hands the coordinator only what it can't resolve, with the structured context. - Where did the attack enter? Ask where the bad text came in. "Include API keys" inside a tool description = tool poisoning; inside a
tool_result= indirect prompt injection. Defend: vet and pin servers, least-privilege credentials per server, treat descriptions and results as data. Practice Q&A ยท Q25 ยท Practice Q&A ยท Q69 ยท โ wrong choice to avoid: calling it a confused-deputy flaw, or indirect injection via a tool_result, when the instruction sat in the descriptionwhy
A discovered tool isn't automatically trustworthy: its description enters the model's context like any other text, so a hidden instruction there is an attack through tool metadata. The same text in a result is the other channel, indirect prompt injection. Review and pin server versions, give each server only the credentials and actions it needs, keep secrets out of model context, and keep authorization outside the model. Confused deputy and over-broad allowlists describe how much harm follows, not how the text got in.
- Tool too broad? Narrow the tool itself. A document worker using
fetch_urlfor searches โ replace it withload_document, which rejects search/query URLs and validates in the backend. Not a prompt sentence, a domain denylist, or routing via the coordinator. Reuse the HTTP client inside. โ wrong choice to avoid: a prompt sentence limiting fetch_url to document links, or routing every fetch through the coordinatorwhy
A general fetch tool gives too much power, so the worker will eventually run web searches that belong to the search agent. Least privilege puts the limit at the interface: accept only document IDs or document URLs, and let the backend check the destination, follow redirects safely and limit content size. A prompt sentence is a gamble, a hand-kept denylist has gaps, and coordinator routing adds a round trip while still relying on a model's URL judgment. The shared HTTP library can still sit behind the narrow tool.
- Too many round trips? If synthesis goes back to the coordinator for simple checks 85% of the time, give it a scoped
verify_facttool and escalate the complex 15%. Not all web-search tools, not batching, not more coordinator parallelism. โ wrong choice to avoid: giving the synthesis agent access to all web-search toolswhy
Each simple check now costs two or three coordinator round trips; the delay is routing overhead, not coordinator speed. A small read-only
verify_factlets synthesis settle the common lookups itself, while the harder 15% still go through the coordinator to the search agent. All web-search tools would turn it into a search agent; batching delays finishing and holds unchecked claims; more parallelism speeds up trips that shouldn't exist. A narrow tool name isn't authorization, so the backend still checks permissions.
Review sources
Domain 4 lesson and worked explanations ยท Official reference↗
Fact wording rewritten as concrete numbered facts on 25 September 2026. The mock providers' original keys are preserved on their review pages; these recall lines retain the engineering caveats.