Source: AI Engineering with Claude (Udacity ND7426), plus current MCP / Claude docs I fetched: architecture, server concepts, client concepts, transports, 2026-07-28 spec notes, Claude Code MCP, Agent SDK MCP, custom tools, and permissions
Part 3 ended with a debt: tools that live outside the process, MCP servers, how they enter the window, and how to keep that surface small.
This is Part 4 of 6 in my Agentic Coding with Claude Code series. The working model I am keeping is simple: the model still reasons. The harness still decides. MCP is how tools and context live outside the process and still enter the window as a governed surface.
Later posts will cover evaluation and observability, then bounded autonomy. This one stays on the MCP boundary.
Why the Split Matters
Part 1 treated tools as the agent’s public API. Part 3 put that API inside a harness I own: allowed_tools, disallowed_tools, permission_mode. Those knobs still apply. MCP adds a second address: the tool or the file does not have to live in my process.
Official architecture names three roles. I kept the split because it is the reason I can add a scraper without rewriting the agent.
| Role | Official job | Why I care |
|---|---|---|
| Host | The AI application that coordinates one or more MCP clients (Claude Code, the Agent SDK process, an IDE) | One product, many servers. The host owns the window, permissions, and the loop. |
| Client | The connector the host instantiates per server | Not a second agent. A dedicated connection. One client, one server. |
| Server | A program that exposes tools, resources, and prompts | The capability lives here. Local stdio or remote HTTP. Same JSON-RPC either way. |
One host. Many servers. The client is the connector inside the host, not a chat partner. If I collapse those three into “the MCP,” I lose the place where governance actually sits: the host decides which servers exist, which tools from those servers enter the window, and which calls run.
flowchart TB
Host["Host: Claude Code / Agent SDK"]
Host --> C1[Client: scraper]
Host --> C2[Client: catalog]
Host --> C3[Client: docs]
C1 --> S1[Scraper server]
C2 --> S2[Catalog server]
C3 --> S3[Remote docs server]
classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff
classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff
classDef purpleClass fill:#9B59B6,stroke:#333,stroke-width:2px,color:#fff
classDef tealClass fill:#16A085,stroke:#333,stroke-width:2px,color:#fff
class Host blueClass
class C1,C2,C3 orangeClass
class S1 greenClass
class S2 purpleClass
class S3 tealClass
MCP’s data layer is JSON-RPC. The 2026-07-28 spec made that layer stateless: each request carries version and capabilities in _meta, and a client that wants the catalog up front calls server/discover. I treat that as infrastructure. The product question is still: what from those servers is allowed to occupy the window?
Server Primitives: Tools, Resources, Prompts
Server concepts give servers three building blocks. Who controls each one is the useful column.
| Primitive | Who controls it | Protocol ops I actually use | When I use it | When I do not |
|---|---|---|---|---|
| Tools | Model | tools/list, tools/call |
An action: scrape a shelf, look up a SKU, write a row | Read-only context the host should attach itself |
| Resources | Application (the host) | resources/list, resources/read |
A schema, a snapshot, a file the host chooses to load | Anything with a side effect |
| Prompts | User | prompts/list, prompts/get |
A named template: “reconcile this aisle” | Hidden policy. Policy is a permission rule. |
Tools are model-invoked. The model sees the name, the description, and the inputSchema, then returns a tool_use the way Part 3 already described. The host still runs or denies the call.
Resources are read-only context. The host (not the model) decides whether to fetch resources/read and paste the bytes into the window. A catalog schema is a resource. A “delete this SKU” endpoint is a tool.
Prompts are templates the user (or a slash command) invokes. They are not a second system prompt I hope the model obeys.
They all cost tokens. tools/list is not free metadata. Every tool name and description sits in the working set before the first user sentence. A fat catalog is a Part 2 context bug at the MCP boundary: the model is already paying for tools it will never need.
flowchart TB
subgraph Small["Small surface"]
direction LR
A1[2 servers] --> B1[4 tools] --> C1[Trimmed results]
end
subgraph Fat["Fat tools/list"]
direction LR
A2[8 servers] --> B2[60 tools] --> C2[Raw dumps]
end
classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff
classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
classDef redClass fill:#E74C3C,stroke:#333,stroke-width:2px,color:#fff
class A1,B1,C1 greenClass
class A2,B2 orangeClass
class C2 redClass
Official Agent SDK MCP docs already treat this as a known tax. Tool search withholds unused definitions and loads the ones the turn needs. That is a safety net, the same way Claude Code’s bash output cap is a safety net. I still keep the catalog small. Tool search does not fix a server that exposes thirty overlapping verbs.
Client Features I Will Name
The public Course 2 syllabus names client features including roots, sampling, and elicitation. Current client concepts still list all three. Roots and sampling are deprecated as of protocol version 2026-07-28. New implementations should not adopt them. Earliest removal is the first spec revision on or after 2027-07-28. The spec notes still say they work during that window.
| Feature | What it was for | What I do instead |
|---|---|---|
| Roots | The client tells the server which file:// directories to focus on. Advisory, not a lock. The spec says servers SHOULD respect roots, not MUST enforce them. |
Pass the directory as a tool argument, a resource URI, or server config. Claude Code still answers roots/list with the launch directory plus --add-dir / additionalDirectories. I do not treat that reply as a sandbox. |
| Sampling | The server asks the host’s model to complete a prompt (sampling/createMessage) so the server stays model-agnostic. |
Call the Messages API from my process. The host already owns model choice and spend. |
Elicitation is the live client primitive (a server asking the user for input mid-call via MRTR). I am not covering it here. The takeaway I kept for roots and sampling: they were coordination, not governance. A root is a hint. A sample is a nested model call I did not budget. Neither one is an allow list.
Transports, High Level
The transport binding does not change the messages. It changes how they are framed.
| Transport | Official shape | When I use it |
|---|---|---|
| stdio | Client launches a subprocess. Newline-delimited JSON-RPC on stdin/stdout. | A local server I trust on this machine: a catalog process, a scraper I wrote. |
| Streamable HTTP | HTTP POST to one MCP endpoint. Reply is JSON or a request-scoped SSE stream. | A remote server. Claude Code and the Agent SDK call this type: "http". In .mcp.json, streamable-http is an alias for http. |
Claude Code still accepts sse for services that have not moved, and documents it as deprecated. HTTP+SSE is on the spec deprecation list. I do not pick SSE for new work.
I am not walking connection exploits or auth bypasses. The only operational note I kept: a .mcp.json entry with a url and no type is a config error. Claude Code reads a typeless entry as stdio, skips it, and reports that the server has a URL but no type.
Wiring MCP into Claude Code and the Agent SDK
In Claude Code, a server is configuration. claude mcp add writes it. Scope decides who else sees it.
| Scope | Where it lives | Who it is for |
|---|---|---|
| local | ~/.claude.json, under this project’s path |
Default for claude mcp add. Private to this project. |
| user | ~/.claude.json, top-level mcpServers |
Every project on this laptop. Still private to you. |
| project | .mcp.json in the repo |
The team. First interactive session asks you to approve it so a clone cannot launch processes without consent. |
claude mcp list and /mcp are how I check health (Connected, Needs authentication, Failed to connect, Pending approval). Headless claude -p and Agent SDK sessions cannot show that approval prompt. Project servers load without asking unless I put the name in disabledMcpjsonServers or drop project settings with --setting-sources / setting_sources.
In the Agent SDK, the same servers are a field on ClaudeAgentOptions: mcp_servers in Python, mcpServers in TypeScript. I can pass them in code or load .mcp.json when the project setting source is on.
1 | from claude_agent_sdk import ClaudeAgentOptions |
That snippet is a sketch, not a product, and not a course file. Official names only: mcp_servers, allowed_tools, disallowed_tools, permission_mode.
Custom tools I write in-process are still MCP. create_sdk_mcp_server / createSdkMcpServer wraps @tool / tool() handlers. The key in mcp_servers becomes the server segment of the fully qualified name: mcp__{server}__{tool}. List that name in allowed_tools or the model can see the tool and still not be allowed to call it.
MCP tools require explicit permission. Official wording: without it, Claude sees the tools and will not call them.
Governance Is Not Prompt Text
Part 3 said CLAUDE.md is guidance. The same sentence applies to MCP. “Please only use the catalog lookup” is a prompt. allowed_tools=["mcp__catalog__lookup_sku"] plus permission_mode="dontAsk" is a rule.
The SDK still evaluates permissions in a fixed order: hooks, deny, ask, the active mode, allow, then can_use_tool. I am not writing hooks in this post. The MCP-specific facts I fetched:
| Knob | Official field | What it does to MCP |
|---|---|---|
| Allow list | allowed_tools / allowedTools |
Auto-approves listed tools. MCP names are mcp__server__tool. A per-server wildcard is mcp__scraper__*. An unanchored * or mcp__* in the allow list is ignored. |
| Deny list | disallowed_tools / disallowedTools |
A bare name (Bash, or mcp__scraper__fetch_shelf) removes the tool from the window. mcp__* removes every MCP tool from context. A scoped deny keeps the tool visible and blocks the pattern in every mode, including bypassPermissions. |
| Mode | permission_mode |
acceptEdits does not auto-approve MCP tools. bypassPermissions does, and also disables most other prompts. Official guidance: prefer allowed_tools over a mode for MCP access. |
| Locked headless | dontAsk + allow list |
Listed MCP tools run, except a tool the server marks _meta["anthropic/requiresUserInteraction"] (that mode never prompts, so those calls deny). Everything else is denied. Pair this when I ship a fixed surface. |
| Availability vs permission | tools vs allowed_tools |
tools: ["Read", "Grep"] drops unlisted built-ins from context. MCP tools are unaffected. tools: [] removes built-ins so only MCP remains. |
allowed_tools still does not constrain bypassPermissions. Listing mcp__catalog__lookup_sku and then setting bypass still approves Bash. For a locked-down analyst, I pair the allow list with dontAsk and I put writes on the deny list.
The description on each tool is still the prompt, the way Part 2 said. On the wire, an MCP tool advertises name, description, and inputSchema. The handler returns content. The spec result may also set structuredContent and isError. On the Agent SDK, TypeScript handlers use isError; the Python @tool decorator forwards content and is_error only. To return structuredContent from Python I need a standalone server, not the in-process decorator.
Keep the Surface Small
The trim rule from Part 2 moves to the MCP boundary. If fetch_shelf returns a raw HTML dump, every later turn pays for it. If lookup_sku returns the whole commerce row, the next reason step is already working from noise.
The contract I keep, in-process or over MCP:
1 | Name: the verb the model should think |
A working checklist:
- Few servers. One job per server. A scraper that also writes the catalog will fight the catalog server.
- Few tools. If two tools both “get a price,” they will race. Say so in both descriptions, or delete one.
- Tight descriptions. When to use, when not to, what empty means. Newer models explore less when I over-specify the happy path. They still need the overlap called out.
- Trimmed results. Return
sku,price_cents,currency,observed_at. Not the page. The MCP spec lets a tool setoutputSchemaandstructuredContent. I use that (on a standalone server, or in TypeScript) so the next turn reads fields, not prose. - Deny lists. If a server ships a
write_*tool I do not want, deny it by name so it never enters the window. - Scoped config. Project
.mcp.jsonfor the team surface. User scope for my laptop experiments.disabledMcpjsonServersfor a server I will not load in CI.
A fat tools/list is the failure mode I now look for first. It is cheaper to delete a tool than to ask the model to ignore it.
An Audited Agent Loop
This is not the observability post. I am not standing up traces or evals. I want an append-only record of what crossed the MCP boundary so I can answer four questions after a run: which server, which tool, which args, which result class.
Result classes I will write, not SDK field names:
| Class | Meaning |
|---|---|
ok |
Handler returned the expected fields |
not_found |
The SKU or shelf does not exist. Not a guess. |
denied |
The harness blocked the call (deny rule, dontAsk, missing allow) |
error |
Transport failed, schema failed, or the server set isError: true |
The record is append-only. I do not update a row to make the story nicer. A later compact can squeeze the conversation. The audit file still has the call.
flowchart TB
Model[Model returns tool_use] --> Gate{Allow or deny}
Gate -->|denied| Audit1[Append: denied]
Audit1 --> Result1[tool_result: denied]
Gate -->|allowed| Call[Call MCP server]
Call --> Class{Result class}
Class -->|ok / not_found / error| Trim[Trim to contract fields]
Trim --> Audit2[Append: server, tool, args, class]
Audit2 --> Result2[tool_result into the window]
Result1 --> Loop[Next turn]
Result2 --> Loop
classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff
classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff
classDef purpleClass fill:#9B59B6,stroke:#333,stroke-width:2px,color:#fff
classDef tealClass fill:#16A085,stroke:#333,stroke-width:2px,color:#fff
classDef redClass fill:#E74C3C,stroke:#333,stroke-width:2px,color:#fff
class Model blueClass
class Gate orangeClass
class Call purpleClass
class Class tealClass
class Trim greenClass
class Audit1,Audit2 orangeClass
class Result1,Result2 greenClass
class Loop redClass
The harness already owns this dispatch. MCP does not change the stop_reason == "tool_use" branch. It changes where the function runs. The audit line is how I keep that hop inspectable without turning this post into a metrics stack.
Shelfwatch: Gather, Reconcile, Reason
Course 2 trains this on PriceScout: an agentic analyst over a custom scraper and a database server. I am not reprinting their starter files. Invented analog: shelfwatch, a small watcher for a fictional grocer (Northbridge Market) that checks a fictional competitor (Harbor Pantry) for shelf prices on a short list of SKUs.
1 | Goal: say whether Harbor's shelf price for a SKU moved, with evidence. |
Two servers, four tools. Intentionally small.
| Server | Tool | Returns | When not to use |
|---|---|---|---|
scraper |
fetch_shelf |
sku, price_cents, currency, observed_at, shelf |
You already have today’s observation; use the catalog |
scraper |
list_aisle |
sku list for one aisle |
You already know the SKU |
catalog |
lookup_sku |
sku, our_price_cents, currency, name |
You are asking Harbor’s price; that is fetch_shelf |
catalog |
record_observation |
observation_id |
Any field is missing or the result class is not ok |
Each result is a handful of fields. fetch_shelf does not return the page. That is the Part 2 trim rule, implemented at the MCP handler.
The allow list is the product surface:
1 | # Sketch. Official fields only. Not a course file. |
If the catalog server also exposes delete_sku, the deny list keeps it out of the window. The model should not have to “be careful.”
Malformed and misbehaving results are the point of the exercise. Invented cases I will actually test:
| What comes back | Result class | What the harness does |
|---|---|---|
{sku, price_cents, currency, observed_at} |
ok |
Trim (already small), audit, reason |
| Empty body, or SKU not on the shelf | not_found |
Audit, skip that SKU, do not invent a price |
HTML, a string price ("$3.49"), missing currency |
error |
Audit, skip. Fail closed. |
isError: true from the server |
error |
Same. The model sees the error text, not a guessed number. |
lookup_sku denied by config |
denied |
Halt the write path. Do not record an observation. |
The reason step is allowed to say skip. It is not allowed to average two bad scrapes into a “probably $3.50.” Prices are evidence or they are absent.
A facts block for one run, rebuilt after every tool result:
1 | sku: NB-OATS-1KG |
If fetch_shelf returns junk, that block stays empty on the Harbor side and the decision is skip. I do not ask the model to remember the cents from turn 2 after a compact. I rewrite the block.
Putting the Concepts into Practice
Pick one workflow that already copies data out of another system (a price list, an issue tracker, a catalog) and write this brief before you add a server:
1 | Host (Claude Code vs Agent SDK): |
Then inspect a real run:
- How many
mcp__names landed in the inittoolsarray? Was that the surface you meant? - Did
acceptEditsfool you into thinking an MCP write was gated? (It does not auto-approve MCP.) - Did any handler return a dump you then asked the model to ignore?
- If you force
dontAskand omit a write tool, does the agent stop instead of inventing a workaround? - If a tool returns HTML or
isError: true, does the audit sayerrorand does the decision sayskip?
If you cannot answer those, you have a connected demo, not a governed surface.
Key Terms
- MCP: Model Context Protocol. An open standard for connecting a host to external tools, resources, and prompts
- Host: The AI application that owns the window, the loop, and the permission policy
- Client: The per-server connector the host instantiates
- Server: A process (local or remote) that exposes primitives over JSON-RPC
- Tools /
tools/list/tools/call: Model-invoked actions. The catalog is context. A fat list is a budget bug. - Resources: Host-fetched, read-only context (
resources/read) - Prompts: User-invoked templates (
prompts/get) - Roots: Client hint for
file://directories. Deprecated as of MCP2026-07-28. Not a sandbox. - Sampling: Client feature where a server asks the host to run a model completion. Deprecated as of MCP
2026-07-28. - stdio / Streamable HTTP: The two standard transports. Claude Code’s
httptype is Streamable HTTP. mcp__server__tool: Fully qualified MCP tool name in Claude Code and the Agent SDKmcp_servers/allowed_tools/disallowed_tools/permission_mode: The governance knobs from Part 3, applied to MCPcreate_sdk_mcp_server: In-process MCP server for custom toolsstructuredContent/isError: Official MCP tool-result fields for a machine payload and a failed call. Python@toolusesis_errorand does not forwardstructuredContent.- Append-only audit: A durable line per call: server, tool, args, result class
- Fail closed: Skip or halt when evidence is missing or malformed. Do not guess.
Final Thoughts
The model is still the reasoning engine from Part 1. The harness is still the runtime from Part 3. MCP does not replace either one. It moves the public API outside the process and then asks the same questions: which servers exist, which tools enter the window, which calls run, and what a bad result is allowed to mean.
Part 2‘s trim rule does not stop at bash. A scraper that returns a page, or a tools/list that returns sixty verbs, is the same tax. Governance is the allow list, the deny list, and dontAsk. It is not a paragraph in CLAUDE.md.
The next post moves from this architecture to whether the loop is actually doing the job: evaluation and observability. I want to know when a skip was correct, when a tool lied, and when the audit trail is the only honest record.
This is Part 4 of 6 in the Agentic Coding with Claude Code series. Earlier: Part 1, Part 2, Part 3.
Comments