Skip to content

MCP vs. the Chat Shell — two ways to drive the same engines

In one sentence: the engineering maths is identical either way; what changes is which AI does the thinking and where the answers land.

Everything on this page was measured against commit 0469fac10 on 2026-09-11. The live examples are real captured output, not illustrations — the script that produced them is listed at the bottom so you can re-run it.


The 30-second version

Chat shell (/app/agent) MCP server
Who decides which tool to run Our backend, driving Gemini Your AI (Claude Code, Claude Desktop, Cursor…)
Who writes the final sentences Our backend Your AI
Tools reachable 34 8
What you see 32 kinds of visual card JSON your client renders however it likes
Remembers earlier calls Yes (a conversation) Yes (one connection = one session)
Checks the numbers you send in Yes Yes — same code
Checks the sentences that come out Yes No — it can't. See Grounding
Needs a browser Yes No
Login Yes stdio: local. /mcp: yes (Supabase token)

Part 1 — What actually happens

The chat shell: we run the whole show

You type a sentence. Our backend takes over and runs a loop: call Gemini (gemini-2.5-flash-lite, services/agent_loop_sse.py:291), let it pick a tool, run the tool, hand the result back, repeat. The loop's limits are real numbers you can look up in services/agent_loop/_types.py:

Limit Value Line
Steps per turn 16 _types.py:19
Seconds per tool 30 _types.py:20
Seconds per turn 180 _types.py:21

As it works it streams little labelled events to your browser — thinking, tool_call, tool_result, text, done, and error (agent_loop/loop.py:148, _emit.py:27,62,84,85, _model_call.py:30). Each result with a resultType gets drawn as a panel: 32 of them are registered in frontend/src/components/Chat/actionCards/registry.tsx — bom_preview, thermal_result, block_diagram, stackup_inline, and so on.

The picture: a restaurant. You order in plain English, and the kitchen, the waiter, and the person deciding what to cook are all ours.

MCP: we are only the toolbox

MCP ("Model Context Protocol") is a standard way for an AI app to discover and call outside tools. Over MCP, your AI does the deciding. It connects, sees fourteen tools, picks one, and writes the prose itself. We compute and hand back structured data.

The picture: the same kitchen — but you brought your own chef, and they are cooking in your house.

The wrapper doing this is deliberately thin. mcp_server/dispatch.py says so in its first line: "the thinnest possible layer over the loop's" — it owns a name check, the provenance field, the context, the same 30-second per-tool timeout, the ledger append, and the wire shape. Every figure comes from services.tool_dispatcher.dispatch_tool_call — the same executor the chat loop and the Guided wizard call.


Part 2 — Worked examples (real output)

Example 1: ask cold, get a named refusal — never an invented number

Fresh session, ask for a thermal analysis with no inputs at all:

session = McpSession()
result, is_error = await call_tool(session, "run_thermal_analysis", {})
{
  "ok": false,
  "error": "needs_power_input",
  "detail": "No dissipated power is available to analyze. Provide analysis_input.power_w (watts), or design the RF chain first so the dissipated power can be derived from it. Do not invent a value."
}

That is the whole product philosophy in one reply. It does not guess a wattage. It says what is missing, how to supply it, and tells the calling model not to make one up. The same refusal shape reaches both paths, because it comes from the tool, not from the transport.

Note also isError: True — but this arrives as a tool result, not a protocol error. dispatch.py:11-15 explains why: the detail string is written for a model to act on, and collapsing it into a JSON-RPC code like -32602 "throws away the only useful part."

Example 2: chaining — the second call needs no figures

Design a transmitter, then ask for thermals without re-sending anything:

s = McpSession()
await call_tool(s, "design_rf_chain",
                {"frequency": 2.4, "target_output_power": 30},
                user_request="design a 2.4 GHz transmitter at 30 dBm")

await call_tool(s, "run_thermal_analysis", {})      # empty args on purpose

The second call succeeds: {"ok": true, "resultType": "thermal_result"}, and its summary carries power_w, power_w_source, power_w_basis and device_tj_max_c. It found the dissipated power in the session's ledger — the running list of results from earlier calls in this connection.

This is worth flagging because our own public API page says the opposite. docs/llm.md rule 4 tells every agent "Nothing persists. Every call is independent. There is no session." That is true of the plain HTTPS API and exactly wrong for MCP. So the MCP server corrects it out loud at connection time, before any tool is chosen (mcp_server/server.py:51-64):

"This session REMEMBERS the results of earlier calls in it, so design → thermal → PDN → compliance chain together without you re-sending figures. […] Only computed results are remembered, never the arguments you sent."

Example 3: the stackup it solved for you, disclosed

The design call above came back with an extra key we added:

"autoSolvedStackup": {
  "reason": "No stackup had been solved in this session, and run_pdn_analysis and run_compliance_analysis cannot run without one. A stackup was solved automatically so they can. Tell the user this happened — they did not ask for a stackup, and its layer count is a server default.",
  "layerCount": 4,
  "layerCountSource": "server default — the caller stated none",
  "frequencyGhz": 2.4,
  "frequencyGhzSource": "the designed chain's own params.frequency"
}

Two things to notice. It instructs the calling model to tell the user, because over MCP there is no card stream to show it in. And every number says where it came from — the 4 layers are labelled a server default, the 2.4 GHz is traced to the chain's own parameters. The chat shell runs the same hook after a BOM lands; dispatch.py:86-95 is the MCP half.

Example 4: the provenance label

Every tool takes an optional user_request — your end user's own words, verbatim. It changes how figures you pass are treated:

session.grounding_for("9.4 GHz, 43 dBm")   # supplied
session.grounding_for(None)                # omitted
{"mode": "user_stated",
 "note": "Numeric arguments were checked against the end user's own words, which you supplied in user_request — the same grounding the web app applies."}

{"mode": "caller_asserted",
 "note": "No user_request was supplied, so any figure you passed was accepted on your assertion rather than traced to a user statement. Figures the engines computed are unaffected."}

It is optional by decision (tools.py:53-57): requiring it "would make the server annoying and invite clients to fabricate the field." The bet is that a client would rather be labelled honestly than interrogated.

Example 5: can a made-up number become "evidence"? (the interesting one)

The rule is "the corpora grow from results only, never arguments." Here is why that is hard. Tools echo their inputs back into their results on purpose, so a model can quote them without tripping the output checker. A naive fold of the whole result therefore swallows the arguments too — and a number the model invented becomes evidence for the next call's anti-invention floor.

Measured: pass an oddly specific 27.77 dBm target with no user_request, let the design succeed, then inspect the session's number pools.

Check Result Means
27.77 (the argument) in the pool False the echo was stripped
28.32 (what the engine computed) in the pool True real results still count
Pool sizes after one design 505 numbers, 116 in the dBm bucket the fold is doing real work, not sitting empty

The control matters as much as the finding: if the pool were simply empty, the first row would read "False" for a boring reason. It isn't — 505 figures landed; just not that one. This channel was closed on 2026-09-11 (backend/tests/test_mcp_context_contract.py:112), and the fix, agent_loop/corpus_fold.py, is shared by MCP and chat.

Two honest limits, both documented in that file rather than hidden:

  • Roundings escape. The match is on the exact value, so a tool that rounds or re-units its echo is not caught (corpus_fold.py:34-43). In the run above a 28.0 does sit in the dBm bucket. I did not trace which leaf produced it, so I won't claim it is the echo — but it is exactly the shape the caveat describes.
  • It can over-exclude. Matching a bare number across quantities means a design that genuinely computes a value equal to one of its own arguments loses it from that one fold — a 9.4 GHz design that also computes a 9.4 dB noise figure files no 9.4 that turn (corpus_fold.py:50-59). The failure is deliberately conservative: a real figure gets refused, never a fabricated one admitted.

And a bigger caveat, measured in the same work: closing this did not restore the floors. In the dBm bucket, 99 entries give a 75.31% false-accept rate; with the user's words alone it is 2.47%; with perfect echo removal, 72.84%. 98 of those 99 entries are legitimately computed results. The floor is defeated by ordinary saturation, not by laundering.

Bonus: what an impossible ask looks like

Asking for 77.123 dBm at 2.4 GHz drives the PA requirement to 80.8 dBm. The log ends:

WARNING  No PA components found for 2.40 GHz @ 80.8 dBm
WARNING  Best attempt has no PA — reporting the failure instead of the chain

It reports the failure. It does not ship a chain with an imaginary amplifier.


Part 3 — The differences that matter

Fourteen tools, not all of them

mcp_server/tools.py (TIER1_TOOL_NAMES) exposes thirteen engine tools plus check_grounding — fourteen since 2026-10-08 (issue 176 added run_ota_link, run_radar_performance, run_planar_filter, design_patch_antenna and run_glass_weave_analysis). The current list, with what each one does, is the table in backend/mcp_server/README.md. When this section was written there were eight:

Tool What it designs / computes
design_rf_chain a transmitter — driver, PA, filtering, connector, drive matching, power tree
design_rf_receiver a receiver — limiter through IF
design_tr_module a T/R module — both chains plus the shared antenna front end
run_link_budget gain / noise figure / OIP3 / P1dB headroom down a chain
run_stackup_solver the PCB layer stack and its trace impedances
run_thermal_analysis junction temperature, per-layer Rth, via arrays
run_pdn_analysis power-delivery network — plane impedance vs. the rail target
run_compliance_analysis IPC rules plus FCC / ETSI conducted-power limits

design_rf_chain is the transmitter. The generic name is historical — it was coined when a transmit chain was the only chain the product designed, and the receiver later took the explicit name. Read the list before assuming something is missing.

Why not all of them? Cost. An MCP client loads every tool's schema into every conversation. Measured when there were eight (not re-measured for fourteen): 15,775 bytes of input schemas across the eight (compact JSON, schemas only — the mcp_server/README.md quotes 18,050 B for the fuller declarations, and ~55 KB / ~13.8k tokens if all 37 were exposed). The curation is a budget, not a limitation.

Left out on purpose (counts from the same time): the 11 state-projection and 2 agent-only tools (there is no wizard on the other end), generate_export / export_bom (they need a verified user id and belong behind write auth), and run_phase_noise_budget (withdrawn by its own flag). Guarded by backend/tests/test_mcp_tool_set.py, which asserts the set is exactly the curated thirteen (test_curated_set_is_the_tier1_thirteen), that every exposed tool is dispatchable, and that no state-projection or agent-only tool leaks in.

What you see

Chat draws pictures — block diagrams, BOM tables, the stackup view, 32 card types in all. MCP hands your client JSON. If you want to look at a stackup, use the browser.

The difference that matters most: grounding

Both paths check the numbers coming in, using the same code.

Only the chat path checks the sentences coming out. The output guard runs inside the loop (agent_loop/loop.py, _text_turn.py, _guardrail.py), compares the prose against the tool results, and a guard-passed answer earns the line "✓ Figures checked against 3 tool results" under the bubble (frontend/src/components/Chat/MessageBubble.tsx).

Searching backend/mcp_server/ for the output guard returns zero matches — and that is not an oversight. We never write the final sentences over MCP. Your client's model does, outside our reach.

So the honest promise over MCP is narrower: the numbers we compute are real and traceable — not every sentence you read was checked.

Memory, and the trap inside it

The ledger holds results only, never your arguments (session.py:14-19). That is not our invention: replaying model-authored arguments is how a hallucinated figure from a past turn would ground itself.

One subtlety with real teeth (session.py:3-12): five anti-invention floors skip themselves when their corpus key is absent, and enforce when it is present-but-empty. Measured on one receiver call: cascaded noise figure 1.6216 dB from the caller's own numbers with an empty context, versus 1.7798 dB from real catalogue parts with the rejected figures disclosed. Same tool, same arguments, two different physics — selected by a missing dictionary key. So the MCP session always supplies those keys.

Deliberately not supplied: user id, project id, conversation id (so generate_export fails closed — the point), and the application/region hints the chat path infers from your prose. Over MCP the tool's own application argument is your channel; inferring a second one from user_request would let a stray word redirect a selection you never asked for (session.py:131-142).


Part 4 — Which should I use?

Use the chat shell when you want the visuals, the exports, layer editing, or the full 34 tools — and don't want to install anything.

Use MCP when you already work inside Claude Code or Claude Desktop, want the design to feed into other work, and would rather your own AI reason about the results. No browser.

The choice is low-stakes, because both call the same engines — enforced, not aspirational: every MCP-exposed tool is classified SHARED_ENGINE in backend/tools/_parity.py, and CI fails if a registered tool skips that classification.


Part 5 — How to connect

stdio (local, no login)

$env:FF_MCP_SERVER = "true"
backend\venv\Scripts\python.exe -m backend.mcp_server.stdio    # from the repo root

JSON-RPC on stdin/stdout, logs to stderr. The flag defaults off; without it the process exits 1 naming the flag. cwd must be the repo root — the module path is backend.mcp_server.stdio. One process = one client = one ledger.

{ "mcpServers": { "magicon": {
    "command": "C:/.../pcb_stackup_generator/backend/venv/Scripts/python.exe",
    "args": ["-m", "backend.mcp_server.stdio"],
    "cwd": "C:/.../pcb_stackup_generator",
    "env": { "FF_MCP_SERVER": "true" }
} } }

/mcp over HTTPS (hosted, login required)

Mounted at /mcp (http.py:62). It is not in middleware.prefix_public, and that is the entire difference between "deployed" and "announced": anything under a public prefix skips authentication, so listing it there would make it anonymously reachable by anyone who guesses the path. Left off, the app's normal auth middleware gates it behind a Supabase token — reachable by an account holder, invisible to everyone else (http.py:7-16).

Each connection gets its own ledger. A shared one would let caller A's computed results ground caller B's calls and satisfy B's floors with numbers B never saw — "the worst shape a grounding bug can take" (server.py:74-79).


What is not different

  • The engines. Same selection, same solver, same thermal, same PDN, same compliance.
  • The refusals. needs_power_input and friends come from the tool.
  • The shape sent to a model: both use condense_for_model, so an MCP client and the chat model see one projection rather than two that drift apart.
  • The per-tool 30-second timeout.

Re-measure this page

Nothing here should be trusted because it is written down. Counts and behaviour:

# the exposed set, the schema cost, refusals, chaining, grounding labels
backend/venv/Scripts/python.exe -m pytest backend/tests/test_mcp_tool_set.py \
    backend/tests/test_mcp_context_contract.py backend/tests/test_mcp_session_ledger.py \
    backend/tests/test_mcp_stdio_transport.py backend/tests/test_mcp_public_contract.py -q

# the engines are shared, not reimplemented
backend/venv/Scripts/python.exe -m pytest backend/tests/test_agent_phased_parity.py -q

The live examples above came from a short script that builds an McpSession, calls call_tool / mcp_payload directly, and prints the results — the same entry points mcp_server/stdio.py uses. Re-creating it takes about 30 lines against backend/mcp_server/dispatch.py and session.py.

See also

  • backend/mcp_server/README.md — internal setup notes, and the authority on the exposed set
  • docs/agent-skills/pick-the-engine.md — choosing between the four design engines over the public HTTPS API (with per-endpoint rate limits)
  • docs/agent-skills/read-a-result.md, docs/agent-skills/design-a-module.md
  • docs/guides/AI_NATIVE_SHELL.md — the chat shell in depth
  • docs/planning/agent_accessibility/01-mcp-server.md — the implementation plan (a plan, not a description of the shipped code)