Skip to main content
Glama
codeSpicer

read_file MCP Server

by codeSpicer

ollama-react-agent — ReAct Tool-Using Agent (with MCP)

Standalone repo for this project. Curriculum source: ai-engineer-roadmap Phase 2 · Skill Agents · Project A.

Phase: 2 — Building AI Apps · Skill: Agents · Difficulty: Intermediate

A single agent that reasons, selects tools, and acts in a loop until it produces a final answer. Includes two tool-integration paths: plain in-process function-calling and a Model Context Protocol (MCP) server. Local-first: Ollama function-calling model, zero API keys. Build the loop framework-free first (raw ollama.chat + manual dispatch), then compare against a framework — the hand-rolled version is where the learning lives.

An agent = LLM brain + tool hands + observation loop. The LLM never touches the world directly; it emits structured tool requests, your runtime executes them, results come back as observations, and the loop repeats until Final Answer or max-steps.

  • AI Agents (ReAct, tools / function calling)

  • Model Context Protocol (MCP) — folded into this skill

Related MCP server: veriloop

Concepts covered

The agent loop (reason → act → observe), tool schemas (JSON Schema as the model/runtime contract), ReAct prompting (Thought/Action/Observation interleaving), native function-calling vs prompt-parsed actions, error-as-observation handling, max-steps termination guards, reasoning-trace inspection, MCP (server/client, discovery, transport decoupling), framework-free vs framework debugging tradeoffs.

Prerequisites

  • Foundations Project A (sampling params, system/user roles, JSON mode — all reused under the hood)

  • Ollama with a function-calling-capable model (qwen3.5:4b — tier guide for 16GB Macs in root MODELS.md)

  • uv pip install ollama mcp (+ requests for web-search/API tools)

Learning objectives

  • Implement the reason/act/observe loop by hand and read a full trace explaining each tool decision

  • Define tools with clear JSON schemas, descriptions that drive selection, and validated args

  • Make tool failures into observations the model can recover from (not crashes)

  • Expose one tool as an MCP server and consume it through MCP — and articulate what MCP buys over in-process calls

How it works (the loop)

user task
  │
  ▼
prompt = system (role + tool schemas + ReAct format) + history (thoughts, actions, observations)
  │  ollama.chat(model, messages, tools=[...])
  ▼
model output:  Thought: ...  →  Action: tool_name(args)   OR   Final Answer: ...
  │                                              │
  │ dispatch                                     └─ done → return answer + trace
  ▼
registry[tool_name](validated_args)  →  Observation: <result or ERROR: ...>
  │  append to history; steps += 1; if steps > MAX → force-terminate
  └─ loop (model sees the observation, reasons again)

Two wirings for the SAME tool:
  in-process:  registry["read_file"] = python function      (direct call)
  MCP:         registry["read_file"] = mcp_client.call(...) (via MCP server subprocess)

The trace (every Thought/Action/Observation with timestamps) is the primary artifact — "you can read the trace and explain each tool decision" is the definition of done.

First-principles guide — read this if ReAct / MCP feels fuzzy

How an LLM "calls a tool" (it's not magic)

  1. The LLM never touches files or the web itself. Your code passes it tools=[...] schemas (these live in react_agent/tools.py).

  2. The model replies with a structured tool_calls object, e.g. calculator(expression="17*23") — not the answer, just a request.

  3. Your dispatch(name, args) runs the real Python function, gets a string back like "391", appends it as an Observation message, and calls the model again.

  4. The model sees the result and reasons again. That Thought → Action → Observation repeat is ReAct.

Worked trace for evals/tasks.md S1 ("What is 17*23?"):

User task → Model: call calculator(17*23) → you run it → Observation: 391
→ Model: Final Answer: 391

MCP in one paragraph

MCP is the same thing with one extra hop: instead of dispatch() calling the Python function directly, it asks a separate MCP server process over stdio. Think USB-C vs hard-wired — same tool, decoupled. Same task, same pass rate, small +ms latency. That latency delta is the MCP tradeoff interviewers ask about.

Watch in this order

www.youtube.com/watch?v=bOhK-FRR-Ac

Micro-task ladder (15–20 min each, in order)

  1. Be the loop on paper. Take S1, write out your Thought / Action / Observation / Final Answer by hand. That's all ReAct is.

  2. See a raw tool_calls object. No loop yet — one ollama.chat(model="qwen3.5:4b", messages=[...], tools=TOOLS), print response.message.tool_calls. Prove the model emits a request, not text.

  3. Hand-run 2 steps. Copy the tool_calls args, call dispatch("calculator", args) yourself, send the result back as {"role": "tool", "content": "391"} in a second ollama.chat. Watch it produce the final answer. This is the whole loop, manually.

  4. Close the loop (Phase 2). Wrap step 3 in while steps < 5. Run no-tool task N1 (haiku — correct = zero tool_calls) and error task E1 (note.md lowercase → ERROR: file not found observation → model retries NOTES.md).

  5. Break it on purpose. max-steps=1 on 3-step task M2, kill network during web_search, ask for /etc/passwd. Each must end as a readable ERROR observation, never a crash.

  6. MCP swap (Phase 4). Expose just read_file as an MCP server, route that one entry through mcp_client instead of direct call. Run S2 both ways — same pass, small +ms.

If you do 2 and 3, step 4 clicks fast.

Setup

uv venv && source .venv/bin/activate
uv pip install -r requirements.txt
ollama serve
ollama pull qwen3.5:4b

# Sanity: model must support tools — if it emits fake JSON instead of tool_calls, switch models
python -m react_agent run "what is 17 * 23?" --trace
python -m react_agent run "read NOTES.md and summarize it" --trace
python -m react_agent run "search the web for <topic> and save findings to out.md" --trace --mcp

Flag

Where

Default

Meaning

--trace

run

off

print every Thought/Action/Observation

--max-steps

run

10

loop guard before forced termination

--mcp

run

off

route eligible tools through the MCP server instead of in-process

--model

run

qwen3.5:4b

must support nativetools= calling

--log

run

trace.jsonl

append machine-readable trajectory per run

Project layout (files you will create)

react-tool-agent/
├── requirements.txt
├── react_agent/
│   ├── __main__.py        `python -m react_agent` entry
│   ├── cli.py             run subcommand, flags, pretty trace printing
│   ├── loop.py            THE agent loop (prompt build → chat → dispatch → observe)
│   ├── tools.py           tool implementations + JSON SCHEMAS + registry
│   ├── mcp_server.py      same tools exposed as an MCP server (stdio)
│   ├── mcp_client.py      client wrapper matching the in-process registry interface
│   └── trace.py           JSONL trajectory log (thoughts, calls, args, results, ms)
├── evals/
│   └── tasks.md           8–12 multi-step tasks with expected tool sequences
└── README.md              this file + your measured tables

Tool set to implement (each: name, description, JSON schema, handler, error paths): calculator (safe eval, no eval() on raw input) · read_file / write_file (sandboxed to workdir) · web_search (DuckDuckGo API or requests scraper) · one small JSON API (weather/time/stocks — your pick).


Build phases (do these in order — they ARE the curriculum)

Phase 0 — Task set first (before any agent code)

  • Write evals/tasks.md with 8–12 tasks across 4 buckets:

    • single-tool (2–3): "what is 17*23" → calculator only.

    • multi-step (3–4): "search X, save summary to file" → search → write.

    • error-recovery (2–3): task whose first attempt SHOULD fail (bad path, missing arg) → agent must retry corrected.

    • no-tool (1–2): "write a haiku" → must answer directly with zero tool calls (over-triggering is a bug).

  • Verify: for each task you can state the EXPECTED tool sequence. If you can't, the task is vague — rewrite it.

Phase 1 — Tools + schemas (no LLM yet)

  • tools.py: implement the 4–5 tools as plain functions with JSON schemas (name, description, parameters with types/required). Descriptions must say WHEN to use the tool — the model selects tools by reading these, so "reads a file" < "reads a UTF-8 text file under workdir; use when the task references a local file."

  • Unit-test each tool directly (bad args, missing file, network down) — every failure returns a STRING error, never raises out of the registry.

  • Verify: call each tool from a REPL with good + bad inputs; all bad inputs produce clean error strings.

Phase 2 — The loop, framework-free (the core)

  • loop.py: system prompt (role + tool list + ReAct format + "Final Answer:" convention) → ollama.chat(tools=...) → if tool_calls, validate args against schema → dispatch → append observation → repeat.

  • Native tool-calling first (tools= param). qwen3.5:4b uses native tool_calls (S1: calculator({'expression': '17 * 23'}) → 391); no fake JSON observed — no model switch needed.

  • --max-steps guard: --max-steps 1 on 9*10 stops after 1 Action/Observation with Stopped after 1 steps without a Final Answer (partial trace above) — no hang.

  • Verify: S1 (17*23 → 391, 2 steps) + N1 (haiku, zero tool calls) pass with traces narratable line by line (Thought → Action → Observation → Final Answer).

  • Status: Phase 2 complete (2026-09-27). qwen3.5:4b native tool_calls confirmed, guard confirmed, S1+N1 traces saved in-session.

Phase 3 — Errors as observations + multi-step

  • Every tool exception → "ERROR: <what> — <how to fix>" observation fed back to the model (E1: ERROR: file not found: note.md — available files: NOTES.md, prices.txt → retried NOTES.md).

  • Ran multi-step (M2-variant: price.txt→ERROR→prices.txt→calculator(2.50+3.20+1.80)→write_file(total.txt)=7.5)

    • error-recovery (E1 3 steps; big-number E2 12769*83→1059827, 2 steps); traces kept in-session.

  • Verify: E1 shows ERROR observation → corrected read_file(NOTES.md) → summary success. No crash on first error.

  • Status: Phase 3 complete (2026-09-27). Known gaps: small-number E2 answered 96 from weights with zero calls (under-triggering); error.txt probe hit a no-verify-then-stop loop until max-steps (see observation 6).

Phase 4 — MCP server + client (same tools, second wiring)

  • mcp_server.py: exposes read_file via @mcp.tool() on MCPServer("react-tools") over stdio (mcp 2.x API: from mcp.server.mcpserver import MCPServer; body delegates to tools.read_file).

  • mcp_client.py: read_file_via_mcp(path) + registry_with_mcp() exposing the identical name→function interface, so loop.py can't tell which wiring serves the call.

  • --mcp flag swaps the read_file registry entry to MCP-backed; S2 run both ways.

  • Verify: S2 identical summaries both wirings; trace shows Action:mcp vs Action:in-process; MCP hop measured ~926ms vs ~0ms in-process (fresh subprocess spawn per call).

  • Status: Phase 4 complete (2026-09-27).

Phase 5 — Trace report (the deliverable)

  • Full task set run twice (in-process, MCP where applicable); table below.

  • Two annotated traces (clean multi-step M2; error→recovery E1) — see below.

  • Verdict: wasted steps, description mis-selections, MCP cost/benefit — see below.

  • Status: Phase 5 complete (2026-09-28).

Pass table (final)

Task

Expected

In-process

MCP

Steps

Notes

S117*23

[calculator]

pass (391)

pass (391, Action:mcp only labels read_file; calculator stays in-process by design)

2

nativetool_calls

S2 readNOTES.md

[read_file]

pass

pass (identical summary,Action:mcp)

2

same output both wirings (True)

S3 Berlin weather

[get_weather]

pass (24.3°C, live Open-Meteo)

n/a (not MCP-routed)

2

live API, not fixture

M1 search→save

[web_search, write_file]

pass (out.md 1544 chars; model ran 2 searches unprompted before writing)

n/a

5

extra search = wasted step, still correct

M2 read→compute→write

[read_file, calculator, write_file]

pass (total.md = 7.5)

pass (total_mcp.md = 7.5)

4

cleanest trace (annotated below)

M3 two searches→file

[web_search, web_search, write_file]

not run (M1 covers the pattern; DDG rate limits)

n/a

—

skipped deliberately

E1note.md lowercase

[read_file→ERROR, read_file→success]

pass (ERROR + available-files hint →NOTES.md → summary)

pass

3

annotated below; E1 fixture is case-sensitive on Linux, but macOS FS is case-insensitive so E1 must use the truly-missingnote.md (singular), not notes.md

E2twelve times eight

[calculator→ERROR, calculator→success]

fail-as-designed (answered96 from weights, zero calls)

n/a

1

under-triggering finding; big-number variant (12769×83→1059827) passes with tool

N1 haiku

[]

pass (zero calls)

n/a

1

no over-triggering

N2 sky blue

[]

pass (zero calls)

n/a

1

no over-triggering

Bucket roll-up: single-tool 3/3 · multi-step 2/2 run (M3 skipped) · error-recovery E1 pass + E2 documented fail · no-tool 2/2.

Annotated trace 1 — clean multi-step (M2 in-process)

Read prices.txt, add up all the prices, write the total to total.md:

  1. read_file(prices.txt) → apples 2.50 / bread 3.20 / milk 1.80 — model must ground in the file, not guess.

  2. calculator(2.50 + 3.20 + 1.80) → 7.5 — exact decimal string passed, no words-to-digits needed here.

  3. write_file(total.md, 'Total: $7.50') → Wrote 12 chars — task's persist step; file on disk confirms end-to-end.

  4. Thought Final Answer: $7.50 with no further calls — correct stop (contrast the error.txt no-verify loop in observation 6).

Annotated trace 2 — error→recovery (E1 in-process)

Read note.md (lowercase) and summarize it:

  1. read_file(note.md) → ERROR: file not found: note.md — available files: NOTES.md, prices.txt — the ERROR-as-observation; the available-files hint is what makes recovery possible.md

  2. read_file(NOTES.md) → file contents — model visibly corrects the singular→plural/uppercase path from the hint, no new reasoning needed.

  3. Thought + Final Answer summary of NOTES.md — recovery complete in 3 steps, never a crash.

Verdict (1 page, compressed)

  • Wasted steps: M1 ran a second unprompted web_search before writing (5 steps for a 2-search-then-write task — correct but unminimal); the error.txt probe (observation 6) never stopped after write_file succeeded and burned all 10 steps re-reading. Fix for both: system-prompt rule "after write_file succeeds, give Final Answer — don't re-read what you just wrote", plus preferring the first sufficient search result.

  • Description mis-selections: none observed — but one UNDER-trigger: small-number E2 (twelve times eight) answered 96 from weights with zero calls, while the big-number variant forced calculator. The calculator description ("pass digits only — spell out words as numbers first") works when the model reaches for the tool; the gap is the model deciding it doesn't need the tool for easy arithmetic. Rewrite candidate: append "Always use this tool for arithmetic — never compute in your head."

  • MCP cost/benefit (measured): ~926ms per read_file via MCP vs ~0ms in-process (fresh subprocess spawn per call), identical output. Benefit: any MCP client can use the tool without importing the codebase; loop.py is wiring-agnostic. Cost: ~1s/call latency + second process to debug. Worth it for shared/reused tools, not for hot-loop-internal helpers. Persistent session (not per-call spawn) is the obvious next optimization.

  • Break-it results (experiment 3): --max-steps 1 force-terminates with partial trace (no hang); sandbox _resolve rejects /etc/passwd-style escapes as ERROR: path escapes workdir; MCP spawn failure returns ERROR: MCP server failed to start (no traceback). Network-kill mid-web_search not run (live DDG worked throughout).


Core experiments (fill in YOUR numbers)

1. Task-set pass table ← the core objective

Task bucket (tasks)

in-process pass/total

MCP pass/total

avg steps

notes (mis-selections, fixes)

single-tool (S1–S3)

3/3 (S1391; S2 summary; S3 24.3°C live)

2/2 (S1, S2 identical)

2

nativetool_calls, no fake JSON (qwen3.5:4b)

multi-step (M1–M3)

2/2 run (M1out.md 5 steps; M2 total.md=7.5, 4 steps; M3 skipped)

1/1 (M2total_mcp.md=7.5)

4–5

M1 ran an extra unprompted search; M2 clean

error-recovery (E1–E2)

E1 pass (3 steps); E2 fail-as-designed, big-number variant passes

E1 pass

E1: 3; E2-big: 2

small-number E2 = under-triggering finding

no-tool (N1–N2)

2/2 (zero calls both)

n/a

1

no over-triggering

What to look for: single-tool ≈100% both wirings; multi-step is where steps get wasted; error-recovery is where error-as-observation earns its keep; no-tool catches over-triggering (a model that calls search for a haiku has a prompting problem).

2. Description-driven selection

Deliberately weaken one tool description ("does file stuff") and re-run 3 tasks that need it — record mis-selection rate. Restore the precise description, re-run. This is your concrete proof that schemas/descriptions ARE the tool-selection mechanism, not decoration.

3. Break it deliberately

  • --max-steps 2 on a 4-step task — confirm forced termination with a readable partial trace, not a hang.

  • Kill network mid web_search task — the error must appear as an Observation the model reasons about.

  • Ask a task referencing a file outside the sandbox (/etc/passwd-style) — confirm refusal/containment.

  • --mcp with the MCP server NOT running — confirm a clean actionable error, not a traceback.

4. Model swap

Run the same 3 tasks on two Ollama models (e.g. qwen3.5:4b vs a larger qwen 7–8b). Record: native tool-call compliance (did it use tool_calls or fake JSON?), steps wasted, pass/fail. Tool-use ability varies enormously by model — your table makes that concrete.

Observations worth writing down as you go

  1. Description → selection causality: the before/after from experiment 2, verbatim.

  2. Native vs fake tool calls: qwen3.5:4b uses native tool_calls throughout (S1, M2-variant, E1, big-number E2) — no fake JSON; minimum viable model confirmed.

  3. Error-message design: ERROR: file not found: note.md — available files: NOTES.md, prices.txt rescued E1/M2-variant (model retried corrected name).

  4. MCP hop cost: ~926ms per read_file via MCP vs ~0ms in-process (fresh python -m react_agent.mcp_server subprocess spawn per call). Same output both wirings (True on equality check) — interoperability costs ~1s/call.

  5. Over-triggering instances: none on N1 (haiku, zero calls). UNDER-triggering instead: small-number E2 answered from weights with zero calls; big numbers force the tool path.

  6. No-verify-then-stop loop (2026-09-27): read note.txt → save error in error.txt wrote the file at step 2 but re-read error.txt/notes.md until --max-steps 10 killed it. Fix: prompt rule "after write_file succeeds, give Final Answer — don't re-read what you just wrote".

Definition of done

  • The agent completes multi-step tasks end-to-end (M1 out.md, M2 total.md/total_mcp.md) and read_file goes through MCP (S2, M2, E1 via --mcp).

  • Every run has a trace narratable Thought → Action → Observation (annotated M2 + E1 above).

  • Task table filled for both wirings; E1 shows visible self-correction (note.md ERROR → NOTES.md).

  • ReAct = model requests / runtime executes / observation loops; schemas drive selection; max-steps prevents hangs; MCP = same tool + ~1s subprocess cost for cross-client reuse.

Reference material

Next

Project B turns this single loop into a TEAM: supervisor + specialists + shared state + human gates (LangGraph). Keep tools.py — the workers reuse these tools.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Stdio MCP server for sandboxed file access — read files, search content, safely edit with checksums, and manage file structure.
    15 npm
    ISC
  • A
    license
    A
    quality
    D
    maintenance
    Exposes a verified tool registry (calculator, sandboxed file read, web fetch) over MCP stdio, enabling any MCP-capable client to reuse the same tools from the inspectable ReAct loop.
    3
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    A small filesystem MCP server that reads and writes files under one confined root over stdio, with paginated tools and strict change-detection. It enables safe, deterministic file access for agentic runtimes and MCP clients.
    21 npm
    MIT