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:
{
"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:
{"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 a28.0does 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_inputand 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 setdocs/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.mddocs/guides/AI_NATIVE_SHELL.md— the chat shell in depthdocs/planning/agent_accessibility/01-mcp-server.md— the implementation plan (a plan, not a description of the shipped code)