In this issue (6 sections)
The parameter was called user_token: str, and it looked completely reasonable.
Recall’s first MCP server was being built on a Tuesday. The design question was mundane: how does the server know which user is making the request? The obvious answer — add user_token as a parameter to the store_memory tool — took about forty seconds to implement. The LLM receives the tool schema, sees the user_token field, passes it through on every call, and the server validates it. Clean. Obvious. Shipped.
The problems didn’t surface immediately. They surfaced in stages.
First: a trace log from Claude Desktop showed the full tool schema — user_id, user_token, text, topic — embedded in the conversation context. The token wasn’t encrypted in logs. It was there, verbatim, in plain text, in the agent’s prompt history. Every session where Recall ran, the token was being written into the conversation record.
Second: under a test that replayed a long session, the model forgot to pass user_token three times. Not occasionally — three times in a single session. The model judged that the parameter was “probably not needed for this particular call.” Silent auth failure. The tool ran without a valid user context and returned a 200 because the fallback defaulted to an empty string rather than raising.
Third: a junior engineer added a new tool to the server, looked at the existing schema, and copied the pattern. Now two tools had user_token as a parameter. The surface area had doubled.
The root cause of all three problems was the same: auth had been placed where the LLM could see it, manage it, and forget it. Auth was handed to the wrong controller.
Auth in a tool parameter means the LLM is your auth manager. It isn’t.
The fix took two hours. Understanding why it was wrong took longer — because the fix only makes sense once you understand the distinction the MCP spec makes that most tutorials gloss over. Before the code, we need the mental model — because the same mistake shows up in three different shapes once you know what to look for.
Mental Model: Three Transports, Three Primitives, One Distinction
MCP is three transports, three primitives, and one distinction. Most engineers learn the transports and the primitives. The distinction is the one that changes what you build.
The Three Transports
MCP has shipped three spec versions, and each one introduced a new transport:
- MCP v1 (2024-11-05): stdio only
- MCP v2 (2025-03-26): SSE added
- MCP v3 (2025-11-25): Streamable HTTP added, SSE deprecated
┌──────────────────┬──────────────────────┬───────────────────────┬────────────────────────────┐
│ │ stdio │ SSE (deprecated) │ Streamable HTTP │
├──────────────────┼──────────────────────┼───────────────────────┼────────────────────────────┤
│ MCP version │ v1 (2024-11-05) │ v2 (2025-03-26) │ v3 (2025-11-25) │
├──────────────────┼──────────────────────┼───────────────────────┼────────────────────────────┤
│ Architecture │ stdin/stdout pipes │ GET /sse + │ Single POST endpoint │
│ │ │ POST /messages │ Optional inline SSE │
├──────────────────┼──────────────────────┼───────────────────────┼────────────────────────────┤
│ Concurrency │ One process pair │ Multiple clients, │ Multiple clients, │
│ │ No multiplexing │ stateful connection │ stateless, load-balanceable│
├──────────────────┼──────────────────────┼───────────────────────┼────────────────────────────┤
│ Auth │ None (local only) │ Possible, complex │ Bearer token in header │
├──────────────────┼──────────────────────┼───────────────────────┼────────────────────────────┤
│ Horizontal │ No │ No (connection maps │ Yes │
│ scaling │ │ require sticky │ │
│ │ │ sessions) │ │
├──────────────────┼──────────────────────┼───────────────────────┼────────────────────────────┤
│ Use when │ Claude Desktop, │ Legacy clients only │ All production servers. │
│ │ local dev, │ (backward compat) │ Any multi-user scenario. │
│ │ single process pair │ │ │
└──────────────────┴──────────────────────┴───────────────────────┴────────────────────────────┘
Three transports for three contexts. SSE is backward-compatible but deprecated.
stdio is structurally incapable of handling multiple concurrent clients. It’s a single process pair — one stdin, one stdout, one conversation at a time. This isn’t a configuration limit; it’s architectural. Running stdio in a server that handles concurrent users isn’t a bad practice — it doesn’t work at all.
SSE was deprecated for a specific reason worth understanding if you’re already using it. The SSE architecture required two endpoints: a GET /sse for the downstream event stream and a POST /messages for upstream requests. Maintaining the mapping between those two connections required stateful connection tracking, which made horizontal scaling nearly impossible without sticky sessions. If your load balancer sent the GET and the POST to different server instances — the common default — the server had no way to correlate them. Streamable HTTP solved this by collapsing to a single POST endpoint with optional inline SSE for streaming responses. Stateless, load-balanceable, and simpler.
If you’re running SSE in production today, it continues to work while clients maintain backward compatibility. But the scaling ceiling is real, and new servers should default to Streamable HTTP.
The Three Primitives
┌──────────────┬───────────────────────┬─────────────────────────────┬───────────────────────────┐
│ Primitive │ Who controls it │ Who decides when │ Where auth/secrets go │
├──────────────┼───────────────────────┼─────────────────────────────┼───────────────────────────┤
│ TOOL │ Model-controlled │ LLM reads schema, decides │ Never in schema params. │
│ │ │ when to invoke + what │ Auth belongs in │
│ │ │ parameters to pass │ transport/middleware. │
├──────────────┼───────────────────────┼─────────────────────────────┼───────────────────────────┤
│ RESOURCE │ App-controlled │ Application injects on │ App owns injection. │
│ │ │ each request — LLM sees │ Secrets injected by app, │
│ │ │ content, cannot invoke │ never passed via LLM. │
├──────────────┼───────────────────────┼─────────────────────────────┼───────────────────────────┤
│ PROMPT │ User-controlled │ Human triggers via slash │ User identity via │
│ │ │ command or UI menu │ session/client context. │
└──────────────┴───────────────────────┴─────────────────────────────┴───────────────────────────┘
Trust boundary rule:
Transport layer → secrets (tokens, keys, credentials)
App layer → session context, user profile, reference data (Resources)
Model layer → what the LLM needs to ACT on (Tool parameters)
Crossing this boundary in the wrong direction is how secrets leak.
Who controls each primitive determines where your auth and secrets belong.
Each primitive has a distinct controller. Tools are model-controlled: the LLM reads the schema, decides when to invoke the tool, and provides the parameters. The LLM is the decision-maker. Resources are app-controlled: the application decides what context to inject, and the LLM sees the content when it’s included in the request context — but cannot independently invoke a Resource the way it invokes a Tool. Prompts are user-controlled: slash commands and menu items in client UIs, triggered by the human directly.
The Distinction That Changes Everything
The ReAct paper (Yao et al., 2022) demonstrated that LLMs could use tools reliably by interleaving reasoning and action steps. What the paper assumed — that tool calls are initiated by the model when it decides to act — is exactly the model-controlled primitive in MCP’s terminology. But that same paper took something else as given: that context is available to the model without the model having to fetch it. The model-controlled/app-controlled axis formalizes this split. Modern agent architectures depend on it. The LLM acts through Tools; the application provides context through Resources. Mixing these up means either the LLM is doing work it shouldn’t (fetching its own context) or carrying secrets it shouldn’t.
The Recall token mistake was a category error: putting a secret — something that belongs at the transport layer — into a Tool parameter, which is part of the model layer. It crossed the trust boundary in the wrong direction.
The correct location for every piece of context follows directly from who should control it:
- Anything the LLM should act on (data it needs to call a tool correctly) → Tool parameter. Fine.
- Anything the LLM should have access to but not manage (user profile, session state, auth identity) → Resource, injected by the app.
- Anything that is a secret (tokens, keys, credentials) → middleware at the transport layer. It never touches the LLM layer at all.
The spec’s model-controlled/app-controlled axis isn’t a labeling choice. It’s a trust boundary. Crossing it in the wrong direction is how secrets leak.
Here’s what the correct architecture looks like in code — and what changed in Recall’s server once we moved auth out of the tool.
Implementation: The Correct Auth Pattern
The pattern below is Recall’s FastMCP server scaffold: Streamable HTTP transport, bearer token middleware, and context variable injection. It’s the starting point for any MCP server that needs auth. The middleware is a Starlette BaseHTTPMiddleware subclass — FastMCP’s streamable_http_app() returns a standard Starlette ASGI app.
The middleware intercepts every request before any tool runs. The token never appears in any tool’s parameter schema.
import hashlib
from contextvars import ContextVar
from typing import Any
from fastmcp import FastMCP
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
mcp = FastMCP("recall")
# Request-scoped user identity — async-safe, never shared between requests
user_id_ctx: ContextVar[str] = ContextVar("user_id", default="")
class BearerAuthMiddleware(BaseHTTPMiddleware):
"""Validates Bearer token before any tool code runs. Token never touches a tool schema."""
async def dispatch(self, request: Request, call_next: Any) -> Any:
auth = request.headers.get("Authorization", "")
if not auth:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
# Handles both "Bearer <token>" and bare "<token>"
token = auth.removeprefix("Bearer ").strip()
user_id = await validate_token(token) # your DB / cache lookup
if not user_id:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
# Inject identity for tools to read — without a parameter in their schema
user_id_ctx.set(user_id)
return await call_next(request)
async def validate_token(token: str) -> str | None:
"""Return user_id if token is valid, None otherwise. Swap in your auth logic."""
if not token:
return None
import aiosqlite
async with aiosqlite.connect("recall.db") as db:
row = await db.execute_fetchall(
"SELECT user_id FROM api_tokens WHERE token_hash = ? AND revoked = 0",
(hashlib.sha256(token.encode()).hexdigest(),),
)
return row[0][0] if row else None
@mcp.tool()
async def store_memory(text: str, topic: str) -> dict:
"""Store a memory for the current user. Returns immediately — extraction runs async."""
user_id = user_id_ctx.get() # injected by middleware, not a parameter
# ... store logic
return {"status": "ok", "data": {"queued": True}, "error": None}
@mcp.tool()
async def search_memories(query: str, limit: int = 20) -> dict:
"""Search the user's memories using hybrid retrieval (~200-500ms)."""
user_id = user_id_ctx.get()
# ... search logic
return {"status": "ok", "data": {"results": [], "total": 0}, "error": None}
if __name__ == "__main__":
import uvicorn
app = mcp.streamable_http_app()
app.add_middleware(BearerAuthMiddleware) # auth wraps everything
uvicorn.run(app, host="0.0.0.0", port=8000)
Three non-obvious decisions in this scaffold are worth walking through.
Why ContextVar and not a module-level global. A module-level user_id = "" is shared state across all concurrent requests in the same process. Under asyncio, two requests running concurrently can interleave: request A sets user_id = "user-123", request B sets user_id = "user-456", and when request A’s tool handler reads user_id it gets "user-456". This isn’t a theoretical race — it’s the default behavior of a global in an async application with any concurrency at all. ContextVar creates a separate binding per asyncio task. Each request gets its own slot. user_id_ctx.get() inside a tool always returns the value set by the middleware for that specific request, regardless of what’s happening in parallel tasks.
The removeprefix pattern. auth.removeprefix("Bearer ").strip() handles both Authorization: Bearer sk-abc123 and bare Authorization: sk-abc123 without branching or regex. It’s a Python 3.9+ string method that removes the prefix if present and returns the string unchanged if the prefix is absent. The .strip() handles any whitespace. The result is a clean token string either way. Two concurrent client implementations that format the header differently both work.
What happens on auth failure. The middleware returns JSONResponse({"error": "Unauthorized"}, status_code=401) before calling call_next(request). The tool function never runs. The LLM receives a well-formed JSON error — not an exception traceback, not an empty response — that it can surface to the user or handle in its own error-recovery logic. The 401 also prevents the request from reaching any part of the tool layer, which means there’s no partial execution to reason about.
Here is the structural difference between the old design and the new one:
BEFORE (broken) AFTER (correct)
─────────────── ───────────────
Agent Agent
│ │
│ call store_memory( │ call store_memory(
│ user_id="u1", │ text="...",
│ user_token="sk-abc123", ←────── │ topic="food"
│ text="..." │ )
│ ) │ + Authorization: Bearer sk-abc123
│ │ (in HTTP header, not schema)
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ FastMCP tool schema: │ │ BearerAuthMiddleware: │
│ │ │ extracts token │
│ user_token: string ←── │ visible │ validates with DB │
│ user_id: string │ to LLM │ injects user_id via │
│ text: string │ │ ContextVar │
│ topic: string │ └──────────────────────────┘
└──────────────────────────┘ │
▼
Problems with BEFORE: ┌──────────────────────────┐
• Token in LLM conversation logs │ FastMCP tool schema: │
• LLM sometimes drops it silently │ │
• Token embedded in every agent prompt │ text: string │
that uses Recall │ topic: string │
• LLM is your auth manager. It isn't. │ │
│ (no token in schema) │
└──────────────────────────┘
Before: token visible in LLM context. After: token validated before the LLM layer.
The before state isn’t just a security risk — it’s a reliability risk. The LLM occasionally drops parameters it judges as “probably not needed.” A token passed as a parameter can disappear silently. Middleware auth cannot be dropped. It runs on every request, unconditionally, before the tool handler is ever reached.
Resource vs. Tool: The Same Data, Two Controllers
The same data can be a Tool or a Resource. Which one you choose determines who controls it — and that has direct consequences for latency, token cost, and reliability.
# ── AS A TOOL (model-controlled) ─────────────────────────────────────────────
# LLM decides when to call it. LLM must remember to call it. Burns tokens each time.
@mcp.tool()
async def get_user_profile(user_id: str) -> dict:
"""Get the profile and preferences for a user."""
profile = await db.get_profile(user_id)
return {"status": "ok", "data": profile.to_dict(), "error": None}
# Signal that this is a Resource in disguise:
# • Called on >80% of conversation turns
# • Result is static within a session
# • LLM often calls it first before doing anything else
# • Every call is a round-trip that burns tokens and latency
# ── AS A RESOURCE (app-controlled) ───────────────────────────────────────────
# App injects it before the LLM sees anything. Always available. Zero tool calls.
@mcp.resource("user://profile")
async def user_profile_resource() -> str:
"""User profile and preferences — injected by app, not called by LLM."""
user_id = user_id_ctx.get() # available from auth middleware
profile = await db.get_profile(user_id)
return profile.to_json()
# The app includes this resource in every request context:
# resource_result = await mcp.read_resource("user://profile")
# # LLM receives the profile automatically — no tool call needed
# ── RULE ─────────────────────────────────────────────────────────────────────
# Tool: LLM drives the action. Data changes per call. Side effects.
# Resource: App controls injection. Read-only. Static within session.
#
# If a tool is called >80% of turns and always returns the same value
# for that session — it is a Resource in disguise. Move it.
A get_user_profile tool implemented as model-controlled will be called on nearly every turn. The LLM “needs the data” before it can respond helpfully, so it calls the tool first. Each call is a round-trip to the server, time spent by the model processing the tool call schema and the response, and latency added before any user-facing work begins. Under scale, that cost grows proportionally with session count. Any data that doesn’t change within a session and doesn’t have side effects belongs as a Resource. The observable signal is tool call frequency: any tool called more than 80% of turns is a Resource in disguise. Move it.
These are the architectural decisions that prevent failures. Here are the failures you’ll hit anyway.
Failure Modes
Three failure modes — named specifically enough that you’ll recognize them when they appear.
AUTH LEAKAGE
What happens: Token passed as tool parameter appears verbatim in LLM
conversation context. It logs to agent traces, embeds in
every prompt that includes the tool schema, and may surface
in debugging output. In Recall: 'user_token' field visible
in Claude Desktop's tool schema view for every session.
Root cause: Auth designed for the tool layer instead of the transport
layer. The engineer put auth where the schema lives, not
where credentials belong.
How to detect: Audit all tool parameter names. If any contain 'token',
'key', 'secret', 'auth', or 'credential' — this failure
mode is present. The audit takes 2 minutes.
Fix: Remove auth parameters from all tool schemas. Add a
BaseHTTPMiddleware subclass. Validate in dispatch(),
inject identity via ContextVar. The tool sees user_id,
not the credential that proved it.
WRONG TRANSPORT IN PRODUCTION
What happens: stdio transport deployed for a server that needs multiple
concurrent clients. Second connection blocks waiting for
the process pair to free. Under load, requests queue.
At 10 concurrent users: complete service failure.
Root cause: stdio is a single-process-pair transport by design.
It has no multiplexing path. This is structural,
not configurable.
How to detect: Run two simultaneous client connections to the server.
One will block immediately. This is a one-minute test
that most teams skip until production.
Fix: Streamable HTTP for any server with more than one client.
stdio is correct for exactly two cases: Claude Desktop
plugins and local development where the process pair
constraint is acceptable.
TOOL-RESOURCE CONFUSION
What happens: Static reference data (user profile, app config, session
preferences) implemented as a Tool. LLM calls it on nearly
every turn — it "needs the data" before it can help. Token
consumption rises. Latency spikes per turn. Under scale,
cost grows proportionally.
Root cause: Resource primitives not used for app-controlled, read-only
context. The data is the same for every call in a session
but was built as a Tool because that's the familiar pattern.
How to detect: Tool call frequency metrics. Any tool called >80% of
conversation turns is a strong candidate. Observable as
latency spikes at turn start — before any user-facing
work begins.
Fix: Implement as an MCP Resource. App injects it per-request
using @mcp.resource() and includes it in context. LLM
receives the data without invoking a tool. Zero round-trips.
Zero token burn for schema parsing.
Knowing the failure modes is half the answer. The other half is knowing which approach to use when — before you build.
Decision Guide
Two decisions every MCP server forces you to make upfront: which transport, and which primitive for which data.
Use stdio when: Use Streamable HTTP when:
────────────────────────── ──────────────────────────────────
Claude Desktop plugin Any production server
Local dev, single process pair Multi-client / multi-user
No auth required Auth + scaling required
Testing in isolation Horizontal scaling needed
One consumer, one server Any real deployment
Default to Streamable HTTP. Use stdio only when you’re building a local tool and the process pair constraint is acceptable.
Use Tool when: Use Resource when:
────────────────────────── ──────────────────────────────────
LLM drives the action App controls injection
Side effects (writes) Read-only / reference data
User-initiated operations Per-request context
Data that changes per call Static within session
Different result per invocation Called >80% of turns with same result
LLM needs to decide when to call Data should always be available
If the data is the same for every call in a session and has no side effects — it’s a Resource. Everything else is probably a Tool.
One note on Prompts: they’re for pre-built user-facing workflows — slash commands in Claude Desktop, menu items in client UIs. They’re relatively rare in production server code. Most of what you build will be Tools and Resources. Prompts become relevant when you’re building a polished user experience on top of the protocol, not when you’re building the server internals.
One last pass before shipping.
Resources
Is Your MCP Architecture Correct?
| Item | Score | |
|---|---|---|
| No tool parameter names contain: token, key, secret, auth, or credential | ||
| Auth validation lives in a BaseHTTPMiddleware subclass — not in tools | ||
| User identity injected via ContextVar, not passed as a tool parameter | ||
| Transport is Streamable HTTP for any server with more than one client | ||
| stdio is only used for Claude Desktop or local single-process dev | ||
| Static per-session data (user profile, config) is a Resource, not a Tool | ||
| Any tool called >80% of turns has been evaluated for conversion to Resource |
Recall’s server passes all seven. If yours doesn’t pass any item, that item is worth fixing before the first real user connects.
Issue 3 builds the full five-tool Recall server on top of this scaffold — auth wired in, timeouts on the extraction worker, structured error handling on every tool, and a pagination pattern for search_memories that doesn’t blow up the model’s context window on large result sets. The scaffold above is the foundation. Issue 3 is everything you add to it before production.
Building Effective Tools for AI is a seven-issue series from Sentient Zero Labs. Each issue ships with working code from the Recall memory server — a production MCP tool built in public alongside the series.
Until next issue,
Sentient Zero Labs