Problem sketch
StakeholderIs the model doingthe math?BuilderNo. The calculatorstays behind thedoor.StakeholderSo who decideswhat runs?BuilderHost code, afterit checks therequest.

Panel 1: Stakeholder says Is the model doing the math? Panel 2: Builder says No. The calculator stays behind the door. Panel 3: Stakeholder says So who decides what runs? Panel 4: Builder says Host code, after it checks the request.

Practice

One request, three boundaries, and one calculator behind the door

A local Python explainer about how trust and execution responsibility move from direct code to model requests to MCP.

Built to explain a conceptOct 6, 202612 min read

AI-generated, human-reviewed: the first draft was generated by an AI agent; I fact-checked, tested, and edited it. AI use policy

The request looked innocent enough: “What is one hundred twenty-five multiplied by forty-eight?”

That sentence is useful because it is boring. It has one answer, one operation, and no need for a committee. Boring examples are excellent for architecture because they leave fewer places for hand waving to hide.

I built this to make the boundary visible. The same request crosses three different execution shapes: plain code calling a function, model output asking host code to perform a known action, and MCP adding discovery plus protocol-based invocation. The arithmetic stays put because the point is not the math. The point is who gets to decide, who actually runs, what gets trusted, and what still needs to be checked before anything useful happens.

At a glance

The direct call boundary

The model-request boundary

The validation boundary that earns the call

The MCP protocol boundary

Why this exists#

Some demos treat an LLM like a calculator with a poetry habit, which is how a simple arithmetic request becomes an architecture discussion. This one has a narrower reason: explain how trust and execution responsibility move when the same request crosses direct code, model-produced structure, and MCP.

  • The itch: A tool call is not one thing. A caller invoking its own function, a model returning a request for a host to act on, and MCP exposing discoverable tools all move the trust boundary to different places. Pretending they are the same is how demos become vague with confidence.
  • Why I built it: To explain a concept: how trust and execution boundaries change across a plain direct tool call, an LLM-produced request that host code validates and executes, and an MCP protocol boundary where tools can be discovered and invoked.
  • What you get: A local pattern you can copy: keep deterministic behavior in normal Python, treat model output as untrusted data, let host code own authorization, and use MCP for discovery and invocation without handing the toolbox to the model.
  • Who this is for: Python developers, AI-app builders, and MCP-curious engineers who need a crisp way to explain who decides, who executes, what is trusted, and what must be validated. It also helps if you have seen one too many demos where the model is asked to do the part a function could do before coffee.
  • Who can skip it: If your architecture already treats model output like untrusted airport luggage, this may mostly confirm your excellent lack of drama.

With that itch scratched, the rest of the story follows the same request as the boundary moves and the calculator quietly refuses to become a platform.

The plan#

I set out to explain the same concept three ways. First, plain code can call a deterministic function directly when the caller already knows the operation and arguments. Second, a model can turn natural language into a structured request, but the host still decides whether that request is allowed and the host still executes it. Third, MCP can add discovery and standard tool invocation, but the host must still enforce what may run.

One accuracy note before the arrows start multiplying: the repository’s second path does not use a native LLM tool-call envelope. It uses local Ollama to return a JSON intent and parameter request. That still demonstrates the owner’s boundary: the model asks in structured form, while the host decides and runs. The envelope is different. The trust problem is the same, which is rude but convenient.

You will want Python 3.10 or later, a local Ollama setup if you want the live model path, and enough MCP vocabulary to survive the phrase “tool discovery” without requesting a committee. No Azure resources, paid services, cloud identities, or public endpoints are needed.

Here is what I thought the shape should look like before the code had a chance to object.

Three implementation paths that all end in deterministic Python
Figure 1. Structured input, LLM-to-direct execution, and LLM-to-MCP execution all end at the same Python capability

The important part is not that there are three arrows. The important part is that every arrow ends at the same deterministic implementation. If that changed, the demo would prove only that I had found three places to hide bugs.

Boundary Who decides Who executes What is trusted What must be validated
Direct Python call Caller code chooses the calculator and supplies arguments The caller’s process invokes deterministic Python The local call path, after normal input checks Operation allowlist, numeric inputs, and divide-by-zero
Model request to host code Local Ollama proposes structured intent and parameters Host code validates the request and calls the calculator Nothing in the model response until validation passes JSON shape, exact fields, intent, confidence, and types
MCP protocol boundary Host code maps validated intent to an approved MCP tool The MCP client calls the server tool, then Python runs it Discovered tool metadata, never a model-supplied tool name Required tools, host allowlist, arguments, and tool errors

1. The direct call boundary#

I started with the least glamorous version because it has the best manners. The caller’s own code already knows the operation and the two numbers, so it asks for three fields and calls the calculator directly.

I started with the least glamorous version because it has the best manners.

What happened was exactly what should happen: no Ollama, no MCP, no ambiguity, and no invitation for a model to improvise. The caller decides to use the calculator. The caller’s process executes the function. The trusted part is small: local code and explicit arguments after validation. The source tests confirm the prompt count, the Ollama calls: 0 output, and the final Python result: 6000.

Here is the first version doing exactly what it should do, which is almost offensively plain. The caller already knows the operation and operands, so Python calculates 6000 and Ollama gets the afternoon off.

Structured calculator terminal run returning 6000 without Ollama or MCP
Screenshot: real local run; same request, same 6000, with no model call

The change that mattered was not clever. I kept arithmetic in one deterministic function and made everything else prove it deserved to call that function. That proof starts here with an allowlist, numeric checks, and the usual refusal to divide by zero because even demos deserve standards.

Deterministic calculator excerpt
def calculate(operation: str, left: object, right: object) -> dict[str, Any]:
    """Perform one supported arithmetic operation without dynamic evaluation."""
    if not isinstance(operation, str) or operation not in SUPPORTED_OPERATIONS:
        raise ValueError("Unsupported operation.")

Complete snippet: Deterministic capabilities with a strict request contract

2. The model-request boundary#

The next version allowed the user to say the request like a person instead of a form. In the owner’s terms, this is the boundary where the model returns a request for the host to act on. In this codebase, the request is JSON from local Ollama rather than a native tool-call object: intent, confidence, and parameters. Do not answer the request. Do not calculate. Do not wander off and become a spreadsheet.

The next version allowed the user to say the request like a person instead of a form.

The model decides nothing final. It proposes a domain request. The host decides whether the request is acceptable and then invokes deterministic Python. The surprise, if you can call it that, is that the schema helped but did not remove the need for suspicion. The tests reject fenced Markdown, invalid JSON, missing fields, unknown intents, bad confidence values, and wrong parameter types. The model response is still text from a model until Python validates it.

This is the part where the model is useful without being promoted to treasurer. It turns English into a validated calculator request, then host code decides whether the same Python function may run.

Local model terminal run extracting calculator intent before Python returns 6000
Screenshot: real run on a local model; same request, same 6000

The piece that made this path work was the payload. It asks for structure, disables streaming and thinking, sets temperature to 0, and still hands the result to the same request contract before anything can execute.

Ollama payload excerpt
return {
    "model": MODEL_NAME,
    "stream": False,
    "think": False,
    "format": INTENT_RESPONSE_SCHEMA,
    "options": {"temperature": 0},
    "messages": [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": user_input},
    ],
}

Complete snippet: Local Ollama intent extractor with schema output

For the sentence “What is one hundred twenty-five multiplied by forty-eight?”, the model’s job is to propose data with the calculator intent, multiply operation, and the two numeric operands. Then Python parses it, validates it, and executes normal code. If Ollama returns fenced Markdown, missing fields, an unknown intent, or a bonus parameter it found under the sofa, the request fails safely. The validated request can ask for execution. It cannot grant itself permission.

3. The validation boundary that earns the call#

Once the model could translate the sentence, the tempting mistake was to let the translated shape drift into “close enough.” That is where demos start smiling too much. I wanted the contract to behave more like a bouncer than a greeter.

Once the model could translate the sentence, the tempting mistake was to let the translated shape drift into "close enough." That is where demos start smiling too much.

The host validates top-level fields, allowed intents, confidence, exact parameters, numeric types, and calculator operations. It rejects Boolean numbers. It rejects extra parameters. It rejects a model-supplied tool field, which is a polite way of saying the model is not allowed to rummage through the toolbox. This is the responsibility shift that matters: the model can produce a request, but host code owns authorization.

Component flow from user request through local Ollama, host validation, direct or MCP invocation, and deterministic tools
Figure 2. The LLM extracts intent; the host validates and authorizes; deterministic tools execute

The implementation is intentionally fussy at the boundary where fussy is useful.

Request validation excerpt
if intent == "calculator":
    require_exact_fields(parameters, {"operation", "left", "right"}, intent)
    operation = require_string(parameters, "operation", intent)
    if operation not in SUPPORTED_OPERATIONS:
        raise RequestValidationError(
            "Calculator operation must be add, subtract, multiply, or divide."
        )

Complete snippet: Deterministic capabilities with a strict request contract

4. The MCP protocol boundary#

The third path added MCP because more than one host may want to discover and invoke the same capability. MCP adds a protocol boundary: the client can list tools, read schemas, and call a server tool through a standard interface. That is useful. It still does not make arithmetic more interesting, despite the best efforts of architecture diagrams everywhere.

The third path added MCP because more than one host may want to discover and invoke the same capability.

The obstacle was tool choice. If the model can say not only “calculator” but also “call this tool name with these arguments,” the boundary has moved to the wrong place. So the host owns the mapping from domain intent to MCP tool name. The model proposes domain intent. The host chooses the allowed MCP tool. The MCP client discovers the server’s tools and verifies the required tool exists before invocation. The MCP adapter delegates to existing Python.

The third run adds the protocol boundary. That sounds grand, but the output is refreshingly stubborn: the approved MCP tool is calculate, the transport is in memory, and the Python result is still 6000.

Local model and MCP terminal run invoking the approved calculator tool and returning 6000
Screenshot: real run on a local model through MCP; same request, same 6000

Host-owned routing excerpt
MCP_CAPABILITIES = {
    "calculator": "calculate",
    "email_validator": "validate_email",
    "create_support_ticket": "create_support_ticket",
}

Complete snippet: Host-owned routing for direct and MCP execution

The MCP server is thin enough to be almost disappointing, which is the point.

MCP adapter excerpt
@mcp.tool()
def calculate(operation: str, left: int | float, right: int | float) -> dict[str, Any]:
    """Perform basic arithmetic using deterministic Python code."""
    return deterministic_calculate(operation, left, right)

Complete snippet: Thin MCP server adapters over deterministic Python tools

The moment it worked#

The useful proof was not a benchmark chart or a heroic terminal transcript. It was watching the same request land in the same calculator through three different boundaries and still print 6000.

The structured path printed that there were zero Ollama calls. The direct natural-language path printed a validated JSON request and then Python result: 6000. The MCP path printed Approved MCP tool: calculate, Implementation: tools/calculator.py, and the same result. The tests captured those outputs and the source unit run reported 108 successful tests with 5 skipped integration tests.

Sequence diagram showing local Ollama intent extraction, host validation, direct execution, and MCP execution
Figure 3. Direct and MCP execution differ at the invocation boundary; both end in deterministic Python

That was the satisfying part. The implementation never changed. The decision point, execution path, and validation duties changed.

What I would tell you before you try this#

  • Start with one request and one deterministic implementation. If the no-model path does not work, MCP will not make it wiser.
  • Make the zero-model path work first. It gives you a control case and removes the fog.
  • Use the model only to produce a structured request. Interpretation is a job; execution is a different job.
  • Treat model output as untrusted input until validation passes. Friendly JSON is still input from outside the trust boundary.
  • Reject extra fields instead of ignoring them. Unknown fields are where surprises begin rehearsing.
  • Keep tool-name mapping in host code, not in the prompt. Prompts suggest; hosts authorize.
  • Make MCP adapters thin enough to be boring. Boring adapters are easier to test and harder to misunderstand.
  • Test that invalid model output never reaches execution. The failure path is part of the feature.
  • Measure local boundary costs carefully. One laptop is useful evidence, not a benchmark paper.
  • Keep the demo humble. The software industry has enough confident calculators already.

Put it together#

If you want to build your own version, keep the epilogue as small as the demo. This solution runs locally. It does not need Azure, paid services, cloud identities, or public endpoints. The cloud bill is therefore $0.00. If you use a local Ollama model, there is no model token charge either. Your laptop still uses electricity, but that invoice arrives through more traditional channels.

Validated local versions for this draft:

Tool or package Version or setting Purpose
Python 3.14.5 used for validation; 3.10 plus Runs the snippets
mcp package 2.0.0 Provides the MCP server adapter
Ollama 0.32.5 Local intent extraction
Model qwen3:8b Local JSON intent proposal

Minimum resources and monthly cost:

Resource or SKU Minimum needed for this demo Estimated monthly cost
Azure resources None $0.00
Hosted model tokens None with local Ollama $0.00
Local Ollama and Python Owner machine No cloud charge

Use the snippets in this order:

  1. Save the deterministic capabilities as deterministic_capabilities.py.
  2. Save the Ollama intent extractor as ollama_intent_extractor.py.
  3. Save the MCP server adapter as mcp_tool_server.py.
  4. Save the host routing module as host_authorized_routing.py.
  5. Run a structured calculator request directly and confirm 6000.
  6. Run an Ollama-backed intent extraction and confirm the same request becomes the same validated JSON shape.
  7. Run the host routing path with an MCP client and confirm the approved tool is calculate.

The demo proves a small point with sharp edges: every boundary needs a named owner. Direct code can decide and execute for itself. Model output can ask, but host code decides and runs. MCP can discover and invoke tools through a protocol, but the host still guards the door. Once that clicked, MCP stopped looking like magic and started looking like a useful door with a very ordinary calculator behind it.

Remember these

The direct call boundary
The model-request boundary
The validation boundary that earns the call
The MCP protocol boundary

From this post

Snippet

Deterministic capabilities with a strict request contract

A complete Python module that validates model output as data, then runs approved normal code for calculator, email syntax, and mock support-ticket requests.

Compiled with python3 -m py_compile and run with a local self-test in Python 3.14.

Snippet

Host-owned routing for direct and MCP execution

A complete Python unit that maps validated domain intent to approved direct functions or approved MCP tool names.

Compiled with python3 -m py_compile and run with fake MCP results plus mcp 2.0.0 over stdio in Python 3.14.

Snippet

Thin MCP server adapters over deterministic Python tools

A complete MCP server module that exposes approved deterministic capabilities without moving business logic into the MCP layer.

Compiled with python3 -m py_compile and imported with mcp 2.0.0 in Python 3.14, verifying the MCPServer tool registry.

Snippet

Local Ollama intent extractor with schema output

A complete Python unit that asks local Ollama for one JSON intent object, then validates the result before execution can happen.

Compiled with python3 -m py_compile and run with a mocked Ollama response in Python 3.14.