Repository navigation
Preserve insertion order when listing server primitives - #1924
Conversation
McpServerPrimitiveCollection<T> enumerated its ConcurrentDictionary directly, so tools/list, prompts/list, resources/list and resources/templates/list returned primitives in hash order. For resources that order changed on every process start, because UriTemplateComparer uses randomized string hashing. Each entry now records an insertion sequence number, and enumeration (GetEnumerator, ToArray, CopyTo, PrimitiveNames) sorts a snapshot by it. Primitives registered with WithTools<T>(), WithPrompts<T>() and WithResources<T>() are now listed in declaration order, matching the Python and TypeScript SDKs. Fixes modelcontextprotocol#1923 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QNCtb94vqDmohN9UWoGV3U
There was a problem hiding this comment.
🟢 Approval recommended
The implementation consistently preserves insertion order while retaining thread-safe lookup and removal semantics.
0 open findings
What changed in this PR
Preserves insertion order when enumerating registered MCP server primitives.
Changes:
- Associates primitives with monotonic insertion sequence numbers.
- Returns ordered snapshots from enumeration APIs.
- Adds unit and end-to-end ordering tests.
| File | Description |
|---|---|
src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs |
Implements ordered snapshots and safe removal. |
tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs |
Tests collection ordering and re-add behavior. |
tests/ModelContextProtocol.Tests/Configuration/McpServerPrimitiveListOrderTests.cs |
Tests ordering through MCP list endpoints. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
I am wondering, instead of paying the extra cost all the time, why don't expose something like @halter73 what do you think? |
Well the problem is that the caller is an MCP client which expects a stable ordering (and could use any SDK), so the server has to make a choice whether to have that or not. So exposing this at the code level doesn't solve the issue for a protocol-level client, it just allows server authors to write a filter and sort the entries, which is extra busy work and should really be the default, imo. I think it would be good to align with the Python and TypeScript SDKs, especially now that caching has become more important than it used to be. |
Make sense to me. Thanks! |
|
The current implementation has concurrency and performance issues that work against the stability this PR is trying to provide.
Finally, Could we instead use:
Serializing mutations gives each successful add or remove a clear ordering point and removes the need for sequence numbers. Add remains amortized O(1). Removal from the ordered list is O(n), but primitive collections are normally small and mutations are uncommon. The first enumeration after a mutation would rebuild the snapshot in O(n). Subsequent enumerations would traverse the cached snapshot without locking, sorting, or additional allocation. The snapshot array must remain private:
This design would provide reliable behavior under concurrent mutations while retaining lock-free lookup and efficient repeated listing. |
Good points. It was the part of the PR I was the most uncomfortable and unsure about. I'll do an update to address this. Should I fit in the sorting by |
|
I’d leave the Within a batch, the existing discovery order seems sufficient. |
Replace per-entry sequence numbers with a mutation lock that keeps the dictionary and an insertion-ordered list in sync, so a primitive that is already visible can't be reordered by a concurrent add. Enumeration reads a cached snapshot that is rebuilt only after a mutation, and each entry keeps the key it was added under for PrimitiveNames and Remove. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SNwgdcsZB2wxPqPZa2SC4y
I had Claude do an update to the PR and it looks sensible to me in terms of addressing your concerns and following your outlined solution. It did have this interesting comment to you:
|
Remove now conditionally removes the exact entry it compared, so a primitive's Equals that reenters the collection and replaces the entry under the same key can't desynchronize the dictionary and ordered list. Add a deterministic reentrant-comparer test for the add interleaving, keep the threaded stalled-add test as best-effort coverage, and make it failure-safe with bounded waits and cleanup in a finally block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SNwgdcsZB2wxPqPZa2SC4y
Fixes #1923
Problem
McpServerPrimitiveCollection<T>enumerated itsConcurrentDictionarydirectly, so the built-in list handlers returned primitives in hash order:resources/listandresources/templates/listchanged order on every process start, becauseUriTemplate.UriTemplateCompareruses randomized string hashing.tools/listandprompts/listwere stable across runs, but in an arbitrary order that could also change as entries were added or removed.Anything that needs stable ordering (e.g. dynamically constructed context) to avoid cache-busting was affected. Systems that snapshot for integrity might also be affected.
Change
Primitives are now listed in registration order. With
WithTools<T>(),WithPrompts<T>()andWithResources<T>(), that is the order reflection discovers the methods in, which is declaration order in practice. This matches the Python and TypeScript SDKs. A primitive that is removed and added again moves to the end; the class remarks document this.How the collection does it:
ConcurrentDictionaryand stay lock-free.List<Entry>records insertion order.TryAdd,RemoveandCleartake a mutation lock that keeps the dictionary and the list in sync, so every successful mutation has a single ordering point. A primitive that is already visible can't be reordered by a concurrent add.Entry[]snapshot. Mutations invalidate it under the lock and the next read rebuilds it, so repeated list requests don't lock, sort or allocate a copy.GetEnumeratorwalks the snapshot directly;ToArray,PrimitiveNamesandCopyTocopy into caller-owned storage.PrimitiveNamesreturns that key, andRemovereadsprimitive.Idonce and uses the same key for lookup and removal, sinceIdis backed by mutable protocol properties.Removeremoves only the exact entry it compared, so a primitive whoseEqualsreenters the collection and replaces the entry under the same key can't desynchronize the dictionary and the ordered list.RemoveandContainsstill match on both name and instance, andChangedis still raised outside the lock.Ordering within a batch registered from a type or assembly stays as reflection returns it. Sorting by
MetadataTokenwas considered and left out (see discussion).Tests
McpServerPrimitiveCollectionTestscover order for tools, prompts and resources, remove and re-add,Clear, removing a different instance with the same name, a reentrantEqualsthat replaces the entry duringRemove,PrimitiveNamesafter a primitive is renamed, caller-owned copies fromToArray/PrimitiveNames, andCopyToargument validation.Enumeration_AlreadyVisiblePrimitive_KeepsItsPositionuses a key comparer that reenters the collection before the first add is inserted, deterministically forcing the interleaving where a later add becomes visible first. It fails against the earlier sequence-number version of this PR and passes now.Enumeration_AddStalledMidway_LaterAddDoesNotOvertakeItis best-effort threaded coverage of the same scenario: it stalls one add on another thread and gives a second add a window to complete. Waits are bounded so a regression fails rather than hangs.McpServerPrimitiveListOrderTestschecks all four list methods end to end, usingWithTools<T>(),WithPrompts<T>()andWithResources<T>()with names deliberately not in alphabetical order.ModelContextProtocol.Testssuite passes on net10.0 (2408 passed, 5 skipped), and the collection and list-order tests also pass on net8.0.🤖 Generated with Claude Code
https://claude.ai/code/session_01SNwgdcsZB2wxPqPZa2SC4y