Connect a Model Context Protocol (MCP) server to a real security data source, such as a firewall, a SIEM or a CMDB, and the first large question you ask can consume most of the model's context window in a single tool call. This workshop shows the problem happening, then fixes it with response spooling, using the open source Spindl framework.
Everything from the session is public, including the lab server, the fabricated data set, the client configurations and a live token meter, so you can work through it at your own desk. The setup and the exact questions are at the end of this page.
MCP in one picture
| Host application | MCP client | MCP server | Data source |
|---|---|---|---|
| Claude Desktop, VS Code, an agent runtime | One per server connection, speaks JSON-RPC | Your code: exposes tools, resources and prompts | Firewall API, SIEM, database, SaaS platform |
- Local servers run over stdio. The client launches your server as a child process and exchanges newline-delimited JSON-RPC over standard input and output.
- Remote servers run over HTTP. Same protocol, different transport. This is where enterprise MCP deployments live, with authentication.
- Tools are the working surface. A server advertises named tools with JSON schemas, and the model decides when to call them.
The life of a tool call
client: tools/list
server: [fw_get_rulebase, ...]
model: decides a tool helps
client: tools/call fw_get_rulebase
server: { 1,200 rules ... }
client: injects the ENTIRE result into the model's context
model: answers from context
- Context is the budget. Everything the model knows in a turn lives in one window: your question, the tool schemas and every tool result.
- Results are not free. Tool results are tokens. They cost money and latency, and they take space that evicts other content.
- Nobody polices size. The protocol will happily deliver a 500,000 character result. What happens next is the subject of this workshop.
Tool results land in the context window
Small results are fine. Then you connect a real data source.
get_rulebase callThe consequences are truncation, eviction of earlier turns, degraded answers and token spend, all before the second question.
The lab: a simulated firewall behind MCP
The workshop server fronts fw-edge-01, a fictional FabricGate 5200 firewall. The data is entirely fabricated and deterministic, so everyone sees identical rules, logs and numbers.
| Tool | What it returns |
|---|---|
fw_get_device_info | Tiny. Your connectivity smoke test. |
fw_get_rulebase | 1,200 rules, about 127,000 tokens raw. |
fw_get_traffic_logs | 24 hours of sessions, about 165,000 tokens raw. |
fw_list_tools, fw_describe_tool | Spindl skills guides, always present. |
fw_spooler_query, _aggregate, _distinct, _list | Spindl mode only. |
The same server binary runs in two modes, --mode naive and --mode spindl. A transparent proxy in the repository relays MCP traffic untouched while counting what every message contributes to context (estimated at four characters per token), showing a running total and the last eight tool responses, including the filters each spooler call sent.
Exercise 1: feel the flood
In naive mode, ask three questions while watching the token meter, the response latency, and whether the model actually finds the right rule:
- "What firewall device am I connected to?"
- "Which enabled rules allow RDP (tcp/3389) from the internet?"
- "Which source IP generated the most denies in the last 24 hours?"
you: which rules allow inbound RDP?
model: fw_get_rulebase()
tool: [1,200 rules] -> ~127,000 tokens
model: reads everything. or truncates. or guesses.
you: next question? too bad.
- Silent failure modes. Truncated tool results and evicted turns rarely announce themselves. The answer just gets worse.
- You pay for every token. Bulk dumps burn budget and rate limits on data the model mostly discards.
- It compounds. Agents with several servers multiply the problem, and one chatty data source starves the rest.
Spool it, don't send it
Spindl is an MCP framework built for large data. Large tool results are stored server-side in an ephemeral SQLite spool, and the model receives a summary and a handle, then explores the data on demand.
| Step | What happens |
|---|---|
| Tool result | 1,200 rules come back from the data source. |
| Threshold | More than 2,000 tokens, or more than 10 items? |
| SQLite spool | The result is stored on the server. |
| Summary | Count, columns, samples and a spool_id go to the model. |
| Explore | The model calls query, aggregate and distinct tools. |
What lands in context: a few hundred tokens instead of 127,000.
Spindl also provides:
- Prefix namespacing. Every tool carries the server's prefix (
fw_*), so tools never collide across several connected servers. - Skills guides on demand.
list_toolsanddescribe_toolserve usage guides when needed, rather than inflating every tool description. - Strict validation on any transport. Undeclared arguments are rejected with a structured error, and the same tool code runs over stdio, HTTP and SSE.
The entire migration
Same tools, same data, one argument:
# Exercise 1: naive
server = MCPServer(
prefix="fw",
)
# Exercise 2: spindl
server = MCPServer(
prefix="fw",
spooler=SpoolerConfig(),
)
Plus one attribute on each large tool: spooler_auto_detect = True. Spooling is an architectural layer you switch on, not a rewrite. Tools never know their prefix and never know they are spooled.
Exercise 2: four new tools, nothing else changed
Restarting in Spindl mode adds four spooler tools alongside the same firewall tools. These are what the model drives instead of swallowing dumps:
| Tool | Purpose |
|---|---|
fw_spooler_list | Every spooled data set in this session. |
fw_spooler_query | Filter, sort, search and paginate records. |
fw_spooler_aggregate | Group by, with count, sum, average, minimum and maximum. |
fw_spooler_distinct | Unique values with counts, and accepts filters. |
Spools live in a session SQLite file that is deleted on shutdown by default. Every spool summary arrives with a query_hint naming the right follow-up tool and its spool_id, so the model never calls these tools cold.
Now repeat the questions from Exercise 1 and watch the summary arrive instead of the dump. Then hunt the noisy talker ("Which source IP generated the most denies?"), which the model answers with fw_spooler_aggregate, and pull the thread: did that scanner ever get in, which rule allowed it, and what services has it probed? Run it like an incident.
Not one bulk dump enters your context for the entire investigation.
You just worked a real incident shape
185.220.101.42
An external scanner with 137 denies across ports 3389, 22, 445, 1433 and 23.
3 allowed sessions
RDP to 10.20.30.15, hiding in the noise.
Rule R01037
"vendor-remote-support", allowing 0.0.0.0/0 to RDP. The contract ended in 2024.
R01036 disabled
The deny rule that would have shadowed it, switched off since 2022.
Aggregate found the noise. Query found the exception. The rulebase spool found the cause. That workflow, from alert to root cause, is exactly what spooling makes affordable inside a single conversation.
There is more planted in the data for anyone working through it at their desk: an any-to-any "temporary" rule, telnet into the management network, and guest wifi reaching SMB.
Inside Spindl
What the model actually receives
"spooled_data": [{
"spool_id": "c411c8d56dfe",
"total_records": 1200,
"columns": ["rule_id", "action", ...],
"column_types": { "enabled": "INTEGER", ... },
"statistics": {
"distinct_value_counts": { "action": 2, ... },
"numeric_ranges": { "enabled": [0, 1] }
},
"sample_records": [ ...3 rows... ],
"query_hint": "Use fw_spooler_query ..."
}]
- Enough to plan a query. Columns, types, cardinality and samples tell the model which filters exist before it asks for a single row.
- The hint closes the loop.
query_hintnames the follow-up tool with thespool_idalready filled in, so the model rarely gets lost. - Ephemeral by default. Nothing persists unless you choose a backend that does.
Tuning: the thresholds are the contract
| Setting | Default | Meaning |
|---|---|---|
SPOOLER_MAX_INLINE_TOKENS | 2000 | Spool when the estimated result size exceeds this. |
SPOOLER_MAX_INLINE_ITEMS | 10 | Or when the array has more items than this. |
SPOOLER_DEFAULT_PAGE_SIZE | 20 | Rows per query page (maximum 50). |
SPOOLER_SUMMARY_SAMPLE_SIZE | 3 | Sample rows embedded in the summary. |
SPOOLER_CLEANUP_ON_EXIT | true | Delete the spool database on shutdown. |
Exercise 3 in the repository shows this directly: raise the thresholds past the size of the data and Spindl mode behaves like naive mode again. The mode provides the architecture, and the thresholds decide when it engages.
Prefixes and skills guides
Bloated tool descriptions are context flooding too. Spindl keeps descriptions to a single line and serves the full guides, with examples, through describe_tool. Guides refer to other tools by bare name using placeholders such as @spooler_query, which Spindl resolves to the prefixed wire name for each deployment. Setting SPINDL_INSTANCE_PREFIX=prod gives you prod_fw_* and dev_fw_* side by side, guides included.
Hardened for model-facing callers
> fw_spooler_distinct(column="service", bogus_argument=true)
{ "success": false, "error": {
"error_code": "INVALID_ARGUMENTS",
"error_message": "... does not accept the argument(s) 'bogus_argument'",
"retry_eligible": true,
"suggestion": "Accepted parameters: ..."
} }
Silently discarding an argument is a lie. If a tool ignores an argument, the model believes its filter was applied and then presents the wrong data confidently. A structured rejection naming the accepted parameters lets the model correct itself in one turn. Filterable distinct and strict argument validation both landed in Spindl 0.3.0, as a direct result of rehearsing these exercises.
From laptop to production
- Transports. The same server runs over stdio for desktop clients, or streamable HTTP and SSE through
run_http()andrun_sse().build_http_app()mounts it on an existing gateway. - Authentication.
AuthConfigadds bearer token verification, scope enforcement and RFC 9728 protected resource metadata for the HTTP transports. Your identity provider stays in charge. - Identity-scoped spools. Spools created during an authenticated request are owned by the caller's subject and are invisible to other callers, so data cannot bleed between tenants through the spool tools.
- Pluggable spool backends. SQLite by default, or inject a shared
SpoolBackendfor deployments with several replicas, with per-spool time to live.
Design for the context window
- Spool, don't dump. Any MCP server fronting a SIEM, EDR, firewall, CMDB or ticketing API returns arrays the model should query, not swallow.
- Summaries beat payloads. A count, the columns, some samples and a handle. The model asks better follow-up questions when it is not drowning.
- Architecture, not a rewrite. One constructor argument and one tool attribute took the workshop from about 145,000 tokens to a few hundred.
The cheapest token is the one you never send.
Replay the workshop at your desk
You need uv and an MCP-capable client. Claude Desktop, VS Code and the ChatGPT desktop app all work. Check the setup first:
uvx --from git+https://github.com/Cognisn/spindl-workshop \
spindl-workshop --help
If the output lists --mode {naive,spindl}, you are ready.
Client configuration
Claude Desktop. On macOS the file is ~/Library/Application Support/Claude/claude_desktop_config.json, and on Windows %APPDATA%\Claude\claude_desktop_config.json. For Exercise 2, change "naive" to "spindl" and fully restart Claude Desktop.
{
"mcpServers": {
"fw-workshop": {
"command": "uvx",
"args": ["--from", "git+https://github.com/Cognisn/spindl-workshop",
"spindl-workshop", "--mode", "naive"]
}
}
}
VS Code.
// .vscode/mcp.json (VS Code 1.99 or later)
{
"mcp": { "servers": {
"fw-workshop": {
"type": "stdio",
"command": "uvx",
"args": ["--from", "git+https://github.com/Cognisn/spindl-workshop",
"spindl-workshop", "--mode", "naive"]
}
} }
}
ChatGPT desktop. The desktop app runs stdio servers, and the Codex CLI shares the same configuration file. ChatGPT on the web supports remote HTTPS connectors only.
# ChatGPT desktop: ~/.codex/config.toml
# (or Settings, MCP servers, Add)
[mcp_servers.fw-workshop]
command = "uvx"
args = [
"--from", "git+https://github.com/Cognisn/spindl-workshop",
"spindl-workshop", "--mode", "naive",
]
The token meter (optional)
To watch the token counts yourself, swap the entry point in your client configuration from spindl-workshop to spindl-workshop-meter, leaving everything else the same, then run the viewer in a terminal:
uvx --from git+https://github.com/Cognisn/spindl-workshop \
spindl-workshop-meter watch
The questions, verbatim
Run Exercise 1 in --mode naive and Exercise 2 in --mode spindl.
| Exercise | Question |
|---|---|
| 1 and 2 | "What firewall device am I connected to?" |
| 1 and 2 | "Which enabled rules allow RDP (tcp/3389) from the internet, meaning source zone untrust with an any or 0.0.0.0/0 source?" |
| 1 and 2 | "Which source IP generated the most denies in the last 24 hours?" |
| 2 | "Did that scanner ever succeed in connecting? Show any allowed sessions." |
| 2 | "Which rule permitted those sessions, and what do we know about it?" |
| 2 | "What services has that scanner probed, and how often?" |
| 3 | Raise the SPOOLER_* thresholds until Spindl mode behaves like naive mode (see EXERCISE-3.md in the repository). |
| Deep dive | "What tools does this server offer, and how should I use the rulebase tool?" |
Everything is yours to keep
- Workshop repository (MIT licence; exercises, data, configurations and meter): github.com/Cognisn/spindl-workshop
- Spindl framework (MIT licence with Commons Clause): github.com/Cognisn/spindl, installable with
pip install cognisn-spindl
Sources
- Spindl, MCP server framework with response spooling (GitHub)github.com/Cognisn/spindl
- Spindl workshop repository, CyberCon 2026 (GitHub)github.com/Cognisn/spindl-workshop
- cognisn-spindl on PyPIpypi.org/project/cognisn-spindl/
- Model Context Protocol specificationmodelcontextprotocol.io/specification
- RFC 9728, OAuth 2.0 Protected Resource Metadatawww.rfc-editor.org/rfc/rfc9728
- uv, Python package and project managerdocs.astral.sh/uv/