Integrate MCP Servers


10 Aug 2026  Istvan Dobrentei  11 mins read.

Part 11 · Domain 2, Task 2.4 - Integrate MCP servers into Claude Code and agent workflows

A part that isn’t only about the Messages API

Every part so far has been testable the same way: call client.messages.create(), look at what came back, change one thing, call it again. Task 2.4 doesn’t fully fit that shape. Where an MCP server’s tools actually get registered — a project’s .mcp.json versus a user’s ~/.claude.json — and the fact that every configured server’s tools all show up at once, at connection time, are properties of Claude Code and the MCP protocol itself. There’s no API call that runs a config file and shows you the result the way tool_choice demos have all series.

So this part covers those pieces the way the exam guide states them: as configuration, checked for accuracy the same way the Python and PHP in every other part get checked — against real documented behavior, not a guess. Two pieces of the objective are testable the usual way, because they’re really about tool descriptions and content, not file scoping — and those get the live demo treatment like everything else in Domain 2.

Where a server’s config actually lives

An MCP server is configured once and its tools become available to every conversation that loads that config — the question is which conversations that ends up being, and that’s a scoping decision, not a technical one.

A server the whole team needs — say, a company’s internal Jira or GitHub integration — belongs in .mcp.json, checked into the project’s own repository:

{
  "mcpServers": {
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": {
        "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}"
      }
    }
  }
}

${GITHUB_TOKEN} is not a literal token sitting in version control — it’s expanded from an environment variable at the point Claude Code actually starts the server. Everyone on the team gets the same server, the same tools, the same config, and nobody’s personal access token ends up committed to the repository. That last part is the entire reason the expansion syntax exists: a shared config file and a secret are two different things, and .mcp.json only ever holds the first one.

A server that’s personal, or still experimental — a local database browser only one developer is trying out, say — doesn’t belong in that shared file at all. It goes in the user’s own config, ~/.claude.json, scoped to that one person’s machine and never checked into anything:

{
  "mcpServers": {
    "local-db-browser": {
      "command": "npx",
      "args": ["-y", "@example/mcp-db-browser", "--connection", "${LOCAL_DB_URL}"]
    }
  }
}

Same shape, same expansion syntax — the only thing that changed is which file it lives in, and that answers a real question: does every teammate who clones this repo get this server, or does it stay on one person’s setup until it’s actually ready to be shared?

Every configured server’s tools show up together

Nothing about MCP asks an agent to pick a server before it can use a tool. Every server listed across both config files gets connected when the session starts, and every tool from every one of them lands in the same tools list the model sees on every call — a GitHub server’s tools sit right next to a database browser’s tools sit right next to whatever else got added, with no separate step to choose “which server” first.

This is the same tool list Task 2.1 through 2.3 have been about the whole time, just populated from more than one source now. A vague description on a tool from one MCP server can lose to a clearer one from a different server, the same way analyze_content lost to analyze_document in Part 8 — the model has no concept of “which server this came from,” only the tool list in front of it. Scoping which servers get loaded where controls whether a tool is in that list at all. It says nothing about whether the tool’s own description is good enough to win once it’s there — and that part is fully testable.

A capable MCP tool still has to out-describe the familiar option

An agent doesn’t only choose between two MCP tools. It often chooses between an MCP tool and a built-in one that already has a strong, familiar shape — grep, in particular. Give an agent both grep and an MCP-style search_codebase tool, with a bare-minimum description, and ask it to find every call site of a function including calls made through a renamed import — something plain text search can’t reliably do:

SEARCH_CODEBASE_VAGUE = {
    "name": "search_codebase",
    "description": "Searches the codebase.",
    ...
}
--- grep + a vaguely described MCP tool ---
run 1: grep({'pattern': 'sendNotification'})
run 2: grep({'pattern': 'sendNotification'})
run 3: grep({'pattern': 'sendNotification'})

Three for three, grep won — the tool the model already has a strong prior about, over an MCP tool that never explained why it would do better. Nothing was technically wrong with search_codebase’s description. It just never said what made this tool worth reaching for instead of the familiar one. Now the same tool, same input schema, with a description that actually states the capability gap:

SEARCH_CODEBASE_DETAILED = {
    "name": "search_codebase",
    "description": (
        "Finds call sites for a function using semantic, import-aware "
        "analysis. Unlike a text search, it resolves renamed and aliased "
        "imports (e.g. `import { sendNotification as notify }`) and still "
        "finds the call. Use this instead of grep whenever a call might "
        "go through a renamed or re-exported binding, not just the "
        "original name."
    ),
    ...
}
--- grep + the same MCP tool, description states the capability gap ---
run 1: search_codebase({'query': 'sendNotification'})
run 2: search_codebase({'query': 'sendNotification'})
run 3: search_codebase({'query': 'sendNotification'})

Same two tools available, same query. The only thing that changed was whether the description said, in plain words, why this tool beats the obvious default for this exact kind of request. grep isn’t a weak competitor — it’s a strong one, precisely because the model has seen it work well so many times before. An MCP tool that wants to be picked over it has to say, explicitly, what it does that grep can’t.

A catalog saves the trip that finds out where to look

The other testable half of this objective is what MCP resources are actually for. A resource isn’t a tool the model calls — it’s content the client can load directly into context, so the agent already knows what’s available before it acts. The alternative is an agent that has to ask first: list what exists, then decide what to read.

Three internal documents, one of them about pricing, and a list_documents / read_document tool pair with no hint in either tool’s description about what’s actually inside any of them:

--- No catalog: the agent has to discover document names itself ---
  [no-catalog] turn 0: list_documents({})
  [no-catalog] turn 1: read_document({'name': 'pricing.md'})
  [no-catalog] done in 2 tool-using turn(s)

Two tool-using turns to answer one pricing question — one just to find out pricing.md exists and is the right file. Now the same tools, same question, but a short catalog sitting directly in the system prompt — the same information an MCP resource would hand over automatically at session start:

DOCS_CATALOG = (
    "Available documents:\n"
    "- pricing.md: Q3 pricing tiers and discount structure\n"
    "- onboarding.md: new customer setup guide\n"
    "- faq.md: common support questions\n"
)
--- Catalog in context: the agent already knows where to look ---
  [with-catalog] turn 0: read_document({'name': 'pricing.md'})
  [with-catalog] done in 1 tool-using turn(s)

One turn, straight to the right file. The tools didn’t change. What changed is that the agent no longer had to spend a call finding out something a resource could have told it up front. This is the actual case for exposing a content catalog as an MCP resource instead of just another list-style tool: a database’s table names, a documentation site’s page hierarchy, a project’s open issues — anywhere an agent would otherwise have to ask “what’s available” before it can ask for the thing it actually wants.

You can find the full file on GitHub. A PHP port is available too, built the same way as the earlier parts.

Critical design decisions

  • Scope by who needs it, not by convenience: a server the whole team relies on belongs in version-controlled .mcp.json. A server one person is still trying out belongs in their own ~/.claude.json, not in a shared file everyone else now has to load too.
  • Environment variable expansion keeps a shared config file free of secrets: ${GITHUB_TOKEN} in .mcp.json is what makes committing that file safe in the first place — the token itself never has to be there.
  • All configured servers’ tools land in one list, together: there’s no per-server selection step before tool selection happens. Every tool from every connected server competes for the same decision Task 2.1 through 2.3 have already covered.
  • An MCP tool competes against the model’s priors on built-in tools, not just against other MCP tools: grep starts as the default expectation for “search.” A tool meant to replace it for a specific case has to say so directly, not just describe itself.
  • A content catalog belongs in context, not behind a tool call: exposing what’s available as an MCP resource turns “find out what exists, then ask for it” into just “ask for it.”
  • Before building a custom MCP server, check whether the integration already exists: for a standard target like Jira or GitHub, an existing community server usually covers it. Custom servers earn their cost on workflows that are actually team-specific.

Consequences

  • A shared server configured in ~/.claude.json instead of .mcp.json -> only the person who set it up actually has it. Teammates hit “the tool doesn’t exist” for something that was supposed to be standard.
  • A personal or experimental server committed into .mcp.json -> everyone on the team now loads a server that was never meant for them, for tools most of them will never use.
  • A secret written directly into .mcp.json instead of expanded from an environment variable -> a credential sitting in version control, readable by anyone with repository access.
  • An MCP tool description that only restates its name -> it loses to whatever built-in tool already has a strong prior for that kind of task, even when the MCP tool is genuinely more capable for that specific request.
  • No content catalog for a large or unfamiliar data source -> every session pays for an exploratory call just to find out what’s there, before it can even start on what was actually asked.
  • A custom MCP server built for a standard integration that already has a community one -> maintenance cost with no advantage over adopting what already exists and is already tested against the real service.

Task 2.1 through 2.3 assumed a tool list that was already sitting there, ready to reason about. Task 2.4 is where that list actually comes from — which servers get connected, to whom, and what they bring into it. Get the scoping and the descriptions right, and everything the earlier parts covered still applies exactly as written. Get it wrong, and the model is reasoning well over a tool list that was never the right one to begin with.