Repository navigation
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:
|
| return false; | ||
| } | ||
|
|
||
| _primitives.TryRemove(key, out _); |
There was a problem hiding this comment.
Optional defensive hardening: could we conditionally remove the exact Entry captured above, rather than unconditionally removing by key?
EqualityComparer<T>.Default.Equals can invoke a custom primitive's Equals while the mutation monitor is held. Since the monitor is reentrant, an equality implementation that calls Clear() and adds a replacement under the same key leaves entry stale. This line then removes the replacement from the dictionary, while _orderedEntries.Remove(entry) targets the original entry and leaves the replacement in the ordered list. The resulting collection can have Count == 0 and a failed lookup while ToArray() still returns one primitive.
This requires unusual side-effectful equality and is not a blocker for ordinary built-in primitives, but the safeguard is small:
if (!((ICollection<KeyValuePair<string, Entry>>)_primitives).Remove(new(key, entry)))
{
return false;
}
_orderedEntries.Remove(entry);
_snapshot = null;That way the ordered list, snapshot invalidation, and Changed notification are updated only when the exact compared entry was removed.
| if (obj == stallKey && Interlocked.Exchange(ref _hasStalled, 1) == 0) | ||
| { | ||
| Stalled.Set(); | ||
| Release.Wait(); |
There was a problem hiding this comment.
Nit: could we make the stalled-add test failure-safe?
This Release.Wait() is unbounded and does not observe cancellation, while the test does not guarantee Release.Set() if an earlier assertion or cancellation fails. That can leave a worker permanently blocked. A regression that makes snapshot enumeration wait for the mutation lock can also hang the test rather than produce a useful failure.
Please bound the comparer wait using TestConstants.DefaultTimeout and the test cancellation token, ensure release in a finally block, and await the worker tasks during cleanup. The synchronization objects should be disposed after the workers have finished. The initial hardcoded 10-second wait can also use TestConstants.DefaultTimeout.
|
|
||
| // Give the second add a chance to complete while the first is stalled inside the collection. | ||
| var addSecond = Task.Run(() => collection.Add(CreateTool("second")), TestContext.Current.CancellationToken); | ||
| await Task.WhenAny(addSecond, Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken)); |
There was a problem hiding this comment.
Nit: the 200 ms delay gives the second add a chance to run, but does not establish that it ran before the snapshot was read. If it is scheduled only after the first add is released, whileStalled is empty and the prefix assertion is vacuous; the earlier sequence-number implementation can then pass too. Checking addSecond.IsCompleted instead would not solve that scheduling gap.
Could we retain this as best-effort threaded coverage and add the following deterministic ordering-invariant regression? It deliberately uses a reentrant comparer as a test fixture to force the insertion interleaving; it is not a claim of actual multithreaded execution. The earlier sequence-number implementation observes [second] and then reorders it to [first, second], whereas the current implementation preserves [second, first].
These members fit into this test class and use the existing CreateTool helper:
[Fact]
public void Enumeration_AlreadyVisiblePrimitive_KeepsItsPosition()
{
var comparer = new InsertionInterleavingComparer();
var collection = new McpServerPrimitiveCollection<McpServerTool>(comparer);
string[] whileFirstPending = [];
comparer.BeforeFirstInsertion = () =>
{
collection.Add(CreateTool("second"));
whileFirstPending = collection.ToArray()
.Select(tool => tool.ProtocolTool.Name)
.ToArray();
};
collection.Add(CreateTool("first"));
string[] afterward = collection.ToArray()
.Select(tool => tool.ProtocolTool.Name)
.ToArray();
Assert.Equal(["second"], whileFirstPending);
Assert.Equal(["second", "first"], afterward);
Assert.Equal(
whileFirstPending,
afterward.Take(whileFirstPending.Length));
}
private sealed class InsertionInterleavingComparer : IEqualityComparer<string>
{
private bool _callbackInvoked;
public Action? BeforeFirstInsertion { get; set; }
public bool Equals(string? x, string? y) =>
StringComparer.Ordinal.Equals(x, y);
public int GetHashCode(string value)
{
if (value == "first" && !_callbackInvoked)
{
// Re-enter once, before the first primitive is inserted.
_callbackInvoked = true;
BeforeFirstInsertion?.Invoke();
}
return StringComparer.Ordinal.GetHashCode(value);
}
}The threaded test should not be described as a deterministic reproducer while it still depends on the scheduling window.
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.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,PrimitiveNamesafter a primitive is renamed, caller-owned copies fromToArray/PrimitiveNames, andCopyToargument validation.Enumeration_AddStalledMidway_LaterAddDoesNotOvertakeItuses a key comparer that stalls one add inside the collection while a second add runs, reproducing the interleaving where a later add could become visible first. It fails against the earlier sequence-number version of this PR and passes now.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 (2405 passed, 5 skipped), and the collection and list-order tests pass on net8.0.🤖 Generated with Claude Code
https://claude.ai/code/session_01SNwgdcsZB2wxPqPZa2SC4y