Skip to content

Commit c7b646d

Browse files
authored
Merge branch 'main' into dependabot/nuget/opentelemetry-testing-41c03e88af
2 parents 742b8e0 + 81664c3 commit c7b646d

11 files changed

Lines changed: 1036 additions & 62 deletions

File tree

.github/workflows/ci-build-test.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,9 @@ jobs:
6767
- name: 📦 Install dependencies for tests
6868
run: npm install @modelcontextprotocol/server-memory
6969

70-
- name: 📦 Install dependencies for tests
71-
run: npm install @modelcontextprotocol/conformance
70+
# Keep version in sync with McpConformanceVersion in Directory.Packages.props
71+
- name: 📦 Install conformance test runner
72+
run: npm install @modelcontextprotocol/conformance@0.1.10
7273

7374
- name: 🏗️ Build
7475
run: make build CONFIGURATION=${{ matrix.configuration }}

Directory.Packages.props

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
<System9Version>9.0.11</System9Version>
66
<System10Version>10.0.2</System10Version>
77
<MicrosoftExtensionsVersion>10.2.0</MicrosoftExtensionsVersion>
8+
<!-- Pin the conformance tester Node package version for CI stability.
9+
Keep in sync npm install step at ci-build-test.yml -->
10+
<McpConformanceVersion>0.1.10</McpConformanceVersion>
811
</PropertyGroup>
912

1013
<!-- Product dependencies shared -->

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ var response = await chatClient.GetResponseAsync(
8888

8989
## Getting Started (Server)
9090

91+
> [!TIP]
92+
> You can use the [MCP Server project template](https://learn.microsoft.com/dotnet/ai/quickstarts/build-mcp-server?pivots=visualstudio) to quickly get started with creating your own MCP server.
93+
9194
Here is an example of how to create an MCP server and register all tools from the current application.
9295
It includes a simple echo tool as an example (this is included in the same file here for easy of copy and paste, but it needn't be in the same file...
9396
the employed overload of `WithTools` examines the current assembly for classes with the `McpServerToolType` attribute, and registers all methods with the

src/ModelContextProtocol.Core/UriTemplate.cs

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ internal static partial class UriTemplate
6767
public static Regex CreateParser(string uriTemplate)
6868
{
6969
DefaultInterpolatedStringHandler pattern = new(0, 0, CultureInfo.InvariantCulture, stackalloc char[256]);
70-
pattern.AppendFormatted('^');
70+
pattern.AppendLiteral("^");
7171

7272
int lastIndex = 0;
7373
for (Match m = UriTemplateExpression().Match(uriTemplate); m.Success; m = m.NextMatch())
@@ -84,17 +84,20 @@ public static Regex CreateParser(string uriTemplate)
8484

8585
switch (m.Groups["operator"].Value)
8686
{
87-
case "#": AppendExpression(ref pattern, paramNames, '#', "[^,]+"); break;
88-
case "/": AppendExpression(ref pattern, paramNames, '/', "[^/?]+"); break;
89-
default: AppendExpression(ref pattern, paramNames, null, "[^/?&]+"); break;
90-
87+
case "+": AppendExpression(ref pattern, paramNames, null, "[^?&#]*"); break;
88+
case "#": AppendExpression(ref pattern, paramNames, '#', ".*"); break;
89+
case ".": AppendExpression(ref pattern, paramNames, '.', "[^/?#]*"); break;
90+
case "/": AppendExpression(ref pattern, paramNames, '/', "[^/?#]*"); break;
91+
default: AppendExpression(ref pattern, paramNames, null, "[^/?&#]*"); break;
92+
9193
case "?": AppendQueryExpression(ref pattern, paramNames, '?'); break;
9294
case "&": AppendQueryExpression(ref pattern, paramNames, '&'); break;
95+
case ";": AppendPathParameterExpression(ref pattern, paramNames); break;
9396
}
9497
}
9598

9699
pattern.AppendFormatted(Regex.Escape(uriTemplate.Substring(lastIndex)));
97-
pattern.AppendFormatted('$');
100+
pattern.AppendLiteral("$");
98101

99102
return new Regex(
100103
pattern.ToStringAndClear(),
@@ -113,63 +116,101 @@ static void AppendQueryExpression(ref DefaultInterpolatedStringHandler pattern,
113116
{
114117
Debug.Assert(prefix is '?' or '&');
115118

116-
pattern.AppendFormatted("(?:\\");
119+
pattern.AppendLiteral("(?:\\");
117120
pattern.AppendFormatted(prefix);
118121

119122
if (paramNames.Count > 0)
120123
{
121124
AppendParameter(ref pattern, paramNames[0]);
122125
for (int i = 1; i < paramNames.Count; i++)
123126
{
124-
pattern.AppendFormatted("\\&?");
127+
pattern.AppendLiteral("\\&?");
125128
AppendParameter(ref pattern, paramNames[i]);
126129
}
127130

128131
static void AppendParameter(ref DefaultInterpolatedStringHandler pattern, string paramName)
129132
{
130133
paramName = Regex.Escape(paramName);
131-
pattern.AppendFormatted("(?:");
134+
pattern.AppendLiteral("(?:");
132135
pattern.AppendFormatted(paramName);
133-
pattern.AppendFormatted("=(?<");
136+
pattern.AppendLiteral("=(?<");
134137
pattern.AppendFormatted(paramName);
135-
pattern.AppendFormatted(">[^/?&]+))?");
138+
pattern.AppendLiteral(">[^/?&]*))?");
136139
}
137140
}
138141

139-
pattern.AppendFormatted(")?");
142+
pattern.AppendLiteral(")?");
140143
}
141144

142145
// Chooses a regex character‐class (`valueChars`) based on the initial `prefix` to define which
143146
// characters make up a parameter value. Then, for each name in `paramNames`, it optionally
144147
// appends the escaped `prefix` (only on the first parameter, then switches to ','), and
145148
// adds an optional named capture group `(?<paramName>valueChars)` to match and capture that value.
149+
// Note: For "+" (reserved expansion) operator, prefix is null but valueChars allows "/" characters.
150+
// Note: For "." (label expansion) operator, the separator is "." instead of ",".
146151
static void AppendExpression(ref DefaultInterpolatedStringHandler pattern, List<string> paramNames, char? prefix, string valueChars)
147152
{
148-
Debug.Assert(prefix is '#' or '/' or null);
153+
Debug.Assert(prefix is '#' or '/' or '.' or null);
149154

150155
if (paramNames.Count > 0)
151156
{
152157
if (prefix is not null)
153158
{
154-
pattern.AppendFormatted('\\');
159+
pattern.AppendLiteral("\\");
155160
pattern.AppendFormatted(prefix);
156-
pattern.AppendFormatted('?');
161+
pattern.AppendLiteral("?");
157162
}
158163

159164
AppendParameter(ref pattern, paramNames[0], valueChars);
165+
166+
// For label expansion (.), the separator between values is also a dot
167+
// For path segment expansion (/), the separator between values is also a slash
168+
string separator = prefix switch
169+
{
170+
'.' => "\\.",
171+
'/' => "\\/",
172+
_ => "\\,"
173+
};
160174
for (int i = 1; i < paramNames.Count; i++)
161175
{
162-
pattern.AppendFormatted("\\,?");
176+
pattern.AppendFormatted(separator);
177+
pattern.AppendLiteral("?");
163178
AppendParameter(ref pattern, paramNames[i], valueChars);
164179
}
165180

166181
static void AppendParameter(ref DefaultInterpolatedStringHandler pattern, string paramName, string valueChars)
167182
{
168-
pattern.AppendFormatted("(?<");
183+
pattern.AppendLiteral("(?<");
169184
pattern.AppendFormatted(Regex.Escape(paramName));
170-
pattern.AppendFormatted('>');
185+
pattern.AppendLiteral(">");
171186
pattern.AppendFormatted(valueChars);
172-
pattern.AppendFormatted(")?");
187+
pattern.AppendLiteral(")?");
188+
}
189+
}
190+
}
191+
192+
// Appends a regex fragment for path-style parameter expansion (;).
193+
// Format: ;name=value or ;name (if value is empty), separated by semicolons.
194+
// Each parameter is made optional and captured by a named group.
195+
static void AppendPathParameterExpression(ref DefaultInterpolatedStringHandler pattern, List<string> paramNames)
196+
{
197+
if (paramNames.Count > 0)
198+
{
199+
AppendParameter(ref pattern, paramNames[0]);
200+
for (int i = 1; i < paramNames.Count; i++)
201+
{
202+
AppendParameter(ref pattern, paramNames[i]);
203+
}
204+
205+
static void AppendParameter(ref DefaultInterpolatedStringHandler pattern, string paramName)
206+
{
207+
// Match ;name or ;name=value
208+
paramName = Regex.Escape(paramName);
209+
pattern.AppendLiteral("(?:;");
210+
pattern.AppendFormatted(paramName);
211+
pattern.AppendLiteral("(?:=(?<");
212+
pattern.AppendFormatted(paramName);
213+
pattern.AppendLiteral(">[^;/?&]*))?)?");
173214
}
174215
}
175216
}
@@ -363,7 +404,7 @@ value as string ??
363404
}
364405
}
365406

366-
if (expansions.Count > 0 &&
407+
if (expansions.Count > 0 &&
367408
(modifierBehavior.PrefixEmptyExpansions || !expansions.All(string.IsNullOrEmpty)))
368409
{
369410
builder.AppendLiteral(modifierBehavior.Prefix);
@@ -435,7 +476,7 @@ static void AppendHex(ref DefaultInterpolatedStringHandler builder, char c)
435476

436477
if (c <= 0x7F)
437478
{
438-
builder.AppendFormatted('%');
479+
builder.AppendLiteral("%");
439480
builder.AppendFormatted(hexDigits[c >> 4]);
440481
builder.AppendFormatted(hexDigits[c & 0xF]);
441482
}
@@ -448,7 +489,7 @@ static void AppendHex(ref DefaultInterpolatedStringHandler builder, char c)
448489
foreach (byte b in Encoding.UTF8.GetBytes([c]))
449490
#endif
450491
{
451-
builder.AppendFormatted('%');
492+
builder.AppendLiteral("%");
452493
builder.AppendFormatted(hexDigits[b >> 4]);
453494
builder.AppendFormatted(hexDigits[b & 0xF]);
454495
}
@@ -460,13 +501,13 @@ static void AppendHex(ref DefaultInterpolatedStringHandler builder, char c)
460501
/// Defines an equality comparer for Uri templates as follows:
461502
/// 1. Non-templated Uris use regular System.Uri equality comparison (host name is case insensitive).
462503
/// 2. Templated Uris use regular string equality.
463-
///
504+
///
464505
/// We do this because non-templated resources are looked up directly from the resource dictionary
465506
/// and we need to make sure equality is implemented correctly. Templated Uris are resolved in a
466507
/// fallback step using linear traversal of the resource dictionary, so their equality is only
467508
/// there to distinguish between different templates.
468509
/// </summary>
469-
public sealed class UriTemplateComparer : IEqualityComparer<string>
510+
internal sealed class UriTemplateComparer : IEqualityComparer<string>
470511
{
471512
public static IEqualityComparer<string> Instance { get; } = new UriTemplateComparer();
472513

tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@
5151
<PackageReference Include="System.Linq.AsyncEnumerable" />
5252
</ItemGroup>
5353

54+
<ItemGroup>
55+
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
56+
<_Parameter1>McpConformanceVersion</_Parameter1>
57+
<_Parameter2>$(McpConformanceVersion)</_Parameter2>
58+
</AssemblyAttribute>
59+
</ItemGroup>
60+
5461
<ItemGroup>
5562
<ProjectReference Include="..\..\samples\TestServerWithHosting\TestServerWithHosting.csproj" />
5663
<ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" />

tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTests.cs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ public async Task Client_CanResumePostResponseStream_AfterDisconnection()
279279
[Fact]
280280
public async Task Client_CanResumeUnsolicitedMessageStream_AfterDisconnection()
281281
{
282+
var timeout = TimeSpan.FromSeconds(10);
282283
using var faultingStreamHandler = new FaultingStreamHandler()
283284
{
284285
InnerHandler = SocketsHttpHandler,
@@ -304,12 +305,12 @@ public async Task Client_CanResumeUnsolicitedMessageStream_AfterDisconnection()
304305
await using var client = await ConnectClientAsync();
305306

306307
// Get the server instance
307-
var server = await serverTcs.Task.WaitAsync(TestContext.Current.CancellationToken);
308+
var server = await serverTcs.Task.WaitAsync(timeout, TestContext.Current.CancellationToken);
308309

309310
// Set up notification tracking with unique messages
310-
var clientReceivedInitialNotificationTcs = new TaskCompletionSource();
311-
var clientReceivedReplayedNotificationTcs = new TaskCompletionSource();
312-
var clientReceivedReconnectNotificationTcs = new TaskCompletionSource();
311+
var clientReceivedInitialNotificationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
312+
var clientReceivedReplayedNotificationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
313+
var clientReceivedReconnectNotificationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
313314

314315
const string CustomNotificationMethod = "test/custom_notification";
315316
const string InitialMessage = "Initial notification";
@@ -343,11 +344,14 @@ public async Task Client_CanResumeUnsolicitedMessageStream_AfterDisconnection()
343344
return default;
344345
});
345346

347+
// Wait for the client's unsolicited message stream to be established before sending notifications
348+
await faultingStreamHandler.WaitForUnsolicitedMessageStreamAsync(TestContext.Current.CancellationToken);
349+
346350
// Send a custom notification to the client on the unsolicited message stream
347351
await server.SendNotificationAsync(CustomNotificationMethod, new JsonObject { ["message"] = InitialMessage }, cancellationToken: TestContext.Current.CancellationToken);
348352

349353
// Wait for client to receive the first notification
350-
await clientReceivedInitialNotificationTcs.Task.WaitAsync(TestContext.Current.CancellationToken);
354+
await clientReceivedInitialNotificationTcs.Task.WaitAsync(timeout, TestContext.Current.CancellationToken);
351355

352356
// Fault the unsolicited message stream (GET SSE)
353357
var reconnectAttempt = await faultingStreamHandler.TriggerFaultAsync(TestContext.Current.CancellationToken);
@@ -359,13 +363,13 @@ public async Task Client_CanResumeUnsolicitedMessageStream_AfterDisconnection()
359363
reconnectAttempt.Continue();
360364

361365
// Wait for client to receive the notification via replay
362-
await clientReceivedReplayedNotificationTcs.Task.WaitAsync(TestContext.Current.CancellationToken);
366+
await clientReceivedReplayedNotificationTcs.Task.WaitAsync(timeout, TestContext.Current.CancellationToken);
363367

364368
// Send a final notification while the client has reconnected - this should be handled by the transport
365369
await server.SendNotificationAsync(CustomNotificationMethod, new JsonObject { ["message"] = ReconnectMessage }, cancellationToken: TestContext.Current.CancellationToken);
366370

367371
// Wait for the client to receive the final notification
368-
await clientReceivedReconnectNotificationTcs.Task.WaitAsync(TestContext.Current.CancellationToken);
372+
await clientReceivedReconnectNotificationTcs.Task.WaitAsync(timeout, TestContext.Current.CancellationToken);
369373

370374
// Assert each notification was received exactly once
371375
Assert.Equal(1, initialNotificationReceivedCount);
@@ -531,7 +535,7 @@ public async Task PostResponse_EndsAndSseEventStreamWriterIsDisposed_WhenWriteEv
531535
timeoutCts.CancelAfter(TimeSpan.FromSeconds(10));
532536

533537
// The call task should throw an OCE due to cancellation
534-
await Assert.ThrowsAsync<OperationCanceledException>(() => callTask).WaitAsync(timeoutCts.Token);
538+
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => callTask).WaitAsync(timeoutCts.Token);
535539

536540
// Wait for the writer to be disposed
537541
await blockingStore.DisposedTask.WaitAsync(timeoutCts.Token);

tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,21 @@ private void StartConformanceServer()
115115
_serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(["--urls", _serverUrl], new XunitLoggerProvider(_output), cancellationToken: _serverCts.Token));
116116
}
117117

118+
private static string GetConformanceVersion()
119+
{
120+
var assembly = typeof(ServerConformanceTests).Assembly;
121+
var attribute = assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyMetadataAttribute), false)
122+
.Cast<System.Reflection.AssemblyMetadataAttribute>()
123+
.FirstOrDefault(a => a.Key == "McpConformanceVersion");
124+
return attribute?.Value ?? throw new InvalidOperationException("McpConformanceVersion not found in assembly metadata");
125+
}
126+
118127
private async Task<(bool Success, string Output, string Error)> RunNpxConformanceTests()
119128
{
120-
var startInfo = NodeHelpers.NpxStartInfo($"-y @modelcontextprotocol/conformance server --url {_serverUrl}");
129+
// Version is configured in Directory.Packages.props for central management
130+
var version = GetConformanceVersion();
131+
132+
var startInfo = NodeHelpers.NpxStartInfo($"-y @modelcontextprotocol/conformance@{version} server --url {_serverUrl}");
121133

122134
var outputBuilder = new StringBuilder();
123135
var errorBuilder = new StringBuilder();

0 commit comments

Comments
 (0)