Before you start, three things have to be working: a terminal (the macOS
Terminal, or the one inside VS Code), Python 3, and Claude Code signed in to the NRT
organization. Check with python3 --version and claude, then
/status.
If any of those fail, stop here and work through the setup steps first — about ten minutes. Nothing below will work without them.
Package:
Demo2_MCP_Tissue_Analyzer.zip
This guide uses the same synthetic histology-like images, but now the tool is exposed through MCP.
The main teaching goal is to show that the scientific workflow remains similar while the tool interface becomes explicit and machine-readable.
1. Unzip the package
unzip Demo2_MCP_Tissue_Analyzer.zip
cd Demo2_MCP_Tissue_Analyzer
Inspect. The second command is the one that matters — .mcp.json starts
with a dot, so it is hidden from a plain ls:
ls -R | head -40
ls -a
If you would rather see a tree. macOS and Linux do not ship tree
by default. Install it with brew install tree (macOS) or
sudo apt install tree (Ubuntu / WSL), then run tree -a -L 3 — the
-a is what shows .mcp.json. On Windows, tree /F works with
nothing to install, or use Get-ChildItem -Force -Recurse -Depth 2.
Important files include:
server.py
.mcp.json
tools/
data/
previews/
results/
docs/EXPECTED_SYNTHETIC_RESULTS.md
2. Review the synthetic images
Grayscale images used by the tool:
data/case_normal_histology.pgm
data/case_mild_injury_histology.pgm
data/case_necrosis_histology.pgm
data/case_fold_artifact_histology.pgm
Color previews:
previews/
These are synthetic teaching images only.
3. Review expected outputs
cat docs/EXPECTED_SYNTHETIC_RESULTS.md
Recommended parameters:
magnification = 20
damage_threshold_fraction = 0.45
Expected pattern:
normal -> normal
mild injury -> mild_injury
necrosis -> necrosis
fold artifact -> plausible class + elevated artifact/edge behavior
Part A — Test the MCP-adapted tools directly
4. Run the MCP-adapted analysis script
Example:
python3 tools/analyze_tissue.py \
--slide data/case_mild_injury_histology.pgm \
--magnification 20 \
--damage-threshold-fraction 0.45 \
--out results/mild_mcp_analysis.json
Inspect:
cat results/mild_mcp_analysis.json
5. Run the classifier
The MCP classifier expects the saved analysis JSON as its input.
python3 tools/classify_tissue.py \
--features results/mild_mcp_analysis.json \
--out results/mild_mcp_classification.json
Inspect:
cat results/mild_mcp_classification.json
6. Demonstrate stronger validation
Try an invalid threshold:
python3 tools/analyze_tissue.py \
--slide data/case_mild_injury_histology.pgm \
--magnification 20 \
--damage-threshold-fraction 50
This version should reject the invalid scale instead of silently treating 50 as a valid fraction.
7. Inspect the MCP configuration
cat .mcp.json
{
"mcpServers": {
"tissue-analyzer": {
"command": "python3",
"args": ["server.py"]
}
}
}
Four lines of content, and each one is doing a specific job:
| key | what it is for |
|---|---|
mcpServers | The file is a map of the servers this project offers. Add a second entry and the project has two. |
tissue-analyzer | The name the server is known by — you choose it. It prefixes every tool the server exposes: analyze_tissue appears as mcp__tissue-analyzer__analyze_tissue in permission rules and in /context. |
command | What to run to start the server. python3 here; a Node server would say node. |
args | What to pass it. server.py is relative to the project folder. |
How Claude Code will use this file
When you start claude in this folder in step 9, this file is the reason the two
tools appear. In order:
- It reads
.mcp.json. Nothing scans your computer looking for servers. If this file is not here, Claude Code has no idea the server exists. - It asks you once whether to trust it — “New MCP server found in this project”. That is the prompt in step 9, and your answer is remembered for this project.
- It runs
python3 server.pyfor you, as a background child process, and keeps it alive for the whole session. - It talks to that process through its standard input and output — the
initializehandshake first, thentools/listto ask what it can do. - The tools appear in Claude’s tool list, each prefixed with the server
name:
mcp__tissue-analyzer__analyze_tissue. That is the name you will write in permission rules in Demo 3, and the name you will see in/context. - When you leave Claude Code, the process is stopped. Nothing keeps running.
So .mcp.json is Claude Code’s note to itself: this project has a
server, and here is the command that starts it. server.py never opens the file
— it does not need to know it exists.
Where an agent’s role is actually configured. Four files, and they do different jobs. It is worth knowing which one to open when you want to change what an agent can do.
| file | what it controls | demo |
|---|---|---|
.mcp.json | Which servers exist — which capabilities are on the table at all. | 2 |
server.py → TOOLS | What each capability is called, what it accepts, and what it refuses. | 2 |
.claude/settings.json | Which of those the agent may use freely, which need asking, which are denied outright. | 3 |
.claude/agents/*.md | A narrower role handed to a subagent: a smaller tool list and its own instructions. | 4 |
The first two decide what the tool is; the second two decide what is
allowed. CLAUDE.md is a fifth file you will see, but it is advice to the
model, not enforcement — it can be ignored, and the other four cannot.
8. Inspect the MCP server
more server.py
Space pages down, q quits. Read it — do not run it. If you do run python3 server.py out of
curiosity, the blinking cursor and total silence are correct: an stdio server waits for messages on
standard input. Ctrl C stops it. Claude Code starts it for you.
Look for the tool definitions:
analyze_tissue
classify_tissue
The MCP layer makes explicit things such as:
magnification ∈ {10, 20, 40}
damage_threshold_fraction ∈ [0, 1]
Two programs, not two personalities. Everything below says “Claude” and “the server”. They are two separate programs running on your laptop, and it is worth fixing which is which before reading further.
you ──► Claude ──► server.py ──► tools/analyze_tissue.py
│ │
│ └─ checks the arguments, then runs the script
└─ reads your English, picks a tool, fills in the values
Claude is the assistant in your terminal. It reads your request and decides
what to call and with what values. The server is server.py —
the program Claude Code started for you in step 7. It checks those values and runs the real
script.
They are different processes, and the only thing that passes between them is JSON. So when a sentence below says “Claude sees this” or “the server does that”, it is literal: either it crossed that arrow or it did not.
What server.py is made of — and how much of it you would have to change
The file has four parts. Some of them the protocol dictates and you should copy verbatim; the rest are this author’s choices and are exactly what you would rewrite for your own project. Knowing which is which is the difference between adapting this in an afternoon and being afraid to touch it.
1. TOOLS — the catalogue. Yours to change.
TOOLS = [
{
"name": "analyze_tissue",
"description": ("Measure damaged tissue on a slide. Run this FIRST. Reads the image "
"locally and returns numbers only, with its own confidence."),
"inputSchema": {
"type": "object",
"properties": {
"slide": {"type": "string", "description": "path to a slide, e.g. data/case_003.pgm"},
"magnification": {"type": "integer", "enum": [10, 20, 40], "default": 20},
"damage_threshold_fraction": {
"type": "number", "minimum": 0, "maximum": 1, "default": 0.45,
"description": "a FRACTION between 0 and 1, never a percentage"},
"out": {"type": "string"},
},
"required": ["slide"],
},
"script": "tools/analyze_tissue.py",
"flags": {"slide": "--slide", "magnification": "--magnification",
"damage_threshold_fraction": "--damage-threshold-fraction", "out": "--out"},
},
# classify_tissue follows, same shape
]
TOOLS is where this server keeps the schema for each of its
tools — one entry per tool, holding its name, its description, and its
inputSchema. When Claude asks what tools exist, the server walks this list and sends
back what it finds.
Keeping them in a Python list called TOOLS is just this file’s way of
storing them, and a literal is the easiest thing to read. You could keep exactly the same
entries in a JSON file beside the server and load them at startup:
TOOLS = json.loads((ROOT / "tools.json").read_text())
Nothing downstream would change — and on a larger project that is often the better
arrangement, because the contract becomes a file your collaborators can read and review without
opening any Python. What the protocol fixes is the JSON that goes out, not where you kept
it: each published entry needs a name and an inputSchema, and the schema
must be real JSON Schema.
description is optional in the specification and essential in practice: it is not
a comment, it is the text Claude reads when deciding whether this is the right tool and what to put
in each field. “a FRACTION between 0 and 1, never a percentage” is written for a
reader, and the reader is the model.
Now the last two keys, script and flags. These two
stay on the server’s side of the arrow. They tell server.py
which program to run and which command-line flag each argument becomes —
damage_threshold_fraction turns into --damage-threshold-fraction. Claude
has no use for that; writing the command line is the server’s job.
“But doesn’t the model write the command?” In Demo 1 it did. That was Demo 1: Claude read your sentence and typed a command line, and whatever it typed is what ran.
# Demo 1 - this whole line is text the model produced
python3 tools/analyze_tissue.py --slide data/case_003.pgm \
--magnification 20 --damage-threshold-fraction 45
In Demo 2 it does not. Claude sends values, not a command:
{"name": "analyze_tissue",
"arguments": {"slide": "data/case_003.pgm",
"magnification": 20,
"damage_threshold_fraction": 0.45}}
That JSON is everything the model contributes. The server checks each value against the
schema, and only then assembles the command line itself — from script and
flags, the same way every time. Nothing the model wrote is executed as a
command.
This is the whole difference between the two demos. In Demo 1 the command line was
model output, so an invented flag, a mistyped path, or 45 where 0.45
was meant all became a real command that ran and produced a confident number. In Demo 2 the
model’s output is a small object of values that has to survive validate()
before a command line exists at all.
The split happens in one line. When Claude asks what tools are available, the server sends back three keys per tool and keeps the other two:
{"tools": [{k: t[k] for k in ("name", "description", "inputSchema")} for t in TOOLS]}
So one entry does two jobs at once:
| keys | who uses them | for what |
|---|---|---|
name, description, inputSchema | Claude | choosing the right tool and filling in legal values |
script, flags | the server | building the command line that actually runs |
This is the pattern worth copying: put whatever private fields your own server needs into the entry — a working directory, a queue name, a container image — and send only those three across.
2. validate() — the gatekeeper. Every argument is checked here before anything runs.
def validate(schema, args):
errs, props = [], schema.get("properties", {})
for k in schema.get("required", []):
if k not in args:
errs.append("missing required argument '%s'" % k)
for k, v in args.items():
if k not in props:
errs.append("unknown argument '%s' (allowed: %s)" % (k, ", ".join(props)))
continue
s = props[k]
if s.get("type") == "integer" and not isinstance(v, int):
errs.append("'%s' must be an integer, got %r" % (k, v))
...
if "enum" in s and v not in s["enum"]:
errs.append("'%s' must be one of %s, got %r" % (k, s["enum"], v))
...
if "maximum" in s and v > s["maximum"]:
hint = " (did you mean %g?)" % (v / 100) if s["maximum"] == 1 else ""
errs.append("'%s' must be <= %s, got %s%s" % (k, s["maximum"], v, hint))
return errs
validate() takes the arguments that arrived and the tool’s own
inputSchema, and returns a list of everything wrong with them. If that list is not
empty, call() stops and nothing is run. It checks four things: that every required
argument is present, that there are no argument names it does not recognise, that each value is
the right type, and that each value is inside the allowed set or range.
These are its real answers — the exact strings Claude gets back:
| what arrives | what validate() returns |
|---|---|
magnification: 25 | 'magnification' must be one of [10, 20, 40], got 25 |
damage_threshold_fraction: 45 | 'damage_threshold_fraction' must be <= 1, got 45 (did you mean 0.45?) |
no slide at all | missing required argument 'slide' |
threshold: 0.4 | unknown argument 'threshold' (allowed: slide, magnification, damage_threshold_fraction, out) |
Notice the second one. The server does not quietly fix 45 into 0.45
— it refuses, and says what it suspects you meant. Guessing is how Demo 1 produced a
confident wrong answer.
Why check again, when the schema was already published?
Because publishing a schema and enforcing one are different acts. inputSchema goes
out to Claude as a description — it is what lets Claude fill the fields in
correctly, and most of the time it does. But it is a description, not a lock on the door. The
arguments that actually arrive at the server could come from a different model, a different
client, or a person typing JSON by hand, which is exactly what you will do in step 15. Validation
is the check on the values that really turned up, and the server is the only
place that check can be made.
A way to see how much rests on it: imagine deleting it. The server would
still start, still connect, still list its two tools — nothing would look broken. It would
simply accept magnification 25, run the script, and hand back a confident number.
Nothing else in the chain would have stopped it, because these thirty lines are the only place
where any value is checked at all.
That is the difference between Demo 1 and Demo 2, and it is thirty lines wide. If you carry one function from this file into your own project, carry this one.
3. call() — the one that actually runs the work.
def call(name, args):
spec = BY_NAME.get(name)
if not spec:
return "unknown tool: %s" % name, True
errs = validate(spec["inputSchema"], args)
if errs:
return "argument validation failed (nothing was run):\n- " + "\n- ".join(errs), True
cmd = [sys.executable, str(ROOT / spec["script"])]
for k, v in args.items():
cmd += [spec["flags"][k], str(v)]
p = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True)
return (p.stdout or "") + (("\n[stderr]\n" + p.stderr) if p.stderr else ""), p.returncode != 0
call() is the step that actually runs the analysis. It does five things, in
this order:
- Find the tool by the name that came in. An unrecognised name stops here.
- Validate the arguments, using the schema from that tool’s entry.
- If validation failed, return now — with the list of problems, and without running anything.
- Build the command and run it, capturing whatever the script prints.
- Hand back that text, plus a true/false flag saying whether it failed.
The order is what protects you, not the presence of a check. If steps 2 and
3 came after step 4, then a request with magnification 25 would run the analysis
first and report the problem afterwards — by which point the wrong numbers exist and
somebody may already have read them. Here, a bad value produces no run at all.
Step 4 is the part you would rewrite for your own project. This one shells out to a Python
script because that is what the tissue analyzer is; an R project would call Rscript,
a server wrapping a library would just import it and call a function, and a cluster job would
submit and return an id.
Step 5 is worth looking at directly, because a refusal and a result come back in the same shape. Here are both, as the server really sends them:
it worked:
{"content": [{"type": "text",
"text": "{ \"damage_fraction\": 0.476, \"confidence\": 0.95, ... }"}],
"isError": false}
it was refused:
{"content": [{"type": "text",
"text": "argument validation failed (nothing was run):
- 'magnification' must be one of [10, 20, 40], got 25"}],
"isError": true}
The refusal is not a crash. It is a reply, carrying text that Claude reads, exactly the way it reads a result — so Claude can see what was wrong, change 25 to 20, and call again on its own. That is what you will watch happen in step 16. Had the failure been sent as a protocol-level error instead, Claude would know only that something went wrong, not what.
4. main() — the protocol. Copy this part verbatim.
def main():
for line in sys.stdin:
...
msg = json.loads(line)
mid, method = msg.get("id"), msg.get("method")
if method == "initialize":
ver = (msg.get("params") or {}).get("protocolVersion") or "2024-11-05"
res = {"protocolVersion": ver, "capabilities": {"tools": {}},
"serverInfo": {"name": "tissue-analyzer", "version": "2.0.0"}}
elif method == "tools/list":
res = {"tools": [{k: t[k] for k in ("name", "description", "inputSchema")}
for t in TOOLS]}
elif method == "tools/call":
pr = msg.get("params") or {}
text, err = call(pr.get("name"), pr.get("arguments") or {})
res = {"content": [{"type": "text", "text": text}], "isError": err}
elif mid is None:
continue
else:
... error -32601, no such method ...
if mid is not None:
print(json.dumps({"jsonrpc": "2.0", "id": mid, "result": res}), flush=True)
main() is the way in. Claude Code writes JSON messages to this program’s standard input, one per line. main() reads each one, looks at its method field, and hands it to the right part of the file:
method main() sends it to
---------------- -----------------------------------------------
initialize a short reply: the server's name and version
tools/list TOOLS - the three published keys of each entry
tools/call call() - which validates, then runs the script
Those three method names are fixed by the MCP specification, and Claude Code sends them on its own — you never type them. id, method and params are fields of the message that arrived; the reply carries the same id back, so the client can tell which question it answers.
A real session, so none of it stays abstract. in <- is what the client sent, out -> is what the server printed back:
in <- {"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2024-11-05",
"clientInfo":{"name":"claude-code","version":"1.0"}}}
out -> {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05",
"capabilities":{"tools":{}},
"serverInfo":{"name":"tissue-analyzer","version":"2.0.0"}}}
in <- {"jsonrpc":"2.0","method":"notifications/initialized"}
out -> (nothing at all)
in <- {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
out -> {"jsonrpc":"2.0","id":2,"result":{"tools":[
{"name":"analyze_tissue",
"description":"Measure damaged tissue on a slide. Run this FIRST...",
"inputSchema":{...}},
{"name":"classify_tissue",
"description":"Label a slide normal / mild_injury / necrosis...",
"inputSchema":{...}}]}}
in <- {"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"analyze_tissue",
"arguments":{"slide":"data/case_necrosis_histology.pgm",
"magnification":20,
"damage_threshold_fraction":0.45,
"out":"results/necrosis_mcp_analysis.json"}}}
out -> {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":
"{ \"damage_fraction\": 0.476, \"hard_edge_rate\": 0.076,
\"confidence\": 0.95, \"usable\": true, ... }"}],
"isError":false}}
Line breaks added for reading — on the wire each message is one line. The second message has no id, which is how a client says “no reply needed”, so it gets none.
So how much would you change for your own project?
| part | when you adapt it |
|---|---|
.mcp.json | Rename the server. One line. |
TOOLS | Rewrite. One entry per capability: name, description, schema, and however your entry points are invoked. |
validate() | Keep, then add your field’s rules — the ones no schema keyword covers and no agent can infer. This is where your expertise goes. |
call() | Change how the work is started: a different interpreter, a direct function call, a job submission. |
main() | Copy unchanged. |
A list and one function. That is the honest size of it — and it is why Demo 6 can hand you a project in a field you have never seen and still expect a working server out of it.
8A. Practice the claude mcp commands — optional, and not needed for the demo
Everything the demo needs is already set up by .mcp.json. These are the commands you would use on a project where it is not, or when a server will not connect. Skip straight to step 9 if you would rather stay in Claude Code.
Before starting the interactive session, work through the MCP-management commands from a normal terminal. Do these in order — each one sets up the next.
1. Look before you touch
claude mcp list
claude mcp get tissue-analyzer
tissue-analyzer is already there. Nobody registered it by hand: it is in the
package's .mcp.json, and Claude Code reads that file when it starts in this
folder.
cat .mcp.json
The states claude mcp list can report:
Connected -> configuration exists and Claude Code can connect
Needs authentication -> configuration exists but login is required
Failed to connect -> configuration exists but the server could not start
claude mcp get shows how the server is started — for this project, the
equivalent of python3 server.py. It is the first thing to check when the wrong
Python interpreter, the wrong command or the wrong scope is suspected.
2. Try to add it again — and watch it refuse
claude mcp add \
--scope project \
--transport stdio \
tissue-analyzer \
-- python3 server.py
This fails, and it is meant to: a server called tissue-analyzer already
exists in this project. That is the point of running it. The registration came
from a file, so a command that creates the same name has nothing to do.
Nothing is broken. Move on.
3. Remove it, and watch the file change
claude mcp remove tissue-analyzer --scope project
claude mcp list
cat .mcp.json
Gone from the list, and gone from .mcp.json — the command edited the file.
Removing the registration does not delete server.py, the tools, the model
or the data. It removes only the line that tells Claude Code how to reach them.
4. Now add it back by hand
The same command that failed in step 2 now works:
claude mcp add \
--scope project \
--transport stdio \
tissue-analyzer \
-- python3 server.py
claude mcp list
cat .mcp.json
You have just written .mcp.json yourself. Shipping that file in the package and
typing this command are two routes to the same registration.
What the parts mean — -- separates Claude Code's own options from the command
used to start the server:
name = tissue-analyzer
scope = project
transport = stdio
command = python3 server.py
And the scopes:
local only you, only this project
project stored in the project's .mcp.json - shareable with the repository
user you, across all projects
project scope is the useful one here, because students can open .mcp.json and
read it.
5. Optional — pin the exact Python interpreter
python3 means whichever python3 is first on the PATH when the server starts.
To remove that ambiguity, register the interpreter from your virtual environment
instead. Remove first — otherwise this is step 2 again:
claude mcp remove tissue-analyzer --scope project
claude mcp add \
--scope project \
--transport stdio \
tissue-analyzer \
-- .demo_venv/bin/python3 server.py
Useful when the server starts under a Python that lacks the packages it needs:
which python3
python3 --version
Put the package back the way it shipped before continuing, so everyone in the room is running the same thing:
claude mcp remove tissue-analyzer --scope project
claude mcp add \
--scope project \
--transport stdio \
tissue-analyzer \
-- python3 server.py
6. If a project MCP server will not load at all
If .mcp.json looks right and the server still does not appear, the trust prompt
was probably declined at some point. Clear that decision:
claude mcp reset-project-choices
Then start Claude Code again from the project root and choose Use this MCP server.
Terminal commands vs. /mcp
Terminal CLI configure and inspect MCP servers
claude mcp list / get / add / remove
Claude Code /mcp inspect and manage servers in the running session
The terminal commands change configuration on disk. /mcp shows you what the
current session actually has.
The lifecycle you just walked through
inspect -> add (refused, already there) -> remove -> add (works) -> connect -> use tools
Part B — Test inside Claude Code
9. Start Claude Code from the project root
claude
A prompt appears the first time you start Claude Code here, and the default answer is the wrong one. It reads "New MCP server found in this project: tissue-analyzer" and offers three choices:
Use this MCP server
Use this and all future MCP servers in this project
> Continue without using this MCP server <- pre-selected
Press Enter straight away and the server is switched off, quietly, for this project. Every MCP step after that fails, and nothing tells you why.
Press ↑ to reach "Use this MCP server", then Enter.
If you already pressed Enter too fast, it is recoverable. Leave Claude Code with
/exit, then run:
claude mcp reset-project-choices
and start Claude Code again from the project root.
Inside Claude Code:
/mcp
Confirm that:
tissue-analyzer
is connected.
10. Ask Claude to list available MCP tools
Prompt:
List the tools available from the tissue-analyzer MCP server
and summarize their input schemas.
You should see:
analyze_tissue
classify_tissue
11. Call analyze_tissue through MCP
Prompt:
Use the tissue-analyzer MCP tool analyze_tissue.
Use:
slide = data/case_mild_injury_histology.pgm
magnification = 20
damage_threshold_fraction = 0.45
out = results/mild_claude_mcp_analysis.json
Return the structured tool result.
Then verify in another terminal if desired:
cat results/mild_claude_mcp_analysis.json
12. Chain the classifier
Prompt:
Now call the tissue-analyzer MCP tool classify_tissue.
Use:
features = results/mild_claude_mcp_analysis.json
out = results/mild_claude_mcp_classification.json
Return the structured result.
Expected class:
mild_injury
13. Try the normal case
Prompt:
Use analyze_tissue on:
slide = data/case_normal_histology.pgm
magnification = 20
damage_threshold_fraction = 0.45
out = results/normal_mcp_analysis.json
Then classify the resulting JSON.
Expected class:
normal
14. Try the necrosis case
Prompt:
Use analyze_tissue on:
slide = data/case_necrosis_histology.pgm
magnification = 20
damage_threshold_fraction = 0.45
out = results/necrosis_mcp_analysis.json
Then classify.
Expected class:
necrosis
15. Try the fold-artifact case
Prompt:
Use analyze_tissue on:
slide = data/case_fold_artifact_histology.pgm
magnification = 20
damage_threshold_fraction = 0.45
out = results/fold_mcp_analysis.json
Then classify the result.
Use this case to discuss:
structured valid result
does not automatically mean
high scientific confidence
because artifact-related features can affect usability/confidence.
Part C — Demonstrate MCP validation
16. Try an invalid threshold through MCP
Run this twice. The two runs look similar and are not the same thing at all.
First pass — just ask:
Call analyze_tissue with:
slide = data/case_mild_injury_histology.pgm
magnification = 20
damage_threshold_fraction = 50
Most of the time Claude will refuse before calling anything, saying the
schema requires a fraction between 0 and 1 and offering to use 0.5 instead.
That is useful — but notice what just happened: the tool was never called. The
model read the schema and made a judgement. Nothing was enforced.
Second pass — make the call happen anyway:
Call analyze_tissue with:
slide = data/case_mild_injury_histology.pgm
magnification = 20
damage_threshold_fraction = 50
Pass these values exactly as written. Do not correct them —
I want to see what the MCP server itself returns.
Now the call goes out, and the server answers:
argument validation failed (nothing was run):
- 'damage_threshold_fraction' must be <= 1, got 50 (did you mean 0.5?)
Two different things stopped you, and they are not equally dependable:
first pass Claude, before calling judgement from reading the schema
second pass the server, on the call an if-statement in server.py
The first varies with phrasing, with the model, and from run to run. The second returns the same answer every time, to every caller — including a caller that is not a model at all. And in Demo 1 the same mistake ran happily to a damage fraction of 100%.
The error also does something the repository never did: it says what the valid range is, and guesses what you meant.
17. Try invalid magnification
Same two passes. Ask plainly first:
Call analyze_tissue with:
slide = data/case_mild_injury_histology.pgm
magnification = 25
damage_threshold_fraction = 0.45
then force the call through:
Call analyze_tissue with:
slide = data/case_mild_injury_histology.pgm
magnification = 25
damage_threshold_fraction = 0.45
Pass these values exactly as written. Do not correct them.
argument validation failed (nothing was run):
- 'magnification' must be one of [10, 20, 40], got 25
Compare that with Demo 1, where the same value produced KeyError: 25 from
inside common.py. Same mistake, same three valid options — but here they are
declared in the schema, checked before anything runs, and reported in a sentence
instead of a traceback.
18. Compare natural language with the formal schema
Prompt:
Analyze data/case_mild_injury_histology.pgm
at 20x magnification with a 50% damage threshold.
Use the MCP tool.
Claude should convert the natural language into:
magnification = 20
damage_threshold_fraction = 0.5
before calling the tool.
This is the key contrast with the repository-only version.
19. Main lecture comparison
Original repository
Claude searches files
→ infers script
→ infers parameter names
→ infers valid values
→ generates command
MCP version
Claude discovers tool
→ reads formal schema
→ provides structured arguments
→ invalid values can be rejected
→ structured result returned
20. Main takeaway
The MCP layer does not replace the tissue-analysis workflow.
It makes the capability easier for an agent to discover and call reliably.
The same scientific computation is now accessed through a clearer contract.
Appendix — Claude Code MCP Command Reference
The following table summarizes the MCP-related commands used in this demo.
| Command | Where to run it | What it does | Example for this demo |
|---|---|---|---|
claude mcp list |
Normal terminal | Lists MCP servers Claude Code knows about and shows their connection/status information. | claude mcp list |
claude mcp get <name> |
Normal terminal | Shows the configuration/details for one MCP server. Useful for checking command, scope, transport, and troubleshooting. | claude mcp get tissue-analyzer |
claude mcp add ... |
Normal terminal | Registers a new MCP server with Claude Code. | claude mcp add --scope project --transport stdio tissue-analyzer -- python3 server.py |
claude mcp remove <name> --scope <scope> |
Normal terminal | Removes an MCP server registration from the specified scope. It does not delete the actual server code. | claude mcp remove tissue-analyzer --scope project |
claude mcp reset-project-choices |
Normal terminal | Resets stored trust/approval choices for project MCP servers so Claude Code can ask again. Useful if you previously selected “Do not trust.” | claude mcp reset-project-choices |
claude mcp list after add/remove |
Normal terminal | Confirms whether the MCP server is currently registered and whether Claude can connect to it. | claude mcp list |
claude |
Normal terminal | Starts Claude Code in the current project directory. Start it from the directory containing .mcp.json. |
claude |
/mcp |
Inside Claude Code | Opens the interactive MCP status/management panel for the current session. | /mcp |
which python3 |
Normal terminal | Shows which Python interpreter will be used when python3 is invoked from the current shell. Useful before starting an stdio MCP server. |
which python3 |
python3 --version |
Normal terminal | Confirms the Python version associated with the active interpreter. | python3 --version |
source .demo_venv/bin/activate |
Normal terminal | Activates the project virtual environment before running Claude Code or registering the MCP server. | source .demo_venv/bin/activate |
Common MCP add patterns
Project-scoped stdio server
claude mcp add \
--scope project \
--transport stdio \
tissue-analyzer \
-- python3 server.py
Project-scoped stdio server using an explicit virtual environment
claude mcp add \
--scope project \
--transport stdio \
tissue-analyzer \
-- .demo_venv/bin/python3 server.py
The explicit interpreter version is often better for a classroom demo because it removes ambiguity about which Python environment starts the MCP server.
MCP scope reminder
| Scope | Meaning | Typical use |
|---|---|---|
local |
Available only to you for the current project | Personal project-specific setup |
project |
Stored with the project, typically through .mcp.json |
Shared classroom/repository configuration |
user |
Available to you across projects | Personal globally useful MCP servers |
For this demo, project scope is the clearest choice because students can inspect the .mcp.json file directly.
Useful troubleshooting sequence
If tissue-analyzer does not appear inside /mcp, try:
which python3
python3 --version
claude mcp list
claude mcp get tissue-analyzer
If you previously rejected the project MCP server:
claude mcp reset-project-choices
Then restart:
claude
and inside Claude Code run:
/mcp
If the server is still missing, re-register it:
claude mcp add \
--scope project \
--transport stdio \
tissue-analyzer \
-- python3 server.py
Then confirm:
claude mcp list
A compact mental model is:
claude mcp add/remove/get/list
-> configure or inspect MCP from the terminal
/mcp
-> inspect/manage MCP inside the active Claude Code session
Next. Demo 3 keeps this MCP server as the approved route and closes the
direct ones — denying Read(data/**), editing and general Bash. Open the Demo 3 guide →