Published: https://georgejinu-labs.github.io/aero-ref/  ·  Repo: github.com/georgejinu-labs/aero-ref
Multi-Server Agentic AI · Local Inference

AERO-REF — Under the Hood

A phase-by-phase walkthrough of every log line from a real agent run — mapped to the exact component that produced it. Two MCP stdio servers, LangChain tools (flight boards + BigQuery catalog), one local LLM, zero cloud inference for the model.

qwen2.5:3b · Ollama LangGraph · mcp-use FastMCP · BigQuery AeroAPI · FlightAware
// Contents
ADArchitecture — data flow (diagram) 00App Startup — two MCP servers launch as subprocesses 01Tool Discovery — tools, resources, and prompts via mcp-use 02LLM Call #1 — qwen2.5 reads 1,219-token context, picks first tool 03BigQuery tool fires — catalog lookup returns KIAH metadata 04LLM Calls #2 and #3 — redundant lookup, then flight counts call 05AeroAPI tool — live flight counts from FlightAware via MCP 06LLM Call #4 — final synthesis, tool_calls=[], loop exits 07The Full Loop — what LangGraph actually ran for Turn 1 08Context Window and Tokens — how the prompt grows each step 09max_steps — the safety brake, when it fires, how to handle it 10Turn 2 — KHOU missing from catalog, agent goes off-rails 11Component Summary — every piece and what it did in this run

System architecture

AD End-to-end data flow — how the pieces connect
aero-ref architecture: Ollama, MCPAgent, mcp-use, MCP tools resources and prompts, two FastMCP servers AERO-REF — HIGH LEVEL LOCAL AGENT · MCP STDIO · TOOLS + RESOURCES + PROMPTS Ollama ChatOllama → local LLM (e.g. qwen2.5:3b) main.py — MCPAgent LangGraph + langchain-ollama + MCPClient After initialize: optional prompts/get on bigquery (AGENT_USE_MCP_PROMPTS) → user message text → agent.run mcp-use stdio JSON-RPC · one session per server in mcp_config.json Surfaces tools, resources, prompts to LangChain (wrappers the LLM may call) MCP METHODS (PER SESSION) tools/call list + invoke resources/read URI → text prompts/get template + args mcp-use maps each call to the matching server’s stdin/stdout session tools/call → flight tools/call → bigquery resources/read → bigquery only prompts/get → bigquery only LangGraph tool node → mcp-use → tools/call JSON-RPC on the subprocess for that tool FLIGHT MCP uv run → flight_server.py (FastMCP) Tools: get_airport_flights, arrivals, departures, counts Resources / prompts: none in this demo Live boards → FlightAware AeroAPI (HTTPS) BIGQUERY MCP uv run → bigquery_server.py (FastMCP) Tools: list_demo_airports, get_demo_airport, get_demo_airport_hints Resource: reference://demo-airport-hints Prompts: airport-summary, compare-airports Hints: same text via resource read or no-arg tool (agent-friendly) Catalog rows → Google BigQuery API FlightAware AeroAPI External HTTPS · API key from env Google BigQuery GCP project + ADC · demo airports table No cloud LLM in this stack — only local inference and data APIs

Tools: BigQuery catalog + static hints tool + four flight-board tools. Results return as JSON and are appended to the chat for the next model call. Resources: reference://demo-airport-hints is registered on the BigQuery server (resources/list, resources/read). The same copy is available as get_demo_airport_hints() with no arguments so small models are not forced to synthesize a URI (mcp-use maps resource reads to LangChain tools that validate uri). Prompts: airport-summary and compare-airports are server-side workflow templates. main.py can call prompts/get after connect and pass the returned text to agent.run when AGENT_USE_MCP_PROMPTS=1. Transport: agent ↔ MCP is stdio JSON-RPC; only the flight server calls AeroAPI over the public internet. Diagram: solid green/cyan lines show tools/call reaching both subprocesses (mcp-use picks the session by tool); purple and orange dashed lines show resources/read and prompts/get on the bigquery session only.

Handshake reminder

Every session runs initialize, then tools/list, resources/list, and prompts/list. Your log may show “0 resources / 0 prompts” for an older build; current bigquery_server.py advertises one resource URI and two prompts on that server.

00 App startup — two MCP servers launch as subprocesses
From your log:
🔌 Found 0 existing sessions → creating new ones...
Connecting to MCP implementation: uv          ← flight_server.py
Starting StdioConnectionManager task
StdioConnectionManager connected successfully
→ initialize  (16.826s)  ← cold uv dep resolution + subprocess startup
→ tools/list  (0.008s)   ← 4 tools returned
→ resources/list, prompts/list  ← flight: none in this demo
MCP session initialized with 4 tools, 0 resources, 0 prompts

--- same sequence for bigquery_server.py (cold init varies) ---
→ resources/list  ← e.g. reference://demo-airport-hints
→ prompts/list  ← airport-summary, compare-airports
MCP session initialized with 3 tools, 1 resource, 2 prompts
✓ Created 2 new sessions  — mcp-use then wraps MCP tools + resources + prompts for LangChain
What this means

MCPClient.from_dict() reads mcp_config.json and spawns each server as a local subprocess via uv run. Communication is over stdio pipes — not HTTP. This is the MCP “stdio transport” mode. The protocol then does an MCP handshake: initializetools/listresources/listprompts/list.

The 16–21 second init times are cold uv dependency resolution. Subsequent runs are much faster once uv’s cache is warm. All of this happens before you type any query.

// mcp_config.json — the file that drives all of this
{
  "flight": {
    "command": "uv",
    "args": ["run", "--directory", "C:\...\aero-ref", "src/aero_ref/flight_server.py"]
  },
  "bigquery": {
    "command": "uv",
    "args": ["run", "--directory", "C:\...\aero-ref", "src/aero_ref/bigquery_server.py"]
  }
}
01 Tool discovery — mcp-use wraps tools, resources, and prompts
Representative log (current bigquery_server.py + flight_server.py):
Loaded 4 new tools for connector (flight server):
  get_airport_flights        ← full board: arrivals + departures + scheduled
  get_airport_arrivals       ← recent landings, ordered by actual_on desc
  get_airport_departures     ← recent takeoffs, ordered by actual_off desc
  get_airport_flight_counts  ← summary counts only — fastest snapshot

Loaded 3 new tools for connector (bigquery server):
  list_demo_airports       ← catalog scan with limit
  get_demo_airport         ← lookup by ICAO / IATA / LID
  get_demo_airport_hints   ← tiny static ICAO hint string (no args)

Plus from bigquery (same connector), as LangChain-callable wrappers:
  1 resource tool   ← read by URI (same payload as hints tool)
  2 prompt tools    ← airport-summary, compare-airports

🛠  Created LangChain tools from client: 7 tools, 1 resources, 2 prompts
🧰  Found N tools across all connectors
🧠  Agent ready (names include flight + bigquery + prompt wrappers)
What this means

mcp-use runs tools/list, resources/list, and prompts/list during init. It converts MCP tools into LangChain StructuredTools, and typically exposes resources and prompts as additional callable tools the model can invoke.

main.py can also call prompts/get directly on the bigquery session to build the user message before agent.run — that path does not rely on the LLM to pick a prompt tool.

The LLM still chooses among whatever names land in the system prompt; it matches by text, not by “understanding” MCP.

02 LLM Call #1 — qwen2.5 reads 1,219-token context, picks first tool
Exact prompt sent to qwen2.5:3b (condensed):
System: You are a helpful AI assistant.
        You have access to the following tools:
        - get_airport_flights: All recent and upcoming flights at an airport...
        - get_airport_arrivals: Flights that have recently arrived...
        - get_airport_departures: Flights that have recently departed...
        - get_airport_flight_counts: Summary counts by status...
        - list_demo_airports: List rows from the demo airports table...
        - get_demo_airport: Look up one airport by code...
        - get_demo_airport_hints: Static ICAO hints (no args)...
                ... plus resource-read + prompt wrappers from bigquery (names vary)
        You can call tools on two MCP servers:
        1) bigquery — catalog  2) flight — live AeroAPI
        Typical flow: airport metadata from BigQuery when useful,
        live boards from flight tools. Say which source facts came from.
Human: "At Houston Bush (KIAH), use our airport reference for the official name
        and city. Then summarize live activity: flight counts (departed,
        enroute, scheduled arrivals and departures), plus whether recent
        arrivals or departures look unusually delayed."
LLM output (357 seconds — very slow first call):
tool_calls: [{
  "name": "list_demo_airports",    ← LLM picked the broadest catalog tool first
  "args": {},                    ← no arguments needed, returns all airports
  "type": "tool_call"
}]
content: ""  ← empty. LLM returned ONLY a tool call. No text answer yet.

input_tokens:  1,219
output_tokens: 18      ← just the JSON tool call
total_tokens:  1,237
What this means

langchain-ollama (ChatOllama) sent this prompt to localhost:11434. The LLM read the tool descriptions in the system prompt (more than in the original six-tool log, once resources and prompts are wrapped) and matched “airport reference” against list_demo_airports. Pure text matching — no deeper reasoning.

The 357-second time is dominated by model cold load: load_duration: 33.4 seconds + prompt_eval_duration: 316 seconds to process 1,219 input tokens at CPU speed. Only 18 tokens generated.

LangGraph sees tool_calls is not empty → does not route to END → routes to the tools node. The loop continues.

Note on tool choice: The LLM chose list_demo_airports (returns all airports) instead of get_demo_airport (direct lookup by code). It already knew the ICAO code was KIAH from the user message. A smarter model would call get_demo_airport(airport_code="KIAH") directly and save a round trip.
03 BigQuery tool fires — catalog lookup returns KIAH metadata
From your log:
MCP tool "list_demo_airports" received input: {'limit': 50}
→ stdio:bigquery_server.py → tools/call    (2.293s)
← stdio:bigquery_server.py ← tools/call

Tool result (structured):
{
  "table": "project-b4252fcd.aero_reference.airports",
  "count": 2,
  "rows": [
    { "airport_code": "KIAH", "name": "Houston Bush Intctl", "city": "Houston", "state": "TX",
      "code_iata": "IAH", "elevation": 97, "timezone": "America/Chicago", ... },
    { "airport_code": "KSFO", "name": "San Francisco Intl", "city": "San Francisco", "state": "CA", ... }
  ]
}
What this means

LangGraph’s tools node passes the tool call to mcp-use, which sends a tools/call MCP message to bigquery_server.py over stdio. The server executes the BigQuery SQL and returns 2 rows — your demo catalog.

mcp-use wraps this result in a ToolMessage and appends it to the conversation state. LangGraph routes back to the model node for another LLM call. The prompt is now longer.

04 LLM Calls #2 and #3 — redundant lookup, then the right tool call
LLM Call #2 output (153 seconds):
content: "To proceed, I need to find the official name and city for Houston Bush
           (KIAH). Let's use the get_demo_airport function with airport code 'KIAH'..."
tool_calls: [{
  "name": "get_demo_airport",
  "args": { "airport_code": "KIAH" }
}]
input_tokens: 1,537  output_tokens: 69  ← context grew by +318 tokens (the tool result)
Redundancy alert: The LLM already had KIAH’s full metadata from the previous list_demo_airports result. Calling get_demo_airport(KIAH) again returned identical data. This is a 3b model limitation — it didn’t synthesize the existing context and instead re-fetched. A larger model would recognise the data was already present.
LLM Call #3 output (176 seconds) — now calls the live API:
content: "The official name for Houston Bush (KIAH) is Houston Bush Intercity..."
           [fills placeholder text, no real answer yet]
tool_calls: [{
  "name": "get_airport_flight_counts",
  "args": { "airport_id": "KIAH" }
}]
input_tokens: 1,765  output_tokens: 170  ← context now at 1,765 tokens
What this means

The LLM is making forward progress but inefficiently. By Call #3 it finally reasons: “I have the catalog data, now I need the live counts.” It correctly picks get_airport_flight_counts — the most efficient tool for summary counts, no pagination, ICAO-preferred.

Note it also wrote some content alongside the tool call (the placeholder text). LangGraph sees tool_calls is still non-empty and routes to the tools node ignoring the partial text.

05 AeroAPI tool — live flight counts from FlightAware via MCP
From your log:
MCP tool "get_airport_flight_counts" received input: {'airport_id': 'KIAH'}
→ stdio:flight_server.py → tools/call    (1.808s)
← stdio:flight_server.py ← tools/call

Tool result:
{
  "airport_id": "KIAH",
  "departed":            91,
  "enroute":            254,
  "scheduled_arrivals": 1521,
  "scheduled_departures":1547
}
What this means

flight_server.py wraps the AeroAPI (FlightAware) GET /airports/KIAH/flights/counts endpoint. The 1.8-second response time is the real HTTP call to FlightAware’s servers. This is the only actual external network call in the entire agent run — everything else is local.

The result is compact (4 numbers) because this is the counts endpoint. If you’d asked for delay analysis, the agent would need to call get_airport_arrivals or get_airport_departures to inspect individual flight timings.

Architecture note: The agent correctly used two separate MCP servers for two different data sources: BigQuery for static airport catalog metadata, AeroAPI for live operational data. This is the intended pattern — static reference from BigQuery, real-time from AeroAPI.
06 LLM Call #4 — final synthesis, tool_calls=[], loop exits
LLM output (123 seconds):
content: "The summary of live activity at Houston Bush (KIAH) is as follows:
           Flight Counts:
             Departed: 91 flights that have already departed.
             Enroute: 254 flights currently in transit.
             Scheduled Arrivals: 1,521 flights scheduled to arrive soon.
             Scheduled Departures: 1,547 flights scheduled to depart soon.
           Recent Activity:
             Recent arrivals and departures do not show any delays based on the
             live data. However, this information is up-to-date as of now..."

tool_calls: []    ← EMPTY. LLM decided it is done.

input_tokens: 1,960  output_tokens: 152  total_tokens: 2,112
LangGraph sees tool_calls=[] → conditional edge routes to END → loop exits. Final answer returned. The agent used 4 model calls and 3 tool calls to answer one question.
What this means

The final answer is reasonable but incomplete — it says “no delays based on live data” but the agent never actually called get_airport_arrivals or get_airport_departures to check individual flight times. It only had summary counts. This is a hallucinated conclusion from a 3b model that treated counts as delay evidence.

A well-prompted agent or a larger model would have called the arrivals/departures endpoints to compute actual delay statistics before drawing that conclusion.

The Full Loop — what LangGraph ran for Turn 1

07 LangGraph chain trace — Turn 1 complete execution
LangGraph chain start
│
├─ ModelCallLimitMiddleware.before_model   ← checks: under 24 step limit? yes.
│
├─ model node [LLM Call #1]               ← ChatOllama → qwen2.5:3b (357s cold)
│   output: tool_calls=[list_demo_airports(limit=50)]
│
├─ ModelCallLimitMiddleware.after_model    ← call_count=1
│
├─ tools node                              ← mcp-use → bigquery_server.py (2.3s)
│   output: ToolMessage [KIAH: Houston Bush Intctl, KSFO: San Francisco Intl]
│
├─ model node [LLM Call #2]               ← context: 1,537 tokens (153s)
│   output: tool_calls=[get_demo_airport(KIAH)]   ← redundant re-fetch
│
├─ tools node                              ← bigquery_server.py again (1.2s)
│   output: ToolMessage [KIAH confirmed: Houston Bush Intctl]
│
├─ model node [LLM Call #3]               ← context: 1,765 tokens (176s)
│   output: partial text + tool_calls=[get_airport_flight_counts(KIAH)]
│
├─ tools node                              ← flight_server.py → AeroAPI (1.8s)
│   output: ToolMessage {departed:91, enroute:254, arr:1521, dep:1547}
│
├─ model node [LLM Call #4]               ← context: 1,960 tokens (123s)
│   output: final text answer, tool_calls=[]
│
└─ ModelCallLimitMiddleware.after_model    ← call_count=4 (of 24 max)
    END ← tool_calls=[] → conditional edge routes to END

Total wall time:   816 seconds
LLM calls:         4
Tool calls:        3   (2x BigQuery + 1x AeroAPI)
Tokens used:       2,112  (1,960 in + 152 out)

Context Window and Tokens

08a What is a token?
Definition

A token is a chunk of text — roughly 0.75 words on average. The LLM does not see letters or words, it sees token IDs (numbers). Every prompt you send and every response you get is measured in tokens.

"At Houston Bush (KIAH), use our airport reference..."

  At | Houston | Bush | (| KI | AH | ), | use | our | airport | reference...
  tok1   tok2   tok3  tok4 tok5 tok6 tok7  tok8  tok9   tok10      tok11...

Rule of thumb:
  100  tokens ≈  75 words ≈ half a page
  1000 tokens ≈ 750 words ≈ a short article
  Your full Turn 1 used 2,112 tokens ≈ 1,580 words
08b How the context window grew across 4 LLM calls
LLM Call #1 — 1,219 input tokens
system + tool defs ~700
user question ~300
padding ~219
output: 18 tokens (just the tool call JSON)
LLM Call #2 — 1,537 input tokens (+318 from BigQuery result)
system + tools ~700
question ~300
BQ result +318
other
output: 69 tokens (tool call for get_demo_airport)
LLM Call #3 — 1,765 input tokens (+228 from 2nd BQ result)
system + tools
question
BQ result 1
BQ result 2
other
output: 170 tokens (partial text + tool call for flight counts)
LLM Call #4 — 1,960 input tokens (+195 from AeroAPI counts result)
system + tools
question
BQ 1
BQ 2
API +195
other
output: 152 tokens (final text answer) — loop exits
Key insight: The context window grew from 1,219 to 1,960 tokens across Turn 1 as each tool result was appended. The system prompt + tool definitions take up ~700 tokens regardless — they’re re-sent on every LLM call.
08c Context window limits — qwen2.5:3b vs other models
MODEL                    CONTEXT WINDOW     ROUGHLY EQUIVALENT TO
————————————————————————————————————————————————————
qwen2.5:3b (yours)      32,768 tokens      ~25,000 words / ~50 pages
Llama3.2:9b             128,000 tokens     ~96,000 words / ~200 pages
Claude Sonnet 4         200,000 tokens     ~150,000 words / ~300 pages

Turn 1 used 2,112 tokens — only 6.4% of qwen2.5:3b's 32K limit.
Turn 2 accumulated to ~2,813 tokens.
With memory_enabled=False (your setting), each new agent.run() starts fresh.

max_steps — The Safety Brake

09a What is one "step"?

One step = one model call. ModelCallLimitMiddleware increments a counter before and after every LLM call. When the counter hits max_steps, a StopIteration is raised and the loop is forced to exit — whether or not the LLM is finished.

Your aero-ref used max_steps=24 (seen in log: "Created agent with max_steps=24")
Turn 1 used: 4 model calls of 24 — well within the limit.

Step 1: LLM Call #1 → list_demo_airports tool  → result
Step 2: LLM Call #2 → get_demo_airport tool    → result
Step 3: LLM Call #3 → get_airport_flight_counts → result
Step 4: LLM Call #4 → final answer, tool_calls=[]DONE
09b What causes an agent to hit max_steps
1. Tool keeps failing → LLM keeps retrying
   get_airport_flight_counts → API timeout → retry → timeout → retry...

2. LLM gets confused in a loop
   calls list_demo_airports → gets result → calls list_demo_airports again!
   You saw a mild version of this: LLM called get_demo_airport
   after list_demo_airports had already returned the same data.

3. Task genuinely needs more steps than you allowed
   PA agent processing 5 requests with max_steps=10
   → runs out before finishing all requests

4. LLM over-plans, breaks simple tasks into too many tool calls
   3b models are especially prone to this with complex multi-part questions
Recommendation for your PA agent: Set max_steps=15. A full PA workflow (eligibility check + guidelines lookup + PA DB update + notification) needs ~7–9 steps. 15 gives headroom without letting a stuck agent burn tokens forever.

Turn 2 — KHOU not in catalog, agent goes off-rails

10 What happened when KHOU was requested
Turn 2 query:
Human: "Compare Houston Hobby (KHOU) and Bush (KIAH): official names and cities
        from our catalog, then contrast live flight counts..."
What happened:
Step 1: get_demo_airport(airport_code="KHOU")
Result: {"count": 0, "rows": []}   ← KHOU not in BigQuery demo table

LLM response:
  "It appears there is no entry in the demo airport catalog for 'KHOU'.
   Let's proceed with fetching information for 'KIAH' and compare it to
   a real airport code, such as 'DFW' (Dallas/Fort Worth)..."

Step 2: get_demo_airport(airport_code="KIAH")   ← fetched AGAIN (already in context!)
Result: KIAH data (identical to Turn 1)

Step 3: LLM writes partial answer about KIAH and DFW, tool_calls=[]
        ← NEVER fetched KHOU live data, NEVER fetched DFW data,
           just made up a comparison without any evidence.
Three problems in one turn:
1. Catalog gap — KHOU is not in the BigQuery demo table. Real fix: add it to flight_booking_demo.user_travel.
2. Context blindness — LLM re-fetched KIAH data it already had.
3. Hallucinated pivot — LLM invented a DFW comparison the user never asked for, without any data.
Root cause: A 3b model lacks the reasoning capacity to handle “one entity missing from catalog, gracefully degrade to live-only data for what I have”. Upgrading to qwen2.5:9b or llama3.2:9b would improve this significantly. Better catalog coverage is the other fix.

Component Summary

11 Every piece and what it did in this run
ComponentRoleWhat it did in this run
MCPClient.from_dict() Config reader + session manager Read mcp_config.json, spawned 2 server subprocesses via stdio, ran MCP handshake on each
flight_server.py (FastMCP) Flight MCP server Exposed 4 tools wrapping AeroAPI endpoints. Handled 1 real HTTP call to FlightAware (→ 1.8s)
bigquery_server.py (FastMCP) Catalog MCP server Exposed 2 tools querying BigQuery aero_reference.airports. Handled 3 SQL queries across both turns
mcp-use MCPAgent MCP-to-LangChain bridge + loop Auto-wrapped 6 MCP tool schemas as LangChain StructuredTools; executed all tool calls via stdio; enforced max_steps=24
LangGraph (inside mcp-use) Orchestration loop + routing Ran model→tools→model loop with conditional edges. Exited when tool_calls=[]. Ran ModelCallLimitMiddleware between every node.
ChatOllama (langchain-ollama) LLM bridge Translated LangChain message format to Ollama REST API at localhost:11434; streamed back AIMessages
qwen2.5:3b (Ollama) LLM / reasoning engine Made 4 LLM calls in Turn 1. Read tool schemas from system prompt, chose tools by text matching, wrote final summary. Showed 3b limitations: redundant tool calls, hallucinated delay conclusion, off-rails pivot in Turn 2.
BigQuery (GCP) Airport reference catalog Returned 2-row demo dataset (KIAH + KSFO). KHOU lookup returned 0 rows — catalog gap exposed in Turn 2.
AeroAPI (FlightAware) Live flight data Returned real-time counts for KIAH: 91 departed, 254 enroute, 1521 scheduled arrivals, 1547 scheduled departures.
LangChain
The common language. HumanMessage, ToolMessage, StructuredTool, ChatOllama — all LangChain types. Provides the message format contract every component speaks.
LangGraph
The loop engine. model node → tools node → model node, with state graph, conditional edges, and step counting via middleware. Built on top of LangChain.
mcp-use
The bridge. Converts MCP tool schemas to LangChain StructuredTools automatically, routes tool calls to the correct subprocess over stdio, returns ToolMessages to the graph state.