MCP and Tool Governance

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from claude_agent_sdk import ClaudeAgentOptions

options = ClaudeAgentOptions(
mcp_servers={
"scraper": {
"command": "python",
"args": ["servers/scraper.py"],
},
"catalog": {
"type": "http",
"url": "https://catalog.example.invalid/mcp",
},
},
allowed_tools=[
"mcp__scraper__fetch_shelf",
"mcp__catalog__lookup_sku",
],
disallowed_tools=["Bash"],
permission_mode="dontAsk",
)

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
2
3
4
5
Name: the verb the model should think
When to use / when not to use
Inputs: required vs optional, units, invariants
Output: the fields the next turn will read
Failure: not_found, denied, or error; never a guess

A working checklist:

  1. Few servers. One job per server. A scraper that also writes the catalog will fight the catalog server.
  2. Few tools. If two tools both “get a price,” they will race. Say so in both descriptions, or delete one.
  3. 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.
  4. Trimmed results. Return sku, price_cents, currency, observed_at. Not the page. The MCP spec lets a tool set outputSchema and structuredContent. I use that (on a standalone server, or in TypeScript) so the next turn reads fields, not prose.
  5. Deny lists. If a server ships a write_* tool I do not want, deny it by name so it never enters the window.
  6. Scoped config. Project .mcp.json for the team surface. User scope for my laptop experiments. disabledMcpjsonServers for 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
2
3
4
5
6
7
Goal: say whether Harbor's shelf price for a SKU moved, with evidence.
Definition of done: a structured note per SKU, or a fail-closed skip.
Gather: scrape the competitor shelf, look up our catalog row.
Reconcile: same SKU, same currency, fresh enough timestamp.
Reason: moved / unchanged / skip. Never invent a price.
Stop and skip when: the scrape is malformed, the catalog misses the SKU,
the currencies disagree, or the harness denied a call.

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Sketch. Official fields only. Not a course file.
options = ClaudeAgentOptions(
mcp_servers={
"scraper": scraper_server, # create_sdk_mcp_server(...) or stdio
"catalog": catalog_server,
},
allowed_tools=[
"mcp__scraper__fetch_shelf",
"mcp__scraper__list_aisle",
"mcp__catalog__lookup_sku",
"mcp__catalog__record_observation",
],
disallowed_tools=["Bash", "mcp__catalog__delete_sku"],
permission_mode="dontAsk",
)

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
2
3
4
5
6
7
8
sku: NB-OATS-1KG
harbor_price_cents: 349
harbor_currency: USD
observed_at: 2026-08-15T06:12:00Z
our_price_cents: 329
our_currency: USD
decision_so_far: moved
skipped: none

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
2
3
4
5
6
7
8
9
Host (Claude Code vs Agent SDK):
Servers (one job each) and transport (stdio vs http):
Tools (name, when not to use, return fields):
Resources the host should attach itself, if any:
Allow list (mcp__server__tool) and deny list:
permission_mode (dontAsk for a fixed surface):
Trim at the handler (fields the next turn will read):
Audit line (server, tool, args, ok/not_found/denied/error):
What fail-closed means in this domain (do not guess X):

Then inspect a real run:

  1. How many mcp__ names landed in the init tools array? Was that the surface you meant?
  2. Did acceptEdits fool you into thinking an MCP write was gated? (It does not auto-approve MCP.)
  3. Did any handler return a dump you then asked the model to ignore?
  4. If you force dontAsk and omit a write tool, does the agent stop instead of inventing a workaround?
  5. If a tool returns HTML or isError: true, does the audit say error and does the decision say skip?

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 MCP 2026-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 http type is Streamable HTTP.
  • mcp__server__tool: Fully qualified MCP tool name in Claude Code and the Agent SDK
  • mcp_servers / allowed_tools / disallowed_tools / permission_mode: The governance knobs from Part 3, applied to MCP
  • create_sdk_mcp_server: In-process MCP server for custom tools
  • structuredContent / isError: Official MCP tool-result fields for a machine payload and a failed call. Python @tool uses is_error and does not forward structuredContent.
  • 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.

The Harness: Models, the Agent SDK, and Stop-Reason Loops Evaluation and Observability for Claude Agents

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×