Skip to content

fix: deterministic MCP tool ordering for prompt cache stability - #4588

Merged
akshaydeo merged 1 commit into
maximhq:devfrom
ahoblitz:06-21-fix_deterministic_mcp_tool_ordering
Jun 21, 2026
Merged

akshaydeo merged 1 commit into
maximhq:devfrom
ahoblitz:06-21-fix_deterministic_mcp_tool_ordering

Conversation

@ahoblitz

@ahoblitz ahoblitz commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Summary

MCP tools are assembled from Go maps with undefined iteration order, causing the tool list to shuffle across requests. This breaks prefix-based prompt caching (~11x cost multiplier).

Changes

  • core/mcp/utils.go — Sort client names and tool names before iteration in GetToolPerClient
  • core/mcp/toolmanager.go — Sort client names in GetAvailableTools before flattening
  • core/schemas/orderedmap.go — Sort keys in OrderedMapFromMap so tool schema properties are deterministic

Type of change

  • Bug fix

Affected areas

  • Core (Go)

Breaking changes

  • No

Related issues

Closes #2347

Checklist

  • I read docs/contributing/README.md and followed the guidelines
  • I added/updated tests where appropriate
  • I updated documentation where needed
  • I verified builds succeed (Go and UI)
  • I verified the CI pipeline passes locally if applicable

@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Fixes non-deterministic MCP tool ordering by sorting client names and tool names before iteration in GetAvailableTools and GetToolPerClient, and by adding lexicographic key sorting in OrderedMapFromMap. A changelog entry records the fix.

Changes

Deterministic MCP Tool Ordering

Layer / File(s) Summary
Sorted client and tool iteration in GetAvailableTools and GetToolPerClient
core/mcp/toolmanager.go, core/mcp/utils.go
Adds slices import to toolmanager.go and replaces direct availableToolsPerClient map iteration with a sorted-slice loop. In utils.go, GetToolPerClient builds sorted client name and tool name slices before iterating, replacing unordered range-over-map loops at both the client and tool levels.
Lexicographic key sorting in OrderedMapFromMap
core/schemas/orderedmap.go, core/changelog.md
Adds sort.Strings(om.keys) after key collection in OrderedMapFromMap so tool property keys are ordered deterministically; updates the function comment accordingly. Changelog documents the fix.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Suggested reviewers

  • akshaydeo
  • TejasGhatte
  • danpiths

Poem

🐇 Hop, hop, the maps used to roam,
Keys shuffling wildly, far from home.
Now sorted in order, neat as a row,
The cache finds its prefix — watch those hits grow!
No more random chaos, just deterministic cheer,
Your prompt cache is stable — the rabbit is here! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely summarizes the main change: fixing non-deterministic MCP tool ordering to improve prompt cache stability.
Linked Issues check ✅ Passed The code changes directly address all requirements from issue #2347: sorting client/tool names in GetToolPerClient [utils.go], sorting clients in GetAvailableTools [toolmanager.go], and sorting keys in OrderedMapFromMap [orderedmap.go].
Out of Scope Changes check ✅ Passed All changes are tightly scoped to implementing deterministic sorting in the MCP tool assembly pipeline; the changelog entry and test instructions are appropriate supporting changes with no extraneous modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description check ✅ Passed The PR description includes all required sections per template: Summary, Changes with bullet points, Type of change (Bug fix), Affected areas (Core/Go), Breaking changes (No), Related issues (#2347), and complete checklist. The description clearly explains the problem, solution, and affected files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@CLAassistant

CLAassistant commented Jun 21, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Sort client and tool names lexicographically in GetToolPerClient and
GetAvailableTools so the tool list injected into LLM prompts is stable
across requests. Also sort keys in OrderedMapFromMap so tool schema
properties don't shuffle.

Closes maximhq#2347
@ahoblitz
ahoblitz force-pushed the 06-21-fix_deterministic_mcp_tool_ordering branch from 922919a to 5d6e92d Compare June 21, 2026 14:22
@ahoblitz
ahoblitz marked this pull request as ready for review June 21, 2026 14:24
@greptile-apps

greptile-apps Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 3/5

Safe to merge for the common case (stable MCP connections), but the property-ordering gap means cache misses can still occur after any client reconnection even when tools haven't changed.

The client and tool list sorting in utils.go and toolmanager.go correctly eliminates the request-to-request ordering variance. However, convertMCPToolToBifrostSchema still builds property OrderedMaps by iterating a plain Go map without sorting, so tool schema property keys remain non-deterministic across reconnection events. The orderedmap.go fix does not reach this code path. Additionally, the clientsByName construction silently drops one client's tools whenever two clients share the same name, which is a behavior change from the original loop.

core/mcp/utils.go — the convertMCPToolToBifrostSchema property/defs building loops (lines ~524 and ~548) and the new clientsByName construction block (lines 75–81).

Important Files Changed

Filename Overview
core/mcp/utils.go Adds deterministic client and tool ordering in GetToolPerClient; however, convertMCPToolToBifrostSchema still builds property OrderedMaps from Go map iteration without sorting, which the PR's orderedmap.go fix does not cover.
core/mcp/toolmanager.go Correctly sorts client names before flattening availableToolsPerClient into the final tool list; straightforward and correct fix.
core/schemas/orderedmap.go Adds sort.Strings to OrderedMapFromMap; used correctly in SortKeys/SortedCopy/SortedCopyPreservingProperties paths, but not invoked from convertMCPToolToBifrostSchema's property-building loop.
core/changelog.md Adds changelog entry for the MCP tool ordering fix.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Req as Incoming Request
    participant TM as ToolsManager
    participant MM as MCPManager
    participant CM as clientMap (map[ID]→State)

    Req->>TM: GetAvailableTools(ctx)
    TM->>MM: GetToolPerClient(ctx)
    MM->>CM: iterate clientMap
    Note over MM: collect names → clientsByName<br/>slices.Sort(clientNames) ✅
    loop sorted clientNames
        MM->>MM: iterate sorted toolNames ✅
        MM-->>MM: tools[clientName] append
    end
    MM-->>TM: map[clientName][]ChatTool
    Note over TM: slices.Sort(sortedClients) ✅
    loop sorted clients
        TM-->>TM: flatten into availableTools
    end
    TM-->>Req: []ChatTool (deterministic order ✅)

    Note over MM: At tool-discovery time (reconnect)
    MM->>MM: convertMCPToolToBifrostSchema
    Note over MM: for k,v := range Properties (Go map)<br/>→ NewOrderedMapWithCapacity+Set ❌<br/>key order non-deterministic
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Req as Incoming Request
    participant TM as ToolsManager
    participant MM as MCPManager
    participant CM as clientMap (map[ID]→State)

    Req->>TM: GetAvailableTools(ctx)
    TM->>MM: GetToolPerClient(ctx)
    MM->>CM: iterate clientMap
    Note over MM: collect names → clientsByName<br/>slices.Sort(clientNames) ✅
    loop sorted clientNames
        MM->>MM: iterate sorted toolNames ✅
        MM-->>MM: tools[clientName] append
    end
    MM-->>TM: map[clientName][]ChatTool
    Note over TM: slices.Sort(sortedClients) ✅
    loop sorted clients
        TM-->>TM: flatten into availableTools
    end
    TM-->>Req: []ChatTool (deterministic order ✅)

    Note over MM: At tool-discovery time (reconnect)
    MM->>MM: convertMCPToolToBifrostSchema
    Note over MM: for k,v := range Properties (Go map)<br/>→ NewOrderedMapWithCapacity+Set ❌<br/>key order non-deterministic
Loading

Comments Outside Diff (1)

  1. core/mcp/utils.go, line 524-529 (link)

    P1 Property key ordering remains non-deterministic after reconnection

    convertMCPToolToBifrostSchema builds the orderedProps map by iterating mcpTool.InputSchema.Properties, which is a plain map[string]interface{} with undefined iteration order. The OrderedMapFromMap fix in this PR does add sorting, but this code path uses NewOrderedMapWithCapacity + Set() instead of OrderedMapFromMap, so it is not covered by the fix.

    During normal operation this is stable (tools are discovered once per connection lifetime), but after any reconnection event—SSE drop, STDIO restart, network failure—tools are re-discovered and the property keys can land in a different order. Because ToolFunctionParameters.Normalized() calls preserveKeysWithPropertyAwareness(), which explicitly preserves whatever insertion order exists in the *OrderedMap, that reconnection-time non-determinism propagates directly into the serialized tool schema sent to the LLM, causing prompt cache misses for unchanged tools.

    The same issue applies to the orderedDefs block at line ~548. Replacing both NewOrderedMapWithCapacity + iteration loops with schemas.OrderedMapFromMap(mcpTool.InputSchema.Properties) (and ...Defs) would make discovery-time ordering deterministic and consistent across reconnects.

Reviews (1): Last reviewed commit: "fix: deterministic MCP tool ordering for..." | Re-trigger Greptile

Comment thread core/mcp/utils.go
@ahoblitz

Copy link
Copy Markdown
Contributor Author

Good catch on convertMCPToolToBifrostSchema — fixed. Both the Properties and Defs loops now use OrderedMapFromMap (which sorts keys) instead of manual NewOrderedMapWithCapacity + range iteration. This covers the reconnection-time property ordering gap.

Re: duplicate client names — GetClientByName already returns only the first match, so names are assumed unique. The original code's append-under-same-key behavior for duplicate names was already surprising; this doesn't regress it in practice.

@akshaydeo
akshaydeo merged commit 1cdd311 into maximhq:dev Jun 21, 2026
6 checks passed
abdenasseraroukhsiss added a commit to abdenasseraroukhsiss/bifrost that referenced this pull request Jun 22, 2026
* origin/dev: (76 commits)
  fix: deterministic MCP tool ordering for prompt cache stability (maximhq#4588)
  enterprise changelog (maximhq#4586)
  Adds changelog for v1.5.16 --skip-ci
  transports: update dependencies --skip-ci
  fixes go worspace setup for cost-accuracy and load-test (maximhq#4585)
  plugins/telemetry: bump core to v1.5.22 and framework to v1.3.22 --skip-ci
  plugins/semanticcache: bump core to v1.5.22 and framework to v1.3.22 --skip-ci
  plugins/prompts: bump core to v1.5.22 and framework to v1.3.22 --skip-ci
  plugins/otel: bump core to v1.5.22 and framework to v1.3.22 --skip-ci
  plugins/modelcatalogresolver: bump core to v1.5.22 and framework to v1.3.22 --skip-ci
  plugins/mocker: bump core to v1.5.22 and framework to v1.3.22 --skip-ci
  plugins/maxim: bump core to v1.5.22 and framework to v1.3.22 --skip-ci
  plugins/logging: bump core to v1.5.22 and framework to v1.3.22 --skip-ci
  plugins/jsonparser: bump core to v1.5.22 and framework to v1.3.22 --skip-ci
  plugins/governance: bump core to v1.5.22 and framework to v1.3.22 --skip-ci
  plugins/compat: bump core to v1.5.22 and framework to v1.3.22 --skip-ci
  framework: bump core to v1.5.22 --skip-ci
  build fix (maximhq#4584)
  build fix
  chore: regenerate openapi.json --skip-ci
  ...

# Conflicts:
#	.github/workflows/scripts/cost-accuracy-test.sh
#	core/changelog.md
#	tests/config.json
#	tests/integrations/python/config.json
akshaydeo pushed a commit that referenced this pull request Jun 24, 2026
Sort client and tool names lexicographically in GetToolPerClient and
GetAvailableTools so the tool list injected into LLM prompts is stable
across requests. Also sort keys in OrderedMapFromMap so tool schema
properties don't shuffle.

Closes #2347
akhsaul pushed a commit to akhsaul/bifrost that referenced this pull request Aug 27, 2026
…mhq#4588)

Sort client and tool names lexicographically in GetToolPerClient and
GetAvailableTools so the tool list injected into LLM prompts is stable
across requests. Also sort keys in OrderedMapFromMap so tool schema
properties don't shuffle.

Closes maximhq#2347
occcat pushed a commit to occcat/bifrost that referenced this pull request Sep 2, 2026
…mhq#4588)

Sort client and tool names lexicographically in GetToolPerClient and
GetAvailableTools so the tool list injected into LLM prompts is stable
across requests. Also sort keys in OrderedMapFromMap so tool schema
properties don't shuffle.

Closes maximhq#2347
dougcalobrisi added a commit to dougcalobrisi/bifrost that referenced this pull request Sep 15, 2026
convertMCPToolToBifrostSchema built the OrderedMap for inputSchema
properties and $defs by ranging over the Go maps mcp-go decodes them
into. Map iteration order is random, and the conversion runs again on
every connection check tick, so the stored tool could change property
order at every sync. The tool JSON sent to providers changed with it,
which invalidated provider prompt caches. computeToolsHash changed too,
so the callback set with SetToolsChangeCallback fired for tools that had
not changed.

Copy both maps with OrderedMapFromMap, which sorts keys. mcp-go does not
record the server's key order, so sorted order is the stable order
available here.

maximhq#2348 proposed this change and was closed after maximhq#4588 merged, but maximhq#4588
sorted tool and client names and did not change this function. Related:
maximhq#3362, maximhq#6591.

Fixes maximhq#7169

Affected packages:
- core/mcp/utils.go
- core/mcp/toolschemaorder_test.go
- core/changelog.md
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.

[Bug]: MCP tool ordering is non-deterministic, breaking prefix-based prompt caching

3 participants