Research

Spooling the Firehose

Taming large data sources in MCP with response spooling

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 primer

MCP in one picture

Host applicationMCP clientMCP serverData 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.
The problem

Tool results land in the context window

Small results are fine. Then you connect a real data source.

1,200firewall rules returned by one get_rulebase call
~145ktokens injected into context by that single result
1question answered before your context is spent

The 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.

ToolWhat it returns
fw_get_device_infoTiny. Your connectivity smoke test.
fw_get_rulebase1,200 rules, about 127,000 tokens raw.
fw_get_traffic_logs24 hours of sessions, about 165,000 tokens raw.
fw_list_tools, fw_describe_toolSpindl skills guides, always present.
fw_spooler_query, _aggregate, _distinct, _listSpindl 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:

  1. "What firewall device am I connected to?"
  2. "Which enabled rules allow RDP (tcp/3389) from the internet?"
  3. "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.
The fix

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.

StepWhat happens
Tool result1,200 rules come back from the data source.
ThresholdMore than 2,000 tokens, or more than 10 items?
SQLite spoolThe result is stored on the server.
SummaryCount, columns, samples and a spool_id go to the model.
ExploreThe 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_tools and describe_tool serve 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:

ToolPurpose
fw_spooler_listEvery spooled data set in this session.
fw_spooler_queryFilter, sort, search and paginate records.
fw_spooler_aggregateGroup by, with count, sum, average, minimum and maximum.
fw_spooler_distinctUnique 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.

The reveal

You just worked a real incident shape

The noise

185.220.101.42

An external scanner with 137 denies across ports 3389, 22, 445, 1433 and 23.

The exception

3 allowed sessions

RDP to 10.20.30.15, hiding in the noise.

The cause

Rule R01037

"vendor-remote-support", allowing 0.0.0.0/0 to RDP. The contract ended in 2024.

The root cause

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.

Deep dive

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_hint names the follow-up tool with the spool_id already 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

SettingDefaultMeaning
SPOOLER_MAX_INLINE_TOKENS2000Spool when the estimated result size exceeds this.
SPOOLER_MAX_INLINE_ITEMS10Or when the array has more items than this.
SPOOLER_DEFAULT_PAGE_SIZE20Rows per query page (maximum 50).
SPOOLER_SUMMARY_SAMPLE_SIZE3Sample rows embedded in the summary.
SPOOLER_CLEANUP_ON_EXITtrueDelete 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() and run_sse(). build_http_app() mounts it on an existing gateway.
  • Authentication. AuthConfig adds 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 SpoolBackend for deployments with several replicas, with per-spool time to live.
Takeaways

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.

Run it yourself

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.

ExerciseQuestion
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?"
3Raise 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

References

Sources

  1. Spindl, MCP server framework with response spooling (GitHub)github.com/Cognisn/spindl
  2. Spindl workshop repository, CyberCon 2026 (GitHub)github.com/Cognisn/spindl-workshop
  3. cognisn-spindl on PyPIpypi.org/project/cognisn-spindl/
  4. Model Context Protocol specificationmodelcontextprotocol.io/specification
  5. RFC 9728, OAuth 2.0 Protected Resource Metadatawww.rfc-editor.org/rfc/rfc9728
  6. uv, Python package and project managerdocs.astral.sh/uv/