Skip to content

fix(stella-mcp): sort the MCP schema segment so the advertised toolset is byte-stable (#1848) - #1875

Merged
macanderson merged 2 commits into
mainfrom
fix/1848-mcp-schema-order
Aug 6, 2026
Merged

fix(stella-mcp): sort the MCP schema segment so the advertised toolset is byte-stable (#1848)#1875
macanderson merged 2 commits into
mainfrom
fix/1848-mcp-schema-order

Conversation

@macanderson

@macanderson macanderson commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Problem

ToolRegistry::schemas sorts by name, and its comment says exactly why:

Sort by name: the maps iterate in per-process-randomized HashMap order, and this list is serialized verbatim at position 0 of the prompt prefix. Prompt caching is a byte-level prefix match, so a deterministic order lets two processes (or a restart within the cache TTL) share the tools+system cache entry instead of each writing a divergent one.

McpToolSet::schemas then concatenated after that sorted list without re-sorting, handing the guarantee straight back to self.clients order and to which server finished connecting first.

Within one process the order is stable, so this is a cross-process miss — a restart inside the cache TTL, or two stella processes in one workspace, is precisely the case the registry's sort exists for (invariant 7).

Change

The MCP segment is sorted by its namespaced name. That makes the answer independent of both client order and connection-completion order, rather than merely stable within a run. Namespaced names are unique by construction (routes is keyed on them), so the order is total — no tie for the sort to break arbitrarily and reintroduce the nondeterminism.

Segments are preserved, not flattened. Native tools first, then MCP — that order is a deliberate contract ("the base layer the MCP set augments"), and a sort over the whole list would have made the new test pass while silently moving the native tools. The witness asserts the segment boundary too, so that shortcut cannot pass.

The two siblings the issue named

Both checked; neither needs a change, and the PR says why rather than leaving it to a reader:

  • CandidateMcpView::schemas concatenates a sorted native list with a filtered view of inner.schemas(). Filtering preserves relative order, so it inherits this fix.
  • DiscoveryToolSet::discovery_schemas builds a literal vec![…] — ordered by construction, not by iteration.

Witness

schemas_are_byte_identical_whatever_order_the_servers_connected_in builds two sets from the same two servers registered in opposite orders and compares the serialized schemas. Bytes, not fields — bytes are what the cache matches on, so bytes are what the assertion should be about.

On the old code:

test toolset::tests::schemas_are_byte_identical_whatever_order_the_servers_connected_in ... FAILED
assertion `left == right` failed: two processes that connected the same servers in
different orders must advertise the same bytes, or they cannot share a prompt-cache entry

With the sort: passes.

Cost, stated

This changes the advertised order once, which is a one-time prompt-cache invalidation for any session live across the upgrade. That is the price of having the property at all, and it is paid once rather than on every restart — which is what the old behaviour risked.

Verification

$ cargo test -p stella-mcp
test result: ok. 146 passed; 0 failed

$ cargo test -p stella-pipeline --test cache_correctness
test result: ok. 5 passed; 0 failed      # the golden prompt-cache manifests are unmoved

cargo clippy -p stella-mcp --all-targets -- -D warnings and cargo fmt --check clean.

Closes #1848

Summary by Sourcery

Ensure MCP tool schemas are advertised in a deterministic, cross-process-stable order to keep prompt cache entries shareable across processes.

Bug Fixes:

  • Fix non-deterministic ordering of MCP tool schemas by sorting the MCP segment by namespaced name while preserving native-before-MCP segmentation.

Tests:

  • Add a test that asserts serialized schema bytes are identical regardless of MCP server connection order and that the native/MCP segment boundary and MCP sort order are preserved.

…t is byte-stable

`ToolRegistry::schemas` sorts by name for a stated reason: the list is
serialized verbatim at position 0 of the prompt prefix, prompt caching is a
byte-level prefix match, and HashMap iteration is per-process randomized. So
two processes share the tools+system cache entry only if they emit the same
bytes.

`McpToolSet::schemas` then concatenated after that sorted list without
re-sorting, which handed the guarantee straight back to `self.clients` order
and to which server finished connecting first. Within one process the order is
stable, so this is a CROSS-process miss — a restart inside the cache TTL, or
two stella processes in one workspace, is exactly the case the registry's sort
comment says it exists for.

The MCP segment is now sorted by its namespaced name, which makes the answer
independent of both client order and connection-completion order rather than
merely stable within a run. Namespaced names are unique by construction
(`routes` is keyed on them), so the order is total and no tie is left for the
sort to break arbitrarily.

Segments are preserved rather than flattened: native tools first, then MCP.
That order is a deliberate contract ("the base layer the MCP set augments"),
and sorting the whole list would have made the new test pass while silently
moving the native tools — so the witness asserts the segment boundary too.

The two sibling decorators were checked and need no change.
`CandidateMcpView::schemas` concatenates a sorted native list with a filtered
view of `inner.schemas()`, and filtering preserves relative order, so it
inherits this fix. `DiscoveryToolSet::discovery_schemas` builds a literal
`vec![]`, which is ordered by construction.

Witness: `schemas_are_byte_identical_whatever_order_the_servers_connected_in`
builds two sets from the same two servers registered in opposite orders and
compares the SERIALIZED schemas — bytes, because bytes are what the cache
matches on. It fails on the old code ("two processes that connected the same
servers in different orders must advertise the same bytes") and passes with
the sort.

This changes the advertised order once, which is a one-time prompt-cache
invalidation for sessions live across the upgrade. That is the cost of having
the property at all, and it is paid once rather than on every restart.

`cargo test -p stella-mcp` — 146 passed, 0 failed. The prompt-cache golden
fixtures (`cargo test -p stella-pipeline --test cache_correctness`) are
unaffected: 5 passed.

Closes #1848

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
stella-cli-docs Ignored Ignored Preview Aug 6, 2026 6:37pm

@sourcery-ai

sourcery-ai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Ensures the advertised MCP tool schemas are emitted in a deterministic, cross-process-stable order by sorting the MCP segment by namespaced tool name while preserving the native-vs-MCP segment contract, and adds a test that asserts the serialized schema list is byte-identical regardless of MCP server connection order.

Sequence diagram for deterministic MCP schemas emission order

sequenceDiagram
    participant Caller
    participant McpToolSet
    participant NativeToolExecutor as Native

    Caller->>McpToolSet: schemas()
    activate McpToolSet

    alt native segment present
        McpToolSet->>Native: schemas()
        Native-->>McpToolSet: Vec<ToolSchema> (native)
        McpToolSet->>McpToolSet: extend(native_schemas)
    end

    McpToolSet->>McpToolSet: collect MCP ToolSchema into mcp
    McpToolSet->>McpToolSet: mcp.sort_by(name)
    McpToolSet->>McpToolSet: extend(mcp)

    McpToolSet-->>Caller: Vec<ToolSchema> (native segment, then sorted MCP segment)
    deactivate McpToolSet
Loading

File-Level Changes

Change Details Files
Make MCP tool schemas order deterministic across processes while preserving native-first, MCP-second segmenting.
  • Wrap MCP tool schemas collection in a separate mcp vector instead of appending directly to the combined schemas vector.
  • Filter tools into the mcp segment as before using the routes lookup keyed by namespaced tool name.
  • Sort the mcp vector by the name field (namespaced name) after collection to eliminate dependence on client registration and connection order.
  • Extend the final schemas list by appending the sorted mcp segment after the native tools segment, preserving the native-first contract.
  • Document in the schemas method doc comment that both segment order and per-segment ordering are cross-process contracts due to byte-level prompt cache matching.
crates/stella-mcp/src/toolset.rs
Add a regression test ensuring byte-identical schemas and preserved segment boundaries regardless of MCP server ordering.
  • Introduce async test schemas_are_byte_identical_whatever_order_the_servers_connected_in that builds two McpToolSets with the same servers registered in opposite orders and wraps them with a fake native tool set.
  • Serialize the schemas for both toolsets to JSON strings and assert equality to guarantee byte-level identity for prompt-cache compatibility.
  • Extract the schema names for the forward toolset and assert that all native tool names precede any names with the MCP namespace prefix, validating the native-first, MCP-second segment contract.
  • Assert that the MCP segment names are exactly the expected sorted namespaced identifiers (e.g., mcp__alpha__one, mcp__zeta__two), confirming the per-segment sort order.
  • Add explanatory comments in the test about why byte-level comparison and segment boundaries are important for prompt caching and to guard against future regressions (e.g., sorting the entire list).
crates/stella-mcp/src/toolset.rs

Assessment against linked issues

Issue Objective Addressed Explanation
#1848 Make the merged toolset schemas order cross-process deterministic (independent of MCP client order and connection timing), by sorting at the appropriate boundary while preserving the intended segment ordering (native tools first, then MCP tools, with other segments documented as needed).
#1848 Add tests that verify byte-identical schema serialization regardless of MCP client registration/connection order, and that the native-vs-MCP segment ordering contract is preserved.
#1848 Document the ordering contract and its implications, including noting the one-time prompt-cache invalidation caused by the ordering change and the behavior where schemas are re-read each call so toggled servers disappear mid-session.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@macanderson
macanderson merged commit d1c627b into main Aug 6, 2026
4 checks passed
@macanderson
macanderson deleted the fix/1848-mcp-schema-order branch August 6, 2026 19:04
macanderson added a commit that referenced this pull request Aug 6, 2026
…olset (#1856) (#1898)

## A correction to the premise, which is the fix

The issue reports MCP schemas as forwarded "uncapped". That is not quite
right — and the correction is where the real defect is.

`crate::client::ingest` **already caps every tool individually**:

```rust
pub const MAX_TOOLS_PER_SERVER: usize = 256;
pub(crate) const MAX_TOOL_DESCRIPTION_CHARS: usize = 2_000;
// plus a per-tool inputSchema byte budget
```

What nothing bounds is the **aggregate**. 256 tools × a 2,000-character
description is **~512 KB of third-party prose from a single server**, at
position 0 of every request, before schemas are counted — and every
per-item cap holds the whole way. *The caps are per-item; the cost is
the sum.*

Measured through the real `tools/list` path: 40 tools with 2 KB
descriptions — every ingest cap satisfied — advertise **81,440 bytes**
from one server.

## Change

`MAX_SERVER_SCHEMA_BYTES` (32 KB, ~9k tokens), applied per server.

The **first** tool of a server is always admitted however large: a
server whose single tool exceeds the budget should advertise that tool,
not vanish, and ingest's per-tool caps already bound it.

Applied to the **sorted** segment, which matters for more than tidiness.
Sorting by namespaced name groups each server's tools into a contiguous
run *and* fixes the order inside it, so the truncation point is a
function of the tool names rather than of client order or connection
timing. Budgeting an unsorted list would have re-lost the cross-process
byte stability the sort (#1848, this PR's base) exists to buy.

`over_budget_servers()` reports what was cut — deliberately separate
from `over_advertising_servers()`, because those are two different walls
and a reader needs to know which one was hit: 300 tools trips the count
cap, twelve verbose ones trip this.

One `budget_segment` fold serves both callers, and one `mcp_segment`
builds the input for both. No second copy of either rule for reporting —
that is the drift #1613 was filed for, and I would rather not re-file
it.

## Witness

`one_chatty_server_cannot_spend_the_whole_prefix` drives a 40-tool
server through the real transport and asserts four things, because the
first alone is satisfiable by simply dropping the server:

1. the block is **bounded**;
2. the server is **trimmed, not silenced**;
3. every tool is **either advertised or counted as dropped** (the count
is the whole diagnostic — an operator wondering why a tool is missing
needs to be told);
4. the survivors are the **lexicographic prefix**, so the cut is
deterministic.

On the old code:

```
one server advertised 81440 bytes, over the 32768-byte budget
test result: FAILED. 0 passed; 1 failed
```

`an_ordinary_server_is_not_trimmed` is the other direction — the budget
must be invisible for the servers people actually run, so a fix that
trimmed everything cannot pass.

```
$ cargo test -p stella-mcp
test result: ok. 130 passed; 0 failed
```

Clippy and `cargo fmt --check` clean.

## Not done, deliberately: the lean-catalog default

The issue's other half asks to flip `STELLA_LEAN_TOOLS` on by default.
**Its own fix direction says to measure first**:

> Measure lean-mode's activation rate on the bench corpus before
flipping any default (each activation mutates the schema block = one
prefix invalidation; the trade is ~70% smaller prefix vs ~4-5
invalidations/session).

That is a bench measurement, not a code change, and flipping it
unmeasured is exactly what the issue warns against. Left open — hence
`Refs`, not `Closes`.

## Base

Stacked on #1875 (#1848, the schema sort). The sort is a precondition
here: the budget is applied to the sorted segment so the truncation
point is deterministic.

Refs #1856, #1848

## Summary by Sourcery

Enforce a per-server byte budget on MCP tool schemas while preserving
deterministic tool ordering and improving diagnostics for over-budget
servers.

New Features:
- Introduce a per-server schema byte budget limiting how much a single
MCP server can contribute to the advertised toolset.
- Expose an `over_budget_servers` API that reports which servers had
tools trimmed and how many were dropped.

Enhancements:
- Refactor MCP tool collection into a sorted `mcp_segment` and apply
budgeting via a shared `budget_segment` helper used by both schema
generation and diagnostics.
- Ensure the combined native and MCP tool schema list is byte-stable
across processes regardless of server connection order, maintaining
native-first ordering.

Tests:
- Add tests to verify schema byte stability across different server
connection orders.
- Add tests to ensure chatty servers are trimmed but not silenced and
that ordinary servers are unaffected by the new budget.

---------

Co-authored-by: Stella Test <test@stella.local>
macanderson added a commit that referenced this pull request Aug 6, 2026
…ter the #1875/#1898 growth (#1906)

## What & why

Unbreaks main's `file-size` gate. `crates/stella-mcp/src/toolset.rs`
reached **1756 lines** after three stacked merges — #1875 (sort the MCP
schema segment for byte-stability, #1848), #1898 (bound one server's
contribution to the advertised toolset, #1856), and the parked-waits
work (#1860) — crossing the hard 1500-line limit for files not in
`scripts/file-size-baseline.txt`.

**What moved where:** the `#[cfg(test)] mod tests` block (~945 lines,
more than half the file) moved out to a sibling submodule,
`crates/stella-mcp/src/toolset/tests.rs`, declared as `#[cfg(test)] mod
tests;`. This is the pattern AGENTS.md names
(`crates/stella-core/src/driver/settlement.rs`, split out of
`driver.rs`) and the exact shape `stella-model` already uses for
`src/zai/tests.rs` and `src/anthropic/tests.rs`. Purely mechanical: the
block is de-indented one level (plus the rustfmt repack that de-indent
allows) — **no visibility changes** (a child module reaches the parent's
private items via `use super::*`), no renames, no behavior change. The
new file carries a `//!` module doc per crate idiom, and the README
Layout row for `toolset.rs` now names the tests file (matching
`stella-model`'s README style).

- `src/toolset.rs`: 1756 → **812** lines
- `src/toolset/tests.rs` (new): **948** lines

**Why no baseline entry:** the guard's own policy —
`scripts/file-size-baseline.txt` accepts no new entries; a file over the
limit gets split, not grandfathered (AGENTS.md § God files).

**Two more main unbreaks riding along:**

- main's `cargo doc -D warnings` fails with *public documentation for
`MAX_SERVER_SCHEMA_BYTES` links to private item
`crate::client::ingest`*. The intra-doc link in that const's doc block
(same file, `toolset.rs`) is now plain code font — same fix shape as
#1823/#1830.
- main's `Cargo.lock` is stale: `stella-diff`'s manifest says 0.6.127
but the lock records 0.6.126, so any build rewrites the lock and CI's
`Cargo.lock` sync check fails. One-line resync, generated by cargo
itself.

**Heads-up for reviewers:** this PR alone does not green the gate — the
`stella-pipeline` compile break and the Makefile duplicate target are
being fixed in a separate PR by the coordinator.

## The witness

- [x] No witness needed (pure refactor / docs / CI) — because: this is a
mechanical module split plus a doc-link defuse and a lockfile resync;
behavior is proven unchanged by the existing suite, not by a new test.
All 122 `stella-mcp` unit tests (the relocated `toolset::tests` among
them) and every integration suite pass unchanged.

## The gate

GitHub Actions is in a major outage, so CI will not run on this PR —
local verification is the evidence, run in this branch's worktree:

- [x] `./scripts/check-file-size.sh` — OK, none over 1500 except the 32
grandfathered (none grew)
- [x] `./scripts/check-god-files.sh` — OK; `stella-mcp`'s "no god files"
README claim stays true
- [x] `cargo fmt -p stella-mcp -- --check` — clean
- [x] `cargo check -p stella-mcp` — clean
- [x] `cargo clippy -p stella-mcp --all-targets -- -D warnings` — clean
- [x] `cargo test -p stella-mcp` — 122 unit + 6/3/3/15/1 integration
(wiremock + fixture-server suites included), all pass
- [x] `RUSTDOCFLAGS="-D warnings" cargo doc -p stella-mcp --no-deps` —
clean (verifies the private-link fix)
- [x] Docs updated where behavior changed (README Layout row)
- [x] CLA signed

## Nothing left behind

- [x] There is nothing: everything I noticed is fixed in this PR (the
remaining main breaks — `stella-pipeline` compile, Makefile duplicate
target — are already owned by the coordinator's separate PR)

## Anything reviewers should know?

Do not merge on green checks alone — Actions is down; the checklist
above is the verification record.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mcp: merged toolset schemas are not sorted, breaking the cross-process byte-order determinism the registry establishes

1 participant