---
title: "MCP and Tool Governance"
url: "https://vanducng.dev/2026/08/15/mcp-and-tool-governance/"
date: "2026-08-15"
series: "agentic-coding-with-claude-code"
categories: ["AI"]
tags: ["mcp","agentic-coding","claude-code","agent-sdk"]
---

# MCP and Tool Governance

[Part 3](https://vanducng.dev/2026/08/15/the-harness-models-agent-sdk-and-stop-reason-loops/) 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](https://vanducng.dev/2026/08/09/core-concepts-of-agentic-coding-with-claude-code/) treated tools as the agent's public API. [Part 3](https://vanducng.dev/2026/08/15/the-harness-models-agent-sdk-and-stop-reason-loops/) 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](https://modelcontextprotocol.io/docs/2026-07-28/learn/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.

```mermaid
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](https://modelcontextprotocol.io/docs/2026-07-28/learn/architecture#data-layer) is JSON-RPC. The [2026-07-28 spec](https://blog.modelcontextprotocol.io/posts/2026-07-28/) 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](https://modelcontextprotocol.io/docs/2026-07-28/learn/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](https://vanducng.dev/2026/08/15/the-harness-models-agent-sdk-and-stop-reason-loops/) 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](https://vanducng.dev/2026/08/15/context-engineering-for-claude-code/) context bug at the MCP boundary: the model is already paying for tools it will never need.

```mermaid
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](https://code.claude.com/docs/en/agent-sdk/mcp#mcp-tool-search) docs already treat this as a known tax. [Tool search](https://code.claude.com/docs/en/agent-sdk/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](https://modelcontextprotocol.io/docs/2026-07-28/learn/client-concepts) still list all three. Roots and sampling are [deprecated](https://modelcontextprotocol.io/specification/2026-07-28/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](https://blog.modelcontextprotocol.io/posts/2026-07-28/) 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](https://code.claude.com/docs/en/mcp) 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/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](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports) 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](https://code.claude.com/docs/en/mcp) 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`](https://code.claude.com/docs/en/agent-sdk/mcp): `mcp_servers` in Python, `mcpServers` in TypeScript. I can pass them in code or load `.mcp.json` when the `project` setting source is on.

```python
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`](https://code.claude.com/docs/en/agent-sdk/custom-tools) / `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](https://vanducng.dev/2026/08/15/the-harness-models-agent-sdk-and-stop-reason-loops/) 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](https://code.claude.com/docs/en/agent-sdk/permissions#how-permissions-are-evaluated): 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](https://vanducng.dev/2026/08/15/context-engineering-for-claude-code/) 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](https://vanducng.dev/2026/08/15/context-engineering-for-claude-code/) 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:

```text
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.

```mermaid
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.

```text
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:

```python
# 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:

```text
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:

```text
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](https://vanducng.dev/2026/08/09/core-concepts-of-agentic-coding-with-claude-code/). The harness is still the runtime from [Part 3](https://vanducng.dev/2026/08/15/the-harness-models-agent-sdk-and-stop-reason-loops/). 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](https://vanducng.dev/2026/08/15/context-engineering-for-claude-code/)'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.

## References

- AI Engineering with Claude
- [Architecture](https://modelcontextprotocol.io/docs/2026-07-28/learn/architecture)
- [Server concepts](https://modelcontextprotocol.io/docs/2026-07-28/learn/server-concepts)
- [Client concepts](https://modelcontextprotocol.io/docs/2026-07-28/learn/client-concepts)
- [Transports](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports)
- [2026-07-28 spec notes](https://blog.modelcontextprotocol.io/posts/2026-07-28/)
- [Claude Code MCP](https://code.claude.com/docs/en/mcp)
- [Agent SDK MCP](https://code.claude.com/docs/en/agent-sdk/mcp)
- [Custom tools](https://code.claude.com/docs/en/agent-sdk/custom-tools)
- [Permissions](https://code.claude.com/docs/en/agent-sdk/permissions)

---

*This is Part 4 of 6 in the Agentic Coding with Claude Code series. Earlier: [Part 1](https://vanducng.dev/2026/08/09/core-concepts-of-agentic-coding-with-claude-code/), [Part 2](https://vanducng.dev/2026/08/15/context-engineering-for-claude-code/), [Part 3](https://vanducng.dev/2026/08/15/the-harness-models-agent-sdk-and-stop-reason-loops/).*
