All lessons › Function A — Getting Started — Building Agentic AI › Build the Agent — From a Loop to a Running Platform
A1.2

Tools over MCP — a resource server, and why it is a separate process

A1
▶
Lightboard recording

Not recorded yet. The lesson below is complete and runnable today — the video is added later without touching this page.

Video placeholderpublishes automatically from recordings/A1.2.mp4
Risk

Tools called in-process share the agent's authority by default, and there is no place left to put a check.

Control

A resource server per trust domain, each with its own audience.

The lab

Stand up both MCP servers and list their tools from the client.

◉Use case relevance

It is faster to import the booking client and let the agent call it directly. It works on the first afternoon and it is still working months later, which is the trap: the tool now runs with whatever authority the agent has, and the only place to add a check is inside the component an attacker is trying to influence.

At CyberTravels. CyberTravels has two resource servers: mcp/internal_server.py for bookings and payments, and mcp/vendor_server.py, which is a travel vendor's process running on CyberTravels' host.

◴What this lesson is — Day 0, Day 1, Day 2

Two MCP resource servers behind a process boundary, split by trust domain rather than by convenience, each with its own audience — and why a tool called in-process leaves nowhere to put a check.

Day 0 — why

A tool called in-process runs with the agent's authority, and the only place left for a check is inside the component under attack.

Day 1 — how

Stand up two MCP resource servers, split by trust domain, each with its own audience.

Day 2 — measure

Tools reachable with a token minted for the other server. It should be zero, and it is worth proving rather than assuming.

Measure this

what to measuredenominator or target
tools reachable with a token minted for the other resource servercount; target zero

▦The framework, and how it works

        WHY THE TOOL IS A SEPARATE PROCESS

   in-process:   agent ─────────────► data
                 (the check would live inside the agent)

   over MCP:     agent ──► server ──► data
                            ▲
                            └─ verifies BEFORE acting

Tools are the only components that change anything. Everything else in an agentic system reads, reasons and routes; the tools book, cancel and refund.

Model Context Protocol is how an agent reaches them, and the useful thing about MCP is not the wire format — it is that a resource server is a separate process. That boundary is what makes a refusal possible later.

Why a process boundary and not a function call

A tool called in-process runs with the agent's authority, whatever that is. The check would have to live inside the agent, which is the component an attacker is trying to influence. Put the tool behind a server and there is somewhere else to stand:

  agent  ──MCP──►  resource server  ──►  data
                   ▲
                   └─ verifies the caller's token BEFORE acting

CyberTravels has two, and the split is by trust rather than by convenience:

  • mcp/internal_server.py — bookings and payments. Ours.
  • mcp/vendor_server.py — a third party's process, on our host. Everything it returns is somebody else's writing.

Each has its own audience. A token minted for one is useless at the other, which means a compromised call cannot wander sideways.

The tool description is part of the attack surface

An MCP server declares its tools — names, descriptions, schemas — and the agent reads those descriptions to decide what to call. They are instructions arriving from a third party, they can change after you approved them, and B1.9 is the lesson on what that enables. For now: notice that you are trusting them.

2 · Start them, and read what each one declares

Do this

1. Install what the application needs. The lessons themselves need nothing extra, but this is a real web service, so it does. One command — python3 on a Mac, python on Windows:

python3 -m pip install -r cybertravels/requirements.txt

2. Start one of the two tool servers. Each is a program you run on its own:

python3 -m cybertravels.mcp.internal_server    # bookings, payments

It will look like nothing happened. No banner, no message, just a cursor sitting there. That is correct — these servers talk over a pipe rather than to a screen, so silence means it is running and waiting. Press Control and C together to stop it.

3. Start the other one the same way, in a second terminal window if you want both at once:

python3 -m cybertravels.mcp.vendor_server      # the third party's

4. Or start the whole application instead, which is easier to see, because it opens in your browser:

./cybertravels/run.sh      # then open http://127.0.0.1:8000

If that stops and says main.py does not exist at this checkpoint yet, nothing is broken — you have a copy of the code from before the web app was built. The message tells you which command to run to get a newer copy.

5. Now look at what the two servers say they can do. Stop the servers, start Python, and print the list:

from cybertravels import config
for tool, p in config.TOOL_POLICY.items():
    print(f"{tool:20s} {p['audience']:14s} {p['scope']:16s} "
          f"{'HIGH RISK' if p['high_risk'] else ''}")

6. Read the middle column. Seven tools. Five of them say mcp:internal and two say mcp:vendor. That word is the audience — the one server a permission slip is valid at. A slip made for the internal server is refused by the vendor server, so a request that goes wrong in one place cannot wander into the other.

7. Read the last column. Two tools are marked HIGH RISK: cancel_booking and issue_refund. Those are the two that are hard to undo, and they are what A1.7 puts a human in front of.

The skill below enumerates an agent's declared tool surface — what it says it can do, against what the code actually does. B1.9 runs it to catch a rug-pull; here you run it on your own servers, to see the surface you just created.

The skill

▶Real time execution as skill

The skill — skills/attestation/agent-code-surface-analyzer/SKILL.md

name: agent-code-surface-analyzer
description: >-
  Statically enumerate an agent or MCP server's declared tools, dangerous
  actions and declared-versus-actual capability surface from its repository
  or image. Use to inventory what a deployment can do before trusting what
  it says it does, to check MCP tool annotations against the code, or to
  baseline tool descriptions for rug-pull detection.
allowed-tools: Read, Grep, Glob, Bash

Agent Code Surface Analyzer

Controls: Static basis for controls 1, 2 and 5

What this establishes

Every other control is about constraining capability. This skill establishes what the capability actually is, from the code rather than from the manifest — because the manifest is a claim made by the thing being audited.

When to use this

Before trusting what a deployment says it does — a new MCP server, an agent framework you are adopting, a vendor integration. Also on a schedule against anything already deployed: tool descriptions are fetched at connect time and a server can change one after review, so the baseline this produces is what makes a rug-pull visible.

Procedure

  1. Enumerate declared tools. From the MCP tool manifest, the tool registry, or the decorator/registration sites in code.
  1. Classify each tool by what it actually reaches, by locating sinks:
  • subprocess, os.system, exec, eval → process execution
  • open(...,'w'), file writes, shutil, os.remove → filesystem write
  • HTTP clients, sockets → network egress
  • SDK credential reads, environment access → credential access
  • DELETE, DROP, TRUNCATE → destructive downstream
  1. Cross-check annotations against the code. MCP tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) are hints, not guarantees — the specification says so explicitly, and a server can mislabel a destructive tool as read-only. Treat every annotation as a claim to verify, never as truth.

Apply the spec's pessimistic defaults: an unannotated tool is assumed destructiveHint: true and openWorldHint: true.

  1. Record the declared-versus-actual delta. A tool annotated readOnlyHint whose body writes a file is the highest-value finding this skill produces.
  1. Hash the tool descriptions. Store tool_description_hash per tool. This is the rug-pull baseline: a server can change a tool's description after the client approved it, and the hash is what detects that.

Example

Input — the fixture committed at the top of scripts/agent_code_surface_analyzer.py. Edit it and re-run: the buckets, counts and verdicts below are derived from it, not hard-coded.

Output — the opening lines of a real run:

C1_default_deny_least_privilege   INTENT_EVIDENCED  runtime verdict needs observed usage and the
C2_sandbox_no_egress              PARTIAL           absence of a covert channel is not provable 
C3_identity_chain_obo             INTENT_EVIDENCED  4 signals; enforcement not shown
C4_gateway_guardrails             INTENT_EVIDENCED  runtime verdict needs reachability testing f
C5_injection_screening            PARTIAL           detector presence is verifiable; robustness 

PASS is not in the vocabulary. The strongest static verdict is
INTENT_EVIDENCED, and two controls cannot exceed PARTIAL at all.

The run continues past this. test_skills.py executes the script on every build, with no model configured — so what CI proves is that it runs and refuses legibly, not that it prints these lines. The output above is a recorded run against a real model, and nothing re-diffs it: a model's answer is not reproducible, and this repository does not claim otherwise anywhere it can be checked.

Output contract

{
  "deployment_id": "str",
  "tools": [
    {"name": "str", "capability_class": ["process|filesystem|network|credential|destructive"],
     "annotations": {"readOnlyHint": "bool|null", "destructiveHint": "bool|null",
                     "idempotentHint": "bool|null", "openWorldHint": "bool|null"},
     "annotations_present": true,
     "declared_vs_actual": "match|understated|overstated",
     "evidence": [{"file": "str", "line": 0, "sink": "str"}],
     "tool_description_hash": "str"}
  ],
  "dangerous_actions": ["str"],
  "unannotated_count": 0,
  "verdict": "PASS|PARTIAL|FAIL"
}

Failure modes

  • Believing the annotations. They are advisory. Cross-check or do not report on them at all.
  • Missing dynamic registration. Tools registered at runtime from config do not appear in a static scan; record that as a PARTIAL, not a clean pass.
  • Hashing the tool name instead of the description. The description is what the model reads and what a rug-pull changes.

Three ways to do this lesson. Pick one. They run the same procedure against the same code — what changes is who drives it, and how much you see.

1In your AI agent — you are taught

The agent reads the lesson, explains the idea, fetches the code, runs the check and answers your questions as you go. This is the one to take if you are here to learn.

Install it, once. From the folder you cloned. --all does every agent it finds on your machine:

python3 scripts/install_skills.py --all --lessons A1.2

Then restart your agent and open it in that folder. On Windows, if python3 says Python was not found, use python instead.

Then pick a1-2-tools-over-mcp:

agentinstall just for this agentstart it
Claude Codepython3 scripts/install_skills.py --tool claude --lessons A1.2type /a1-2-tools-over-mcp
GitHub Copilot (VS Code)python3 scripts/install_skills.py --tool copilot --lessons A1.2type /a1-2-tools-over-mcp in Chat
Cursorpython3 scripts/install_skills.py --tool cursor --lessons A1.2type / in Agent chat and search for it
Codexpython3 scripts/install_skills.py --tool agents --lessons A1.2type $a1-2-tools-over-mcp, or run /skills

2As one Python command — you just run it

No agent, no conversation. The same lesson executes and prints the same readback. Take this one if you have no assistant, if you are reviewing rather than learning, or if you want the result without the teaching around it.

python3 scripts/lesson.py A1.2

3Read the code, and the difference — nothing runs

Neither of the above, and no model needed. The first command writes CyberTravels exactly as it stood at the end of this lesson; the second prints the diff — the lines this lesson added or changed, and nothing from any later one.

python3 scripts/checkpoint.py --at A1.2 --out work/cybertravels
python3 scripts/checkpoint.py --at A1.2 --diff

On Windows, type python wherever these say python3 — and use \ instead of / in the paths. Everything else is identical. A0.0 step 2 explains why.

Ways 1 and 2 need a model; way 3 does not. A signed-in Claude Code CLI needs no API key, and any OpenAI-compatible endpoint works too. With neither, the audit exits 2 and says so rather than inventing an answer. A0.0 sets this up.

Ways 1 and 2 both end with the same readback: What I did, What changed, The number and Read this next. A step that was skipped or refused is reported as exactly that, never as a pass.

If you did the last lesson’s challenge, expect this to stop. Most challenges ask you to edit the code, and ways 1 and 2 will not overwrite your edits: you get stopped, not a failure, with two options — --out for a fresh folder that keeps your work, or --force to discard it and start this lesson clean.

✓What you just proved

Both servers' tools enumerated, each with its audience and the scope it requires, and any tool whose declared surface is wider than its implementation.

✎Your turn

Change one tool's description to claim it is read-only while leaving it a write. Nothing in the protocol stops you. That is B1.9, from the inside.

Tools used

MCP

Skill source: skills/attestation/agent-code-surface-analyzer

← PreviousA1.1 · The loop — model, tools, and the step that turns text into an action