Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,3 +425,103 @@ async def test_add():
if __name__ == "__main__":
asyncio.run(test_add())
```

### Large tool results: data spillover

Attach `DataSpilloverInterceptor` to a tool to store large results separately and
return a preview, `resource_uri`, and `original_size_bytes`. Results at or below
the threshold pass through unchanged. Sizes count serialized UTF-8 bytes, not
characters; lists and dictionaries use compact JSON.

```python
from pydantic import BaseModel
from nitrostack import DataSpilloverInterceptor, MemorySpilloverStore, tool, use_interceptors

spillover = DataSpilloverInterceptor(
threshold_bytes=64 * 1024,
store=MemorySpilloverStore(
max_bytes=10 * 1024 * 1024,
max_total_bytes=100 * 1024 * 1024,
ttl_seconds=300,
),
resource_uri_prefix="resource://data-spillover/",
)

class ReportInput(BaseModel):
count: int = 10000

class ReportTools:
@tool(name="report", description="Generate a report", input_schema=ReportInput)
@use_interceptors(spillover)
async def report(self, input: ReportInput, context):
return [{"row": index} for index in range(input.count)]
```

Register `ReportTools` as a module provider or controller as usual. A configured
interceptor instance works directly in the decorator. For default configuration,
`@use_interceptors(DataSpilloverInterceptor)` also works through dependency injection.

The MCP result includes a text preview, structured content, and a resource link.
Clients retrieve the full result with `resources/read`, for example:

```json
{
"jsonrpc": "2.0",
"id": 2,
"method": "resources/read",
"params": {"uri": "resource://data-spillover/<id-from-tool-result>"}
}
```

Lists and dictionaries preview their first five items or entries and total counts.
Strings preview up to 512 characters. The complete preview stays within 2 KiB;
large nested values are represented as truncated text. Reading the full resource
can consume context again, so clients should retrieve it only when needed.

For disk storage, replace the store with:

```python
from nitrostack import FileSpilloverStore

store = FileSpilloverStore(
".nitrostack/spillover",
max_bytes=10 * 1024 * 1024,
max_total_bytes=100 * 1024 * 1024,
ttl_seconds=300,
)
```

The memory store evicts least recently used results when its payload capacity is
full. The file store rejects writes when its capacity is full; its capacity counts
actual JSON file bytes, including base64 and metadata overhead. A single result
larger than `max_bytes` raises `PayloadTooLargeError`. Both stores remove expired
results during operations; file storage also cleans up on initialization. Expiry
is absolute and reads do not extend it. Each file-store root must be owned by one
store instance in one process; shared storage across processes is not supported.
Keep the configured directory private to the server account.

Resource access requires the same verified principal, issuer, client identity,
and tenant. Anonymous access is limited to the same server-side MCP session.
Requests without either a verified identity or trusted session cannot spill
results; invalid credentials do not fall back to anonymous access. Anonymous
HTTP clients need a persistent MCP session; sessionless HTTP clients should
authenticate. Expired results and results owned by another caller return
resource-not-found. Stored results are not enumerated in `resources/list`.

HTTP ownership uses the server-issued session ID after the official transport
has validated it, rather than the identity of an SDK request/session proxy.
Stdio assigns a random identity to each live stream connection. Requests on that
connection can retrieve its anonymous results; a later connection cannot inherit
them. Concurrent HTTP requests cannot borrow a stdio connection's identity.

Successful explicit MCP tool results are stored as JSON; errors and input-required
responses pass through. Decorated tools with output schemas advertise either the
original output or the spillover envelope. Spilled widget results return the
resource reference without invoking the widget's data transformer.

See [the line-by-line implementation walkthrough](docs/spillover-line-by-line.md)
for the storage, interceptor, MCP integration, and tests.

Independent HTTP/stdio tests and reproducible large-output checks live in
`tests/test_spillover_transport.py`; see
[PR #92 verification](docs/pr92-verification.md) for commands and platform results.
106 changes: 106 additions & 0 deletions docs/pr92-verification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# PR #92 review corrections and verification

Validated locally on 2026-10-29 with official MCP **2.3.0**.

## History and scope

The original PR head was `3ea98a767e01016134fe4d0396518a91b6e7bff5`.
Only its spillover feature commit was replayed onto upstream `develop` at
`63ecd9240d98bbe06c879089dd887494c7e99293`. Unrelated inherited `main` commits
were excluded. The feature branch contains the rebased implementation, transport
ownership corrections/tests, and updated documentation. No version, dependency,
CLI, widget-registry or unrelated feature-branch changes are included.

## Independently reproduced failure and correction

The original 39 spillover cases exercised stores and registered handlers
in-process. They did not prove anonymous retrieval over a real HTTP session.
An independent legacy HTTP probe initialized the official application, called a
large-result tool, and read the returned URI using the same server-issued
`Mcp-Session-Id`. The tool returned a spillover envelope but the resource read
failed: per-request SDK session proxy objects received different ownership keys.

Production HTTP ownership now uses the shared session-ID resolver only on the
sessionful wire after the official manager has validated/routed the request.
Modern/auto HTTP requires verified identity for spilling. Invalid credentials
cannot fall back to an anonymous owner. Principal ownership retains issuer,
subject/client and tenant dimensions and works after reconnecting.

Stdio assigns an internal random connection ID for the lifetime of a stream pair.
The context adapter preserves each message's sender context while binding that
ID, so MCP 2.3's context propagation cannot discard it. Messages pass unchanged
to official MCP dispatch; the adapter does not parse JSON-RPC or own methods.
The ID is reset at shutdown, separate connections are isolated, and concurrent
HTTP requests cannot borrow stdio ownership.

## Results

Complete collection succeeds: **907 tests**. No ignores, deselections, new skips
or `xfail` markers were introduced in complete-suite runs.

| Environment | Full-suite result | Coverage |
|---|---|---|
| Linux Python 3.10.21 | 907 passed | 86.68% |
| Linux Python 3.11.16 | 907 passed | 86.63% |
| Linux Python 3.12.14 | 907 passed | 86.66% |
| Windows Python 3.10.21, sequential rerun | 906 passed, 1 skipped | 86.68% |
| Windows Python 3.11.16 | 906 passed, 1 skipped | 86.66% |
| Windows Python 3.12.14, sequential rerun | 906 passed, 1 skipped | 86.63% |

Linux results are local WSL Ubuntu runs, not hosted GitHub Actions. The Windows
skip is the existing real-filesystem symlink test: this account lacks link
creation privileges. The simulated rejection test passes on Windows and the
actual filesystem test passes on Linux.

- Existing spillover cases: **38 passed, 1 skipped** on Windows.
- New transport module: **43 passed**, including **24 parameterized large-output
cases** across memory/file stores. These are included in the complete suite;
the 24 cases are not additional to the 43.
- Combined spillover, stdio, task, pipeline, transform and widget selection:
**146 passed, 1 skipped** on Windows.
- `git diff --check` passes; public exports and upstream transport behavior are
exercised by the complete suite.

### Earlier Windows failures and their investigation

Initial Windows versions were run concurrently in the same checkout. The 3.10
run reported **905 passed, 1 failed, 1 skipped**: existing production logger tests
share `test_warn.log`, and one process deleted another process's test file. The
3.10 complete suite passed when rerun sequentially. Existing logger tests were
not changed to hide this test-run orchestration collision.

The initial 3.12 run reported **905 passed, 1 failed, 1 skipped**: the existing
`test_discovery_dcr_404_when_disabled_or_missing_client_id` raised a localhost
`ConnectionAbortedError: [WinError 10053]`. Its complete sequential rerun passed.

The two existing OAuth discovery socket tests were also run together three times
against an unchanged archive of upstream `63ecd924...` using Python 3.12.14 and
MCP 2.3.0. Attempts 1 and 3 reproduced the **same DCR test and WinError 10053**
(1 passed, 1 failed); attempt 2 passed both tests. This confirms baseline
intermittence on this host without establishing its underlying cause. No OAuth
implementation or existing test was modified.

## Reproduction

```powershell
python -m pytest tests --collect-only -o addopts= -q
python -m pytest tests/test_spillover.py tests/test_spillover_transport.py -o addopts=--timeout=60 -q
python -m pytest tests --cov=nitrostack --cov-report=term-missing --timeout=60 --tb=short -o addopts= -q
git diff --check
```

Run the complete command for Python 3.10, 3.11 and 3.12. Run Windows matrices
sequentially or in separate source directories because existing tests share
filenames in the current working directory.

Large-output tests generate results inside the tool from a small case identifier,
then check preview limits, original UTF-8 byte sizes, full resource bytes and
other-session denial. HTTP request-body limits are left unchanged. Cases cover
64 KiB boundaries, 1/5/10 MiB text, Unicode/escaped strings, 100,000 rows, 50,000
dictionary entries, approximately 6 MiB nested data, and rejection at 10 MiB plus
one byte and 20 MiB. The adjacent JSON report names the committed tests.

Local logs are retained in `D:/NitroStack/pr92-*.log` and the ignored
`.pytest_cache/pr92-linux-3.*.log`. Hosted CI and the existing PR description must
be verified/updated after publication is authorized; no remote rewrite or
reviewer message was performed during this local implementation.
72 changes: 72 additions & 0 deletions docs/spillover-large-payload-results.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{
"description": "Reproducible large outputs generated inside tools and checked through official HTTP transport",
"test_module": "tests/test_spillover_transport.py",
"test_name": "test_large_payloads_over_http",
"command": "python -m pytest tests/test_spillover_transport.py -q",
"backends": [
"memory",
"file"
],
"threshold_bytes": 65536,
"max_payload_bytes": 10485760,
"checks_passed": 24,
"verified_on": "2026-10-29",
"mcp_version": "2.3.0",
"assertions": [
"UTF-8 original size",
"preview at most 2048 bytes",
"exact full resource content",
"another HTTP session denied"
],
"cases": [
{
"case": "threshold",
"expectation": "64 KiB returned unchanged"
},
{
"case": "threshold-plus-one",
"expectation": "64 KiB plus one byte spills"
},
{
"case": "1MiB",
"expectation": "1 MiB spills and is retrieved byte-for-byte"
},
{
"case": "5MiB",
"expectation": "5 MiB spills and is retrieved byte-for-byte"
},
{
"case": "10MiB",
"expectation": "10 MiB spills and is retrieved byte-for-byte"
},
{
"case": "unicode",
"expectation": "Multibyte UTF-8 size and retrieval match"
},
{
"case": "escaped",
"expectation": "Escaped text has a bounded preview and exact retrieval"
},
{
"case": "rows",
"expectation": "100,000-row list spills and is retrieved exactly"
},
{
"case": "dictionary",
"expectation": "50,000-entry dictionary spills and is retrieved exactly"
},
{
"case": "nested",
"expectation": "Approximately 6 MiB nested data is retrieved exactly"
},
{
"case": "too-large",
"expectation": "10 MiB plus one byte is rejected without a large fallback"
},
{
"case": "20MiB",
"expectation": "20 MiB is rejected without a large fallback"
}
],
"note": "Threshold and rejection cases assert unchanged output or a bounded error, respectively. Timing measurements from the previous in-process script have been replaced by committed transport tests."
}
Loading
Loading