From 8af818a54d244fcfba1cf271dc3a8fdf88acfbbe Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Mon, 12 Jan 2026 18:58:28 -0800 Subject: [PATCH 1/6] Fix reserved expansion (e.g. "{+var}") --- src/ModelContextProtocol.Core/UriTemplate.cs | 8 +- .../McpServerResourceRoutingTests.cs | 4 + .../UriTemplateCreateParserTests.cs | 458 ++++++++++++++++++ 3 files changed, 467 insertions(+), 3 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/UriTemplateCreateParserTests.cs diff --git a/src/ModelContextProtocol.Core/UriTemplate.cs b/src/ModelContextProtocol.Core/UriTemplate.cs index 447ec004c..b3b22517a 100644 --- a/src/ModelContextProtocol.Core/UriTemplate.cs +++ b/src/ModelContextProtocol.Core/UriTemplate.cs @@ -84,10 +84,11 @@ public static Regex CreateParser(string uriTemplate) switch (m.Groups["operator"].Value) { + case "+": AppendExpression(ref pattern, paramNames, null, "[^?&]+"); break; case "#": AppendExpression(ref pattern, paramNames, '#', "[^,]+"); break; case "/": AppendExpression(ref pattern, paramNames, '/', "[^/?]+"); break; default: AppendExpression(ref pattern, paramNames, null, "[^/?&]+"); break; - + case "?": AppendQueryExpression(ref pattern, paramNames, '?'); break; case "&": AppendQueryExpression(ref pattern, paramNames, '&'); break; } @@ -143,6 +144,7 @@ static void AppendParameter(ref DefaultInterpolatedStringHandler pattern, string // characters make up a parameter value. Then, for each name in `paramNames`, it optionally // appends the escaped `prefix` (only on the first parameter, then switches to ','), and // adds an optional named capture group `(?valueChars)` to match and capture that value. + // Note: For "+" (reserved expansion) operator, prefix is null but valueChars allows "/" characters. static void AppendExpression(ref DefaultInterpolatedStringHandler pattern, List paramNames, char? prefix, string valueChars) { Debug.Assert(prefix is '#' or '/' or null); @@ -363,7 +365,7 @@ value as string ?? } } - if (expansions.Count > 0 && + if (expansions.Count > 0 && (modifierBehavior.PrefixEmptyExpansions || !expansions.All(string.IsNullOrEmpty))) { builder.AppendLiteral(modifierBehavior.Prefix); @@ -460,7 +462,7 @@ static void AppendHex(ref DefaultInterpolatedStringHandler builder, char c) /// Defines an equality comparer for Uri templates as follows: /// 1. Non-templated Uris use regular System.Uri equality comparison (host name is case insensitive). /// 2. Templated Uris use regular string equality. - /// + /// /// We do this because non-templated resources are looked up directly from the resource dictionary /// and we need to make sure equality is implemented correctly. Templated Uris are resolved in a /// fallback step using linear traversal of the resource dictionary, so their equality is only diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs index 951e0651d..9b4b4af30 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs @@ -13,6 +13,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer McpServerResource.Create(options: new() { UriTemplate = "test://resource/non-templated" } , method: () => "static"), McpServerResource.Create(options: new() { UriTemplate = "test://resource/{id}" }, method: (string id) => $"template: {id}"), McpServerResource.Create(options: new() { UriTemplate = "test://params{?a1,a2,a3}" }, method: (string a1, string a2, string a3) => $"params: {a1}, {a2}, {a3}"), + McpServerResource.Create(options: new() { UriTemplate = "file://{prefix}/{+path}" }, method: (string prefix, string path) => $"prefix: {prefix}, path: {path}"), ]); } @@ -35,6 +36,9 @@ public async Task MultipleTemplatedResources_MatchesCorrectResource() var paramsResult = await client.ReadResourceAsync("test://params?a1=a&a2=b&a3=c", null, TestContext.Current.CancellationToken); Assert.Equal("params: a, b, c", ((TextResourceContents)paramsResult.Contents[0]).Text); + var pathResult = await client.ReadResourceAsync("file://foo/examples/example.rs", null, TestContext.Current.CancellationToken); + Assert.Equal("prefix: foo, path: examples/example.rs", ((TextResourceContents)pathResult.Contents[0]).Text); + var mcpEx = await Assert.ThrowsAsync(async () => await client.ReadResourceAsync("test://params{?a1,a2,a3}", null, TestContext.Current.CancellationToken)); Assert.Equal(McpErrorCode.ResourceNotFound, mcpEx.ErrorCode); Assert.Equal("Request failed (remote): Unknown resource URI: 'test://params{?a1,a2,a3}'", mcpEx.Message); diff --git a/tests/ModelContextProtocol.Tests/UriTemplateCreateParserTests.cs b/tests/ModelContextProtocol.Tests/UriTemplateCreateParserTests.cs new file mode 100644 index 000000000..fe613d7a4 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/UriTemplateCreateParserTests.cs @@ -0,0 +1,458 @@ +using System.Reflection; +using System.Text.RegularExpressions; + +namespace ModelContextProtocol.Tests; + +/// +/// Comprehensive test suite for UriTemplate.CreateParser method. +/// Tests are based on RFC 6570 (URI Template) specification. +/// +/// Since UriTemplate is internal, these tests use reflection to access it. +/// +public sealed class UriTemplateCreateParserTests +{ + // Access the internal UriTemplate class via reflection + private static readonly Type s_uriTemplateType; + private static readonly MethodInfo s_createParserMethod; + + static UriTemplateCreateParserTests() + { + var assembly = typeof(McpException).Assembly; + s_uriTemplateType = assembly.GetType("ModelContextProtocol.UriTemplate", throwOnError: true)!; + s_createParserMethod = s_uriTemplateType.GetMethod("CreateParser", BindingFlags.Static | BindingFlags.Public)!; + } + + private static Regex CreateParser(string template) + { + return (Regex)s_createParserMethod.Invoke(null, [template])!; + } + + private static Match MatchUri(string template, string uri) + { + var regex = CreateParser(template); + return regex.Match(uri); + } + + private static void AssertMatch(string template, string uri, params (string name, string value)[] expectedGroups) + { + var match = MatchUri(template, uri); + Assert.True(match.Success, $"Template '{template}' should match URI '{uri}'"); + + foreach (var (name, value) in expectedGroups) + { + Assert.True(match.Groups[name].Success, $"Group '{name}' should be captured"); + Assert.Equal(value, match.Groups[name].Value); + } + } + + private static void AssertNoMatch(string template, string uri) + { + var match = MatchUri(template, uri); + Assert.False(match.Success, $"Template '{template}' should NOT match URI '{uri}'"); + } + + #region Level 1: Simple String Expansion {var} + + [Fact] + public void SimpleExpansion_MatchesSingleVariable() + { + AssertMatch("http://example.com/{var}", "http://example.com/value", ("var", "value")); + } + + [Fact] + public void SimpleExpansion_DoesNotMatchSlash() + { + // Simple expansion should NOT match slashes + AssertNoMatch("http://example.com/{var}", "http://example.com/foo/bar"); + } + + [Fact] + public void SimpleExpansion_DoesNotMatchQuestionMark() + { + // Simple expansion should NOT match query string characters + AssertNoMatch("http://example.com/{var}", "http://example.com/foo?query"); + } + + [Fact] + public void SimpleExpansion_MultipleVariables() + { + AssertMatch( + "http://example.com/{x}/{y}", + "http://example.com/1024/768", + ("x", "1024"), + ("y", "768")); + } + + #endregion + + #region Level 2: Reserved Expansion {+var} - REGRESSION TESTS FOR BUG FIX + + /// + /// FIXED BUG: Reserved expansion {+var} should match slashes. + /// This was the bug that caused samples://{dependency}/{+path} to fail. + /// Per RFC 6570 Section 3.2.3, the + operator allows reserved characters including "/" to pass through. + /// + [Fact] + public void ReservedExpansion_MatchesSlashes() + { + // FIXED: {+path} should match paths containing slashes + AssertMatch( + "samples://{dependency}/{+path}", + "samples://foo/README.md", + ("dependency", "foo"), + ("path", "README.md")); + } + + /// + /// FIXED BUG: Reserved expansion with nested path containing slashes. + /// This is the exact failing case from the issue. + /// + [Fact] + public void ReservedExpansion_MatchesNestedPath() + { + // FIXED: {+path} should match paths with multiple segments + AssertMatch( + "samples://{dependency}/{+path}", + "samples://foo/examples/example.rs", + ("dependency", "foo"), + ("path", "examples/example.rs")); + } + + /// + /// FIXED BUG: Reserved expansion with deep nested path. + /// + [Fact] + public void ReservedExpansion_MatchesDeeplyNestedPath() + { + // FIXED: {+path} should match deeply nested paths + AssertMatch( + "samples://{dependency}/{+path}", + "samples://mylib/src/components/utils/helper.ts", + ("dependency", "mylib"), + ("path", "src/components/utils/helper.ts")); + } + + [Fact] + public void ReservedExpansion_SimpleValue() + { + // Reserved expansion should still work for simple values without slashes + AssertMatch("{+var}", "value", ("var", "value")); + } + + [Fact] + public void ReservedExpansion_WithPathStartingWithSlash() + { + // Reserved expansion allows reserved URI characters like / + AssertMatch("{+path}", "/foo/bar", ("path", "/foo/bar")); + } + + [Fact] + public void ReservedExpansion_StopsAtQueryString() + { + // Reserved expansion should stop at ? (query string delimiter) + var match = MatchUri("http://example.com/{+path}", "http://example.com/foo/bar?query=test"); + // The match may succeed but only capture up to the ? + if (match.Success) + { + Assert.DoesNotContain("?", match.Groups["path"].Value); + } + } + + #endregion + + #region Level 2: Fragment Expansion {#var} + + [Fact] + public void FragmentExpansion_MatchesWithHashPrefix() + { + AssertMatch("http://example.com/page{#section}", "http://example.com/page#intro", ("section", "intro")); + } + + [Fact] + public void FragmentExpansion_MatchesSlashes() + { + // Fragment expansion allows reserved characters including / + AssertMatch("{#path}", "#/foo/bar", ("path", "/foo/bar")); + } + + [Fact] + public void FragmentExpansion_OptionalFragment() + { + // Fragment should be optional + var match = MatchUri("http://example.com/page{#section}", "http://example.com/page"); + Assert.True(match.Success); + } + + #endregion + + #region Level 3: Path Segment Expansion {/var} + + [Fact] + public void PathSegmentExpansion_MatchesSingleSegment() + { + AssertMatch("{/var}", "/value", ("var", "value")); + } + + [Fact] + public void PathSegmentExpansion_MultipleSegments() + { + // Note: Multiple comma-separated variables in path expansion with / operator + // This tests the current implementation behavior. + // The template {/x,y} expands to paths like "/value1/value2" + var match = MatchUri("{/x,y}", "/1024/768"); + // The implementation may or may not fully support this, just verify regex creation works + Assert.NotNull(CreateParser("{/x,y}")); + } + + [Fact] + public void PathSegmentExpansion_DoesNotMatchSlashInValue() + { + // Path segment expansion should NOT match slashes within a single variable's value + // Each variable should match one segment only + var match = MatchUri("{/var}", "/foo/bar"); + // This should either not match or only capture "foo" + if (match.Success) + { + Assert.Equal("foo", match.Groups["var"].Value); + } + } + + [Fact] + public void PathSegmentExpansion_CombinedWithLiterals() + { + AssertMatch("/users{/id}", "/users/123", ("id", "123")); + } + + #endregion + + #region Level 3: Form-Style Query Expansion {?var} + + [Fact] + public void QueryExpansion_MatchesSingleParameter() + { + AssertMatch("http://example.com/search{?q}", "http://example.com/search?q=test", ("q", "test")); + } + + [Fact] + public void QueryExpansion_MatchesMultipleParameters() + { + AssertMatch( + "http://example.com/search{?q,lang}", + "http://example.com/search?q=cat&lang=en", + ("q", "cat"), + ("lang", "en")); + } + + [Fact] + public void QueryExpansion_OptionalParameters() + { + // All query parameters should be optional + var match = MatchUri("http://example.com/search{?q,lang}", "http://example.com/search"); + Assert.True(match.Success); + } + + [Fact] + public void QueryExpansion_PartialParameters() + { + // Should match even if not all parameters are present + var match = MatchUri("http://example.com/search{?q,lang}", "http://example.com/search?q=test"); + Assert.True(match.Success); + Assert.Equal("test", match.Groups["q"].Value); + } + + [Fact] + public void QueryExpansion_ThreeParameters() + { + AssertMatch( + "test://params{?a1,a2,a3}", + "test://params?a1=a&a2=b&a3=c", + ("a1", "a"), + ("a2", "b"), + ("a3", "c")); + } + + #endregion + + #region Level 3: Form-Style Query Continuation {&var} + + [Fact] + public void QueryContinuation_MatchesWithExistingQuery() + { + AssertMatch( + "http://example.com/search?fixed=yes{&x}", + "http://example.com/search?fixed=yes&x=1024", + ("x", "1024")); + } + + [Fact] + public void QueryContinuation_MultipleParameters() + { + AssertMatch( + "http://example.com/search?start=0{&x,y}", + "http://example.com/search?start=0&x=1024&y=768", + ("x", "1024"), + ("y", "768")); + } + + #endregion + + #region Edge Cases and Special Characters + + [Fact] + public void PctEncodedInValue_MatchesEncodedCharacters() + { + // Values containing percent-encoded characters + AssertMatch("{var}", "Hello%20World", ("var", "Hello%20World")); + } + + [Fact] + public void EmptyTemplate_MatchesEmpty() + { + var match = MatchUri("", ""); + Assert.True(match.Success); + } + + [Fact] + public void LiteralOnlyTemplate_MatchesExactly() + { + var match = MatchUri("http://example.com/static", "http://example.com/static"); + Assert.True(match.Success); + } + + [Fact] + public void LiteralOnlyTemplate_DoesNotMatchDifferentUri() + { + AssertNoMatch("http://example.com/static", "http://example.com/dynamic"); + } + + [Fact] + public void CaseInsensitiveMatching() + { + // URI matching should be case-insensitive for the host portion + var match = MatchUri("http://EXAMPLE.COM/{var}", "http://example.com/value"); + Assert.True(match.Success); + } + + #endregion + + #region Complex Real-World Templates + + [Fact] + public void RealWorld_GitHubApiStyle() + { + AssertMatch( + "https://api.github.com/repos/{owner}/{repo}/contents/{+path}", + "https://api.github.com/repos/microsoft/vscode/contents/src/vs/editor/editor.main.ts", + ("owner", "microsoft"), + ("repo", "vscode"), + ("path", "src/vs/editor/editor.main.ts")); + } + + [Fact] + public void RealWorld_FileSystemPath() + { + AssertMatch( + "file:///{+path}", + "file:///home/user/documents/file.txt", + ("path", "home/user/documents/file.txt")); + } + + [Fact] + public void RealWorld_ResourceWithOptionalQuery() + { + AssertMatch( + "test://resource/{id}{?format,version}", + "test://resource/12345?format=json&version=2", + ("id", "12345"), + ("format", "json"), + ("version", "2")); + } + + [Fact] + public void RealWorld_NonTemplatedUri() + { + // Non-templated URIs should match exactly + var match = MatchUri("test://resource/non-templated", "test://resource/non-templated"); + Assert.True(match.Success); + } + + [Fact] + public void RealWorld_MixedTemplateAndLiteral() + { + AssertMatch( + "http://example.com/users/{userId}/posts/{postId}", + "http://example.com/users/42/posts/100", + ("userId", "42"), + ("postId", "100")); + } + + /// + /// FIXED BUG: The exact case from the bug report - samples scheme with dependency and path. + /// + [Fact] + public void RealWorld_SamplesSchemeWithDependency() + { + AssertMatch( + "samples://{dependency}/{+path}", + "samples://csharp-sdk/README.md", + ("dependency", "csharp-sdk"), + ("path", "README.md")); + } + + #endregion + + #region Operator Combinations + + [Fact] + public void CombinedOperators_PathAndQuery() + { + AssertMatch( + "/api{/version}/resource{?page,limit}", + "/api/v2/resource?page=1&limit=10", + ("version", "v2"), + ("page", "1"), + ("limit", "10")); + } + + [Fact] + public void CombinedOperators_ReservedAndFragment() + { + // Note: Reserved expansion is greedy - when combined with fragment expansion, + // the reserved expansion may capture the fragment delimiter. + // This tests the current implementation behavior. + var match = MatchUri("{+base}{#section}", "http://example.com/#intro"); + Assert.True(match.Success); + // The reserved expansion {+base} may capture up to (and possibly including) the # + // Just verify the template parses correctly + Assert.NotNull(CreateParser("{+base}{#section}")); + } + + #endregion + + #region Variable Modifiers (prefix `:n`) + + [Fact] + public void PrefixModifier_InTemplate() + { + // Templates with prefix modifiers should still parse + // The regex just captures whatever matches + var regex = CreateParser("{var:3}"); + Assert.NotNull(regex); + var match = regex.Match("val"); + Assert.True(match.Success); + } + + #endregion + + #region Explode Modifier (`*`) + + [Fact] + public void ExplodeModifier_InTemplate() + { + // Templates with explode modifiers should still parse + var regex = CreateParser("{/list*}"); + Assert.NotNull(regex); + } + + #endregion +} From 0443e70947a5a3fe343a80263d12a8fa5b002df5 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Mon, 12 Jan 2026 19:42:11 -0800 Subject: [PATCH 2/6] Fix label expansion and path-style parameters - Add more test cases --- src/ModelContextProtocol.Core/UriTemplate.cs | 53 ++- .../UriTemplateCreateParserTests.cs | 443 +++++++++++++++--- 2 files changed, 417 insertions(+), 79 deletions(-) diff --git a/src/ModelContextProtocol.Core/UriTemplate.cs b/src/ModelContextProtocol.Core/UriTemplate.cs index b3b22517a..3662133c2 100644 --- a/src/ModelContextProtocol.Core/UriTemplate.cs +++ b/src/ModelContextProtocol.Core/UriTemplate.cs @@ -15,7 +15,7 @@ namespace ModelContextProtocol; /// This implementation should correctly handle valid URI templates, but it has undefined output for invalid templates, /// e.g. it may treat portions of invalid templates as literals rather than throwing. /// -internal static partial class UriTemplate +public static partial class UriTemplate { /// Regex pattern for finding URI template expressions and parsing out the operator and varname. private const string UriTemplateExpressionPattern = """ @@ -84,10 +84,12 @@ public static Regex CreateParser(string uriTemplate) switch (m.Groups["operator"].Value) { - case "+": AppendExpression(ref pattern, paramNames, null, "[^?&]+"); break; + case "+": AppendExpression(ref pattern, paramNames, null, "[^?&#]+"); break; case "#": AppendExpression(ref pattern, paramNames, '#', "[^,]+"); break; - case "/": AppendExpression(ref pattern, paramNames, '/', "[^/?]+"); break; - default: AppendExpression(ref pattern, paramNames, null, "[^/?&]+"); break; + case ".": AppendExpression(ref pattern, paramNames, '.', "[^./?#]+"); break; + case "/": AppendExpression(ref pattern, paramNames, '/', "[^/?#]+"); break; + case ";": AppendPathParameterExpression(ref pattern, paramNames); break; + default: AppendExpression(ref pattern, paramNames, null, "[^/?&#]+"); break; case "?": AppendQueryExpression(ref pattern, paramNames, '?'); break; case "&": AppendQueryExpression(ref pattern, paramNames, '&'); break; @@ -145,9 +147,10 @@ static void AppendParameter(ref DefaultInterpolatedStringHandler pattern, string // appends the escaped `prefix` (only on the first parameter, then switches to ','), and // adds an optional named capture group `(?valueChars)` to match and capture that value. // Note: For "+" (reserved expansion) operator, prefix is null but valueChars allows "/" characters. + // Note: For "." (label expansion) operator, the separator is "." instead of ",". static void AppendExpression(ref DefaultInterpolatedStringHandler pattern, List paramNames, char? prefix, string valueChars) { - Debug.Assert(prefix is '#' or '/' or null); + Debug.Assert(prefix is '#' or '/' or '.' or null); if (paramNames.Count > 0) { @@ -159,9 +162,19 @@ static void AppendExpression(ref DefaultInterpolatedStringHandler pattern, List< } AppendParameter(ref pattern, paramNames[0], valueChars); + + // For label expansion (.), the separator between values is also a dot + // For path segment expansion (/), the separator between values is also a slash + string separator = prefix switch + { + '.' => "\\.", + '/' => "\\/", + _ => "\\," + }; for (int i = 1; i < paramNames.Count; i++) { - pattern.AppendFormatted("\\,?"); + pattern.AppendFormatted(separator); + pattern.AppendFormatted('?'); AppendParameter(ref pattern, paramNames[i], valueChars); } @@ -175,6 +188,32 @@ static void AppendParameter(ref DefaultInterpolatedStringHandler pattern, string } } } + + // Appends a regex fragment for path-style parameter expansion (;). + // Format: ;name=value or ;name (if value is empty), separated by semicolons. + // Each parameter is made optional and captured by a named group. + static void AppendPathParameterExpression(ref DefaultInterpolatedStringHandler pattern, List paramNames) + { + if (paramNames.Count > 0) + { + AppendParameter(ref pattern, paramNames[0]); + for (int i = 1; i < paramNames.Count; i++) + { + AppendParameter(ref pattern, paramNames[i]); + } + + static void AppendParameter(ref DefaultInterpolatedStringHandler pattern, string paramName) + { + // Match ;name or ;name=value + paramName = Regex.Escape(paramName); + pattern.AppendFormatted("(?:;"); + pattern.AppendFormatted(paramName); + pattern.AppendFormatted("(?:=(?<"); + pattern.AppendFormatted(paramName); + pattern.AppendFormatted(">[^;/?&]+))?)?"); + } + } + } } /// @@ -468,7 +507,7 @@ static void AppendHex(ref DefaultInterpolatedStringHandler builder, char c) /// fallback step using linear traversal of the resource dictionary, so their equality is only /// there to distinguish between different templates. /// - public sealed class UriTemplateComparer : IEqualityComparer + internal sealed class UriTemplateComparer : IEqualityComparer { public static IEqualityComparer Instance { get; } = new UriTemplateComparer(); diff --git a/tests/ModelContextProtocol.Tests/UriTemplateCreateParserTests.cs b/tests/ModelContextProtocol.Tests/UriTemplateCreateParserTests.cs index fe613d7a4..ba9f568c0 100644 --- a/tests/ModelContextProtocol.Tests/UriTemplateCreateParserTests.cs +++ b/tests/ModelContextProtocol.Tests/UriTemplateCreateParserTests.cs @@ -1,4 +1,3 @@ -using System.Reflection; using System.Text.RegularExpressions; namespace ModelContextProtocol.Tests; @@ -6,26 +5,10 @@ namespace ModelContextProtocol.Tests; /// /// Comprehensive test suite for UriTemplate.CreateParser method. /// Tests are based on RFC 6570 (URI Template) specification. -/// -/// Since UriTemplate is internal, these tests use reflection to access it. /// public sealed class UriTemplateCreateParserTests { - // Access the internal UriTemplate class via reflection - private static readonly Type s_uriTemplateType; - private static readonly MethodInfo s_createParserMethod; - - static UriTemplateCreateParserTests() - { - var assembly = typeof(McpException).Assembly; - s_uriTemplateType = assembly.GetType("ModelContextProtocol.UriTemplate", throwOnError: true)!; - s_createParserMethod = s_uriTemplateType.GetMethod("CreateParser", BindingFlags.Static | BindingFlags.Public)!; - } - - private static Regex CreateParser(string template) - { - return (Regex)s_createParserMethod.Invoke(null, [template])!; - } + private static Regex CreateParser(string template) => UriTemplate.CreateParser(template); private static Match MatchUri(string template, string uri) { @@ -45,12 +28,49 @@ private static void AssertMatch(string template, string uri, params (string name } } + private static void AssertMatchWithGroupCount(string template, string uri, int expectedCapturedGroupCount, params (string name, string value)[] expectedGroups) + { + var match = MatchUri(template, uri); + Assert.True(match.Success, $"Template '{template}' should match URI '{uri}'"); + + // Count groups that actually captured (excluding the default group 0 which is the full match) + int capturedCount = 0; + for (int i = 1; i < match.Groups.Count; i++) + { + if (match.Groups[i].Success && !string.IsNullOrEmpty(match.Groups[i].Value)) + { + capturedCount++; + } + } + Assert.Equal(expectedCapturedGroupCount, capturedCount); + + foreach (var (name, value) in expectedGroups) + { + Assert.True(match.Groups[name].Success, $"Group '{name}' should be captured"); + Assert.Equal(value, match.Groups[name].Value); + } + } + private static void AssertNoMatch(string template, string uri) { var match = MatchUri(template, uri); Assert.False(match.Success, $"Template '{template}' should NOT match URI '{uri}'"); } + private static void AssertMatchWithNoCaptures(string template, string uri) + { + var match = MatchUri(template, uri); + Assert.True(match.Success, $"Template '{template}' should match URI '{uri}'"); + + // Verify that no named groups captured anything (excluding group 0 which is the full match) + for (int i = 1; i < match.Groups.Count; i++) + { + Assert.True( + !match.Groups[i].Success || string.IsNullOrEmpty(match.Groups[i].Value), + $"Group {i} should not capture any value, but captured '{match.Groups[i].Value}'"); + } + } + #region Level 1: Simple String Expansion {var} [Fact] @@ -73,6 +93,34 @@ public void SimpleExpansion_DoesNotMatchQuestionMark() AssertNoMatch("http://example.com/{var}", "http://example.com/foo?query"); } + [Fact] + public void SimpleExpansion_DoesNotMatchFragment() + { + // Simple expansion should NOT match fragment delimiter + AssertNoMatch("http://example.com/{var}", "http://example.com/foo#section"); + } + + [Fact] + public void SimpleExpansion_MatchesWithEmptyValue() + { + // Simple expansion variables are optional - empty value matches but captures nothing + AssertMatchWithGroupCount("http://example.com/{var}", "http://example.com/", 0); + } + + [Fact] + public void SimpleExpansion_DoesNotMatchMissingSegment() + { + // Simple expansion is not optional when it's the only content of a segment + AssertNoMatch("http://example.com/{var}", "http://example.com"); + } + + [Fact] + public void SimpleExpansion_DoesNotMatchExtraPath() + { + // Template requires exact match, extra segments should not match + AssertNoMatch("http://example.com/{var}", "http://example.com/value/extra"); + } + [Fact] public void SimpleExpansion_MultipleVariables() { @@ -83,6 +131,28 @@ public void SimpleExpansion_MultipleVariables() ("y", "768")); } + [Fact] + public void SimpleExpansion_MultipleVariables_MatchesWithMissingSecond() + { + // Second variable is optional - matches with only first captured + AssertMatchWithGroupCount( + "http://example.com/{x}/{y}", + "http://example.com/1024/", + 1, + ("x", "1024")); + } + + [Fact] + public void SimpleExpansion_MultipleVariables_MatchesWithMissingFirst() + { + // First variable is optional - matches with only second captured + AssertMatchWithGroupCount( + "http://example.com/{x}/{y}", + "http://example.com//768", + 1, + ("y", "768")); + } + #endregion #region Level 2: Reserved Expansion {+var} - REGRESSION TESTS FOR BUG FIX @@ -150,12 +220,30 @@ public void ReservedExpansion_WithPathStartingWithSlash() public void ReservedExpansion_StopsAtQueryString() { // Reserved expansion should stop at ? (query string delimiter) - var match = MatchUri("http://example.com/{+path}", "http://example.com/foo/bar?query=test"); - // The match may succeed but only capture up to the ? - if (match.Success) - { - Assert.DoesNotContain("?", match.Groups["path"].Value); - } + // The template doesn't match because it expects the URI to end after {+path} + // but there's a query string. We should verify it doesn't capture the query. + AssertNoMatch("http://example.com/{+path}", "http://example.com/foo/bar?query=test"); + } + + [Fact] + public void ReservedExpansion_StopsAtFragment() + { + // Reserved expansion should stop at # (fragment delimiter) + AssertNoMatch("http://example.com/{+path}", "http://example.com/foo/bar#section"); + } + + [Fact] + public void ReservedExpansion_MatchesEmpty() + { + // Reserved expansion variables are optional - empty matches with 0 captures + AssertMatchWithGroupCount("{+var}", "", 0); + } + + [Fact] + public void ReservedExpansion_DoesNotMatchWrongScheme() + { + // Scheme must match exactly + AssertNoMatch("http://example.com/{+path}", "https://example.com/foo"); } #endregion @@ -178,9 +266,129 @@ public void FragmentExpansion_MatchesSlashes() [Fact] public void FragmentExpansion_OptionalFragment() { - // Fragment should be optional - var match = MatchUri("http://example.com/page{#section}", "http://example.com/page"); - Assert.True(match.Success); + // Fragment should be optional - match succeeds but section is not captured + AssertMatchWithGroupCount("http://example.com/page{#section}", "http://example.com/page", 0); + } + + [Fact] + public void FragmentExpansion_MatchesWithoutHash() + { + // Fragment expansion prefix is optional - matches with captured value even without # + AssertMatchWithGroupCount("{#section}", "intro", 1, ("section", "intro")); + } + + [Fact] + public void FragmentExpansion_DoesNotMatchWrongPath() + { + // The path must match exactly + AssertNoMatch("http://example.com/page{#section}", "http://example.com/other#intro"); + } + + #endregion + + #region Level 3: Label Expansion with Dot-Prefix {.var} - BUG FIX + + /// + /// FIXED BUG: Label expansion {.var} should match dot-prefixed values. + /// The . operator was falling through to the default case which didn't handle the dot prefix. + /// + [Fact] + public void LabelExpansion_MatchesDotPrefixedSingleValue() + { + // FIXED: {.var} should match .value + AssertMatch("X{.var}", "X.value", ("var", "value")); + } + + /// + /// FIXED BUG: Label expansion with multiple variables should use dot as separator. + /// + [Fact] + public void LabelExpansion_MatchesMultipleValues() + { + // FIXED: {.x,y} should match .1024.768 (dot separated) + AssertMatch("www{.x,y}", "www.example.com", ("x", "example"), ("y", "com")); + } + + [Fact] + public void LabelExpansion_DomainStyle() + { + // Common use case: domain name labels + AssertMatch("www{.dom}", "www.example", ("dom", "example")); + } + + [Fact] + public void LabelExpansion_MatchesWithoutDot() + { + // Label expansion prefix is optional - matches with captured value even without . + AssertMatchWithGroupCount("www{.dom}", "wwwexample", 1, ("dom", "example")); + } + + [Fact] + public void LabelExpansion_DoesNotMatchSlash() + { + // Label expansion should not match slashes + AssertNoMatch("www{.dom}", "www.foo/bar"); + } + + [Fact] + public void LabelExpansion_MatchesEmptyAfterDot() + { + // Label expansion variables are optional - dot with no value matches with 0 captures + AssertMatchWithGroupCount("www{.dom}", "www.", 0); + } + + #endregion + + #region Level 3: Path-Style Parameter Expansion {;var} - BUG FIX + + /// + /// FIXED BUG: Path-style parameter expansion {;var} should match semicolon-prefixed name=value pairs. + /// The ; operator was falling through to the default case which didn't handle the semicolon prefix or name=value format. + /// + [Fact] + public void PathParameterExpansion_MatchesSingleParameter() + { + // FIXED: {;x} should match ;x=1024 + AssertMatch("/path{;x}", "/path;x=1024", ("x", "1024")); + } + + /// + /// FIXED BUG: Path-style parameter expansion with multiple parameters. + /// + [Fact] + public void PathParameterExpansion_MatchesMultipleParameters() + { + // FIXED: {;x,y} should match ;x=1024;y=768 + AssertMatch("/path{;x,y}", "/path;x=1024;y=768", ("x", "1024"), ("y", "768")); + } + + [Fact] + public void PathParameterExpansion_MatchesNameOnly() + { + // Path parameters can be just ;name (without =value) when the value is empty + // The name is present but no value is captured + AssertMatchWithGroupCount("/path{;empty}", "/path;empty", 0); + } + + [Fact] + public void PathParameterExpansion_DoesNotMatchMissingSemicolon() + { + // Path parameter expansion requires the ; prefix + AssertNoMatch("/path{;x}", "/pathx=1024"); + } + + [Fact] + public void PathParameterExpansion_DoesNotMatchWrongParamName() + { + // Parameter name must match + AssertNoMatch("/path{;x}", "/path;y=1024"); + } + + [Fact] + public void PathParameterExpansion_DoesNotMatchSlashInValue() + { + // Path parameter values should not contain slashes + AssertNoMatch("/path{;x}", "/path;x=foo/bar"); } #endregion @@ -196,25 +404,36 @@ public void PathSegmentExpansion_MatchesSingleSegment() [Fact] public void PathSegmentExpansion_MultipleSegments() { - // Note: Multiple comma-separated variables in path expansion with / operator - // This tests the current implementation behavior. + // Multiple comma-separated variables in path expansion with / operator // The template {/x,y} expands to paths like "/value1/value2" - var match = MatchUri("{/x,y}", "/1024/768"); - // The implementation may or may not fully support this, just verify regex creation works - Assert.NotNull(CreateParser("{/x,y}")); + AssertMatchWithGroupCount( + "{/x,y}", + "/1024/768", + 2, + ("x", "1024"), + ("y", "768")); + } + + [Fact] + public void PathSegmentExpansion_ThreeSegments() + { + // Multiple comma-separated variables in path expansion with / operator + // The template {/x,y,z} expands to paths like "/value1/value2/value3" + AssertMatchWithGroupCount( + "{/x,y,z}", + "/a/b/c", + 3, + ("x", "a"), + ("y", "b"), + ("z", "c")); } [Fact] public void PathSegmentExpansion_DoesNotMatchSlashInValue() { // Path segment expansion should NOT match slashes within a single variable's value - // Each variable should match one segment only - var match = MatchUri("{/var}", "/foo/bar"); - // This should either not match or only capture "foo" - if (match.Success) - { - Assert.Equal("foo", match.Groups["var"].Value); - } + // Each variable should match one segment only, so /foo/bar doesn't fully match {/var} + AssertNoMatch("{/var}", "/foo/bar"); } [Fact] @@ -223,6 +442,34 @@ public void PathSegmentExpansion_CombinedWithLiterals() AssertMatch("/users{/id}", "/users/123", ("id", "123")); } + [Fact] + public void PathSegmentExpansion_MatchesWithoutSlash() + { + // Path segment expansion prefix is optional - matches with captured value even without / + AssertMatchWithGroupCount("{/var}", "value", 1, ("var", "value")); + } + + [Fact] + public void PathSegmentExpansion_MatchesEmptyAfterSlash() + { + // Path segment expansion variables are optional - slash with no value matches with 0 captures + AssertMatchWithGroupCount("{/var}", "/", 0); + } + + [Fact] + public void PathSegmentExpansion_DoesNotMatchFragment() + { + // Path segment expansion should not match fragment + AssertNoMatch("{/var}", "/value#section"); + } + + [Fact] + public void PathSegmentExpansion_DoesNotMatchQuery() + { + // Path segment expansion should not match query + AssertNoMatch("{/var}", "/value?query"); + } + #endregion #region Level 3: Form-Style Query Expansion {?var} @@ -246,18 +493,19 @@ public void QueryExpansion_MatchesMultipleParameters() [Fact] public void QueryExpansion_OptionalParameters() { - // All query parameters should be optional - var match = MatchUri("http://example.com/search{?q,lang}", "http://example.com/search"); - Assert.True(match.Success); + // All query parameters should be optional - no parameters means no captures + AssertMatchWithGroupCount("http://example.com/search{?q,lang}", "http://example.com/search", 0); } [Fact] public void QueryExpansion_PartialParameters() { - // Should match even if not all parameters are present - var match = MatchUri("http://example.com/search{?q,lang}", "http://example.com/search?q=test"); - Assert.True(match.Success); - Assert.Equal("test", match.Groups["q"].Value); + // Should match even if not all parameters are present - only q is captured + AssertMatchWithGroupCount( + "http://example.com/search{?q,lang}", + "http://example.com/search?q=test", + 1, + ("q", "test")); } [Fact] @@ -271,6 +519,27 @@ public void QueryExpansion_ThreeParameters() ("a3", "c")); } + [Fact] + public void QueryExpansion_DoesNotMatchWrongPath() + { + // The path must match exactly + AssertNoMatch("http://example.com/search{?q}", "http://example.com/find?q=test"); + } + + [Fact] + public void QueryExpansion_DoesNotMatchMissingQuestionMark() + { + // Query expansion requires the ? prefix when parameters are present + AssertNoMatch("http://example.com/search{?q}", "http://example.com/searchq=test"); + } + + [Fact] + public void QueryExpansion_DoesNotMatchSlashInValue() + { + // Query parameter values should not contain slashes + AssertNoMatch("http://example.com/search{?q}", "http://example.com/search?q=foo/bar"); + } + #endregion #region Level 3: Form-Style Query Continuation {&var} @@ -294,6 +563,20 @@ public void QueryContinuation_MultipleParameters() ("y", "768")); } + [Fact] + public void QueryContinuation_DoesNotMatchMissingAmpersand() + { + // Query continuation requires & prefix + AssertNoMatch("http://example.com/search?start=0{&x}", "http://example.com/search?start=0x=1024"); + } + + [Fact] + public void QueryContinuation_DoesNotMatchMissingFixedQuery() + { + // The fixed query part must be present + AssertNoMatch("http://example.com/search?start=0{&x}", "http://example.com/search&x=1024"); + } + #endregion #region Edge Cases and Special Characters @@ -308,15 +591,13 @@ public void PctEncodedInValue_MatchesEncodedCharacters() [Fact] public void EmptyTemplate_MatchesEmpty() { - var match = MatchUri("", ""); - Assert.True(match.Success); + AssertMatchWithNoCaptures("", ""); } [Fact] public void LiteralOnlyTemplate_MatchesExactly() { - var match = MatchUri("http://example.com/static", "http://example.com/static"); - Assert.True(match.Success); + AssertMatchWithNoCaptures("http://example.com/static", "http://example.com/static"); } [Fact] @@ -329,8 +610,32 @@ public void LiteralOnlyTemplate_DoesNotMatchDifferentUri() public void CaseInsensitiveMatching() { // URI matching should be case-insensitive for the host portion - var match = MatchUri("http://EXAMPLE.COM/{var}", "http://example.com/value"); - Assert.True(match.Success); + AssertMatchWithGroupCount( + "http://EXAMPLE.COM/{var}", + "http://example.com/value", + 1, + ("var", "value")); + } + + [Fact] + public void EmptyTemplate_DoesNotMatchNonEmpty() + { + // Empty template should only match empty string + AssertNoMatch("", "http://example.com"); + } + + [Fact] + public void LiteralOnlyTemplate_DoesNotMatchPartial() + { + // Literal template must match completely + AssertNoMatch("http://example.com/static", "http://example.com/static/extra"); + } + + [Fact] + public void LiteralOnlyTemplate_DoesNotMatchPrefix() + { + // Literal template must match completely + AssertNoMatch("http://example.com/static", "http://example.com/stat"); } #endregion @@ -371,9 +676,8 @@ public void RealWorld_ResourceWithOptionalQuery() [Fact] public void RealWorld_NonTemplatedUri() { - // Non-templated URIs should match exactly - var match = MatchUri("test://resource/non-templated", "test://resource/non-templated"); - Assert.True(match.Success); + // Non-templated URIs should match exactly with no captures + AssertMatchWithNoCaptures("test://resource/non-templated", "test://resource/non-templated"); } [Fact] @@ -417,14 +721,13 @@ public void CombinedOperators_PathAndQuery() [Fact] public void CombinedOperators_ReservedAndFragment() { - // Note: Reserved expansion is greedy - when combined with fragment expansion, - // the reserved expansion may capture the fragment delimiter. - // This tests the current implementation behavior. - var match = MatchUri("{+base}{#section}", "http://example.com/#intro"); - Assert.True(match.Success); - // The reserved expansion {+base} may capture up to (and possibly including) the # - // Just verify the template parses correctly - Assert.NotNull(CreateParser("{+base}{#section}")); + // Reserved expansion should stop at # (fragment delimiter) so both parts are captured correctly + AssertMatchWithGroupCount( + "{+base}{#section}", + "http://example.com/#intro", + 2, + ("base", "http://example.com/"), + ("section", "intro")); } #endregion @@ -434,12 +737,9 @@ public void CombinedOperators_ReservedAndFragment() [Fact] public void PrefixModifier_InTemplate() { - // Templates with prefix modifiers should still parse - // The regex just captures whatever matches - var regex = CreateParser("{var:3}"); - Assert.NotNull(regex); - var match = regex.Match("val"); - Assert.True(match.Success); + // Templates with prefix modifiers should still parse and match + // The regex captures whatever matches (the parser doesn't enforce prefix length) + AssertMatchWithGroupCount("{var:3}", "val", 1, ("var", "val")); } #endregion @@ -449,9 +749,8 @@ public void PrefixModifier_InTemplate() [Fact] public void ExplodeModifier_InTemplate() { - // Templates with explode modifiers should still parse - var regex = CreateParser("{/list*}"); - Assert.NotNull(regex); + // Templates with explode modifiers should still parse and match single values + AssertMatchWithGroupCount("{/list*}", "/item", 1, ("list", "item")); } #endregion From 69074820795139939b69f8cfc25c70d737aa416b Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 15 Jan 2026 09:06:30 -0800 Subject: [PATCH 3/6] Move UriTemplateCreateParserTests to McpServerResourceRoutingTests --- src/ModelContextProtocol.Core/UriTemplate.cs | 2 +- .../ClientServerTestBase.cs | 51 +- .../McpServerResourceRoutingTests.cs | 838 +++++++++++++++++- .../UriTemplateCreateParserTests.cs | 757 ---------------- 4 files changed, 860 insertions(+), 788 deletions(-) delete mode 100644 tests/ModelContextProtocol.Tests/UriTemplateCreateParserTests.cs diff --git a/src/ModelContextProtocol.Core/UriTemplate.cs b/src/ModelContextProtocol.Core/UriTemplate.cs index 3662133c2..83124f6a5 100644 --- a/src/ModelContextProtocol.Core/UriTemplate.cs +++ b/src/ModelContextProtocol.Core/UriTemplate.cs @@ -15,7 +15,7 @@ namespace ModelContextProtocol; /// This implementation should correctly handle valid URI templates, but it has undefined output for invalid templates, /// e.g. it may treat portions of invalid templates as literals rather than throwing. /// -public static partial class UriTemplate +internal static partial class UriTemplate { /// Regex pattern for finding URI template expressions and parsing out the operator and varname. private const string UriTemplateExpressionPattern = """ diff --git a/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs b/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs index ab6d19f34..564225abd 100644 --- a/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs +++ b/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs @@ -13,36 +13,55 @@ public abstract class ClientServerTestBase : LoggedTest, IAsyncDisposable { private readonly Pipe _clientToServerPipe = new(); private readonly Pipe _serverToClientPipe = new(); - private readonly IMcpServerBuilder _builder; - private readonly CancellationTokenSource _cts; - private readonly Task _serverTask; + private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + private Task _serverTask = Task.CompletedTask; - public ClientServerTestBase(ITestOutputHelper testOutputHelper) + public ClientServerTestBase(ITestOutputHelper testOutputHelper, bool startServer = true) : base(testOutputHelper) { - ServiceCollection sc = new(); - sc.AddLogging(); - sc.AddSingleton(XunitLoggerProvider); - sc.AddSingleton(MockLoggerProvider); - _builder = sc + ServiceCollection.AddLogging(); + ServiceCollection.AddSingleton(XunitLoggerProvider); + ServiceCollection.AddSingleton(MockLoggerProvider); + McpServerBuilder = ServiceCollection .AddMcpServer() .WithStreamServerTransport(_clientToServerPipe.Reader.AsStream(), _serverToClientPipe.Writer.AsStream()); - ConfigureServices(sc, _builder); - ServiceProvider = sc.BuildServiceProvider(validateScopes: true); - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - Server = ServiceProvider.GetRequiredService(); - _serverTask = Server.RunAsync(_cts.Token); + ConfigureServices(ServiceCollection, McpServerBuilder); + + if (startServer) + { + StartServer(); + } } - protected McpServer Server { get; } + protected ServiceCollection ServiceCollection { get; } = []; + + protected IMcpServerBuilder McpServerBuilder { get; } + + protected McpServer Server + { + get => field ?? throw new InvalidOperationException("You must call StartServer first."); + private set => field = value; + } - protected IServiceProvider ServiceProvider { get; } + protected ServiceProvider ServiceProvider + { + get => field ?? throw new InvalidOperationException("You must call StartServer first."); + private set => field = value; + } protected virtual void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { } + protected McpServer StartServer() + { + ServiceProvider = ServiceCollection.BuildServiceProvider(validateScopes: true); + Server = ServiceProvider.GetRequiredService(); + _serverTask = Server.RunAsync(_cts.Token); + return Server; + } + public async ValueTask DisposeAsync() { await _cts.CancelAsync(); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs index 9b4b4af30..175f8bb46 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs @@ -3,44 +3,854 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -namespace ModelContextProtocol.Tests.Configuration; +namespace ModelContextProtocol.Tests; -public sealed class McpServerResourceRoutingTests(ITestOutputHelper testOutputHelper) : ClientServerTestBase(testOutputHelper) +/// +/// Test suite for UriTemplate.CreateParser method. +/// Tests are based on RFC 6570 (URI Template) specification. +/// Since UriTemplate is internal, we test it indirectly through the MCP server resource routing mechanism. +/// +public sealed class McpServerResourceRoutingTests(ITestOutputHelper testOutputHelper) : ClientServerTestBase(testOutputHelper, startServer: false) { - protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + /// + /// Starts the server with the specified resources and creates a client. + /// + private async Task CreateClientWithResourcesAsync(params McpServerResource[] resources) { - mcpServerBuilder.WithResources([ - McpServerResource.Create(options: new() { UriTemplate = "test://resource/non-templated" } , method: () => "static"), - McpServerResource.Create(options: new() { UriTemplate = "test://resource/{id}" }, method: (string id) => $"template: {id}"), - McpServerResource.Create(options: new() { UriTemplate = "test://params{?a1,a2,a3}" }, method: (string a1, string a2, string a3) => $"params: {a1}, {a2}, {a3}"), - McpServerResource.Create(options: new() { UriTemplate = "file://{prefix}/{+path}" }, method: (string prefix, string path) => $"prefix: {prefix}, path: {path}"), - ]); + McpServerBuilder.WithResources(resources); + StartServer(); + return await CreateMcpClientForServer(); + } + + /// + /// Asserts that the given URI matches the template and produces the expected text result. + /// + private async Task AssertMatchAsync( + string uriTemplate, + Delegate method, + string uri, + string expectedResult) + { + var resource = McpServerResource.Create(options: new() { UriTemplate = uriTemplate }, method: method); + var client = await CreateClientWithResourcesAsync(resource); + + var result = await client.ReadResourceAsync(uri, null, TestContext.Current.CancellationToken); + var text = ((TextResourceContents)result.Contents[0]).Text; + Assert.Equal(expectedResult, text); } + /// + /// Asserts that the given URI does NOT match the template. + /// + private async Task AssertNoMatchAsync( + string uriTemplate, + Delegate method, + string uri) + { + var resource = McpServerResource.Create(options: new() { UriTemplate = uriTemplate }, method: method); + var client = await CreateClientWithResourcesAsync(resource); + + var ex = await Assert.ThrowsAsync(async () => + await client.ReadResourceAsync(uri, null, TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.ResourceNotFound, ex.ErrorCode); + } + + /// + /// Verify that when multiple templated resources exist, the correct one is matched based on the URI pattern. + /// Regression test for https://github.com/modelcontextprotocol/csharp-sdk/issues/821. + /// [Fact] public async Task MultipleTemplatedResources_MatchesCorrectResource() { - // Verify that when multiple templated resources exist, the correct one is matched based on the URI pattern, not just the first one. - // Regression test for https://github.com/modelcontextprotocol/csharp-sdk/issues/821. - await using McpClient client = await CreateMcpClientForServer(); + // Register templates from most specific to least specific + var client = await CreateClientWithResourcesAsync( + McpServerResource.Create(options: new() { UriTemplate = "test://resource/non-templated" }, method: () => "static"), + McpServerResource.Create(options: new() { UriTemplate = "test://resource/{id}" }, method: (string id) => $"template: {id}"), + McpServerResource.Create(options: new() { UriTemplate = "test://params{?a1,a2,a3}" }, method: (string a1, string a2, string a3) => $"params: {a1}, {a2}, {a3}"), + McpServerResource.Create(options: new() { UriTemplate = "file://{prefix}/{+path}" }, method: (string prefix, string path) => $"prefix: {prefix}, path: {path}")); + // Non-templated URI - exact match var nonTemplatedResult = await client.ReadResourceAsync("test://resource/non-templated", null, TestContext.Current.CancellationToken); Assert.Equal("static", ((TextResourceContents)nonTemplatedResult.Contents[0]).Text); + // Templated URI var templatedResult = await client.ReadResourceAsync("test://resource/12345", null, TestContext.Current.CancellationToken); Assert.Equal("template: 12345", ((TextResourceContents)templatedResult.Contents[0]).Text); + // Exact match for templated URI var exactTemplatedResult = await client.ReadResourceAsync("test://resource/{id}", null, TestContext.Current.CancellationToken); Assert.Equal("template: {id}", ((TextResourceContents)exactTemplatedResult.Contents[0]).Text); + // Templated URI with query params var paramsResult = await client.ReadResourceAsync("test://params?a1=a&a2=b&a3=c", null, TestContext.Current.CancellationToken); Assert.Equal("params: a, b, c", ((TextResourceContents)paramsResult.Contents[0]).Text); - var pathResult = await client.ReadResourceAsync("file://foo/examples/example.rs", null, TestContext.Current.CancellationToken); - Assert.Equal("prefix: foo, path: examples/example.rs", ((TextResourceContents)pathResult.Contents[0]).Text); + // Reserved expansion path - matches the generic {prefix}/{+path} template + var pathResult = await client.ReadResourceAsync("file://foo/examples/example.cs", null, TestContext.Current.CancellationToken); + Assert.Equal("prefix: foo, path: examples/example.cs", ((TextResourceContents)pathResult.Contents[0]).Text); + // Literal template braces in URI should not match (template literal is not a valid URI) var mcpEx = await Assert.ThrowsAsync(async () => await client.ReadResourceAsync("test://params{?a1,a2,a3}", null, TestContext.Current.CancellationToken)); Assert.Equal(McpErrorCode.ResourceNotFound, mcpEx.ErrorCode); Assert.Equal("Request failed (remote): Unknown resource URI: 'test://params{?a1,a2,a3}'", mcpEx.Message); } + + #region Level 1: Simple String Expansion {var} + + [Fact] + public async Task SimpleExpansion_MatchesSingleVariable() + { + await AssertMatchAsync( + uriTemplate: "test://example.com/{var}", + method: (string var) => $"var:{var}", + uri: "test://example.com/value", + expectedResult: "var:value"); + } + + [Fact] + public async Task SimpleExpansion_DoesNotMatchSlash() + { + // Simple expansion should NOT match slashes + await AssertNoMatchAsync( + uriTemplate: "test://example.com/{var}", + method: (string var) => $"var:{var}", + uri: "test://example.com/foo/bar"); + } + + [Fact] + public async Task SimpleExpansion_DoesNotMatchQuestionMark() + { + // Simple expansion should NOT match query string characters + await AssertNoMatchAsync( + uriTemplate: "test://example.com/{var}", + method: (string var) => $"var:{var}", + uri: "test://example.com/foo?query"); + } + + [Fact] + public async Task SimpleExpansion_DoesNotMatchFragment() + { + // Simple expansion should NOT match fragment delimiter + await AssertNoMatchAsync( + uriTemplate: "test://example.com/{var}", + method: (string var) => $"var:{var}", + uri: "test://example.com/foo#section"); + } + + [Fact] + public async Task SimpleExpansion_DoesNotMatchMissingSegment() + { + // Simple expansion is not optional when it's the only content of a segment + await AssertNoMatchAsync( + uriTemplate: "test://example.com/{var}", + method: (string var) => $"var:{var}", + uri: "test://example.com"); + } + + [Fact] + public async Task SimpleExpansion_DoesNotMatchExtraPath() + { + // Template requires exact match, extra segments should not match + await AssertNoMatchAsync( + uriTemplate: "test://example.com/{var}", + method: (string var) => $"var:{var}", + uri: "test://example.com/value/extra"); + } + + [Fact] + public async Task SimpleExpansion_MultipleVariables() + { + await AssertMatchAsync( + uriTemplate: "test://example.com/{x}/{y}", + method: (string x, string y) => $"x:{x},y:{y}", + uri: "test://example.com/1024/768", + expectedResult: "x:1024,y:768"); + } + + #endregion + + #region Level 2: Reserved Expansion {+var} - REGRESSION TESTS FOR BUG FIX + + /// + /// FIXED BUG: Reserved expansion {+var} should match slashes. + /// This was the bug that caused samples://{dependency}/{+path} to fail. + /// Per RFC 6570 Section 3.2.3, the + operator allows reserved characters including "/" to pass through. + /// + [Fact] + public async Task ReservedExpansion_MatchesSlashes() + { + // FIXED: {+path} should match paths containing slashes + await AssertMatchAsync( + uriTemplate: "test://{dependency}/{+path}", + method: (string dependency, string path) => $"dependency:{dependency},path:{path}", + uri: "test://foo/README.md", + expectedResult: "dependency:foo,path:README.md"); + } + + /// + /// FIXED BUG: Reserved expansion with nested path containing slashes. + /// This is the exact failing case from the issue. + /// + [Fact] + public async Task ReservedExpansion_MatchesNestedPath() + { + // FIXED: {+path} should match paths with multiple segments + await AssertMatchAsync( + uriTemplate: "test://{dependency}/{+path}", + method: (string dependency, string path) => $"dependency:{dependency},path:{path}", + uri: "test://foo/examples/example.rs", + expectedResult: "dependency:foo,path:examples/example.rs"); + } + + /// + /// FIXED BUG: Reserved expansion with deep nested path. + /// + [Fact] + public async Task ReservedExpansion_MatchesDeeplyNestedPath() + { + // FIXED: {+path} should match deeply nested paths + await AssertMatchAsync( + uriTemplate: "test://{dependency}/{+path}", + method: (string dependency, string path) => $"dependency:{dependency},path:{path}", + uri: "test://mylib/src/components/utils/helper.ts", + expectedResult: "dependency:mylib,path:src/components/utils/helper.ts"); + } + + [Fact] + public async Task ReservedExpansion_SimpleValue() + { + // Reserved expansion should still work for simple values without slashes + await AssertMatchAsync( + uriTemplate: "test://{+var}", + method: (string var) => $"var:{var}", + uri: "test://value", + expectedResult: "var:value"); + } + + [Fact] + public async Task ReservedExpansion_WithPathStartingWithSlash() + { + // Reserved expansion allows reserved URI characters like / + await AssertMatchAsync( + uriTemplate: "test://{+path}", + method: (string path) => $"path:{path}", + uri: "test:///foo/bar", + expectedResult: "path:/foo/bar"); + } + + [Fact] + public async Task ReservedExpansion_StopsAtQueryString() + { + // Reserved expansion should stop at ? (query string delimiter) + // The template doesn't match because it expects the URI to end after {+path} + // but there's a query string. We should verify it doesn't capture the query. + await AssertNoMatchAsync( + uriTemplate: "test://example.com/{+path}", + method: (string path) => $"path:{path}", + uri: "test://example.com/foo/bar?query=test"); + } + + [Fact] + public async Task ReservedExpansion_StopsAtFragment() + { + // Reserved expansion should stop at # (fragment delimiter) + await AssertNoMatchAsync( + uriTemplate: "test://example.com/{+path}", + method: (string path) => $"path:{path}", + uri: "test://example.com/foo/bar#section"); + } + + [Fact] + public async Task ReservedExpansion_DoesNotMatchWrongScheme() + { + // Scheme must match exactly + await AssertNoMatchAsync( + uriTemplate: "test://example.com/{+path}", + method: (string path) => $"path:{path}", + uri: "wrongscheme://example.com/foo"); + } + + #endregion + + #region Level 2: Fragment Expansion {#var} + + [Fact] + public async Task FragmentExpansion_MatchesWithHashPrefix() + { + await AssertMatchAsync( + uriTemplate: "test://example.com/page{#section}", + method: (string section) => $"section:{section}", + uri: "test://example.com/page#intro", + expectedResult: "section:intro"); + } + + [Fact] + public async Task FragmentExpansion_MatchesSlashes() + { + // Fragment expansion allows reserved characters including / + await AssertMatchAsync( + uriTemplate: "test://{#path}", + method: (string path) => $"path:{path}", + uri: "test://#/foo/bar", + expectedResult: "path:/foo/bar"); + } + + [Fact] + public async Task FragmentExpansion_MatchesWithoutHash() + { + // Fragment expansion prefix is optional - matches with captured value even without # + await AssertMatchAsync( + uriTemplate: "test://{#section}", + method: (string section) => $"section:{section}", + uri: "test://intro", + expectedResult: "section:intro"); + } + + [Fact] + public async Task FragmentExpansion_DoesNotMatchWrongPath() + { + // The path must match exactly + await AssertNoMatchAsync( + uriTemplate: "test://example.com/page{#section}", + method: (string section) => $"section:{section}", + uri: "test://example.com/other#intro"); + } + + #endregion + + #region Level 3: Label Expansion with Dot-Prefix {.var} - BUG FIX + + /// + /// FIXED BUG: Label expansion {.var} should match dot-prefixed values. + /// The . operator was falling through to the default case which didn't handle the dot prefix. + /// + [Fact] + public async Task LabelExpansion_MatchesDotPrefixedSingleValue() + { + // FIXED: {.var} should match .value + await AssertMatchAsync( + uriTemplate: "test://X{.var}", + method: (string var) => $"var:{var}", + uri: "test://X.value", + expectedResult: "var:value"); + } + + /// + /// FIXED BUG: Label expansion with multiple variables should use dot as separator. + /// + [Fact] + public async Task LabelExpansion_MatchesMultipleValues() + { + // FIXED: {.x,y} should match .1024.768 (dot separated) + await AssertMatchAsync( + uriTemplate: "test://www{.x,y}", + method: (string x, string y) => $"x:{x},y:{y}", + uri: "test://www.example.com", + expectedResult: "x:example,y:com"); + } + + [Fact] + public async Task LabelExpansion_DomainStyle() + { + // Common use case: domain name labels + await AssertMatchAsync( + uriTemplate: "test://www{.dom}", + method: (string dom) => $"dom:{dom}", + uri: "test://www.example", + expectedResult: "dom:example"); + } + + [Fact] + public async Task LabelExpansion_MatchesWithoutDot() + { + // Label expansion prefix is optional - matches with captured value even without . + await AssertMatchAsync( + uriTemplate: "test://www{.dom}", + method: (string dom) => $"dom:{dom}", + uri: "test://wwwexample", + expectedResult: "dom:example"); + } + + [Fact] + public async Task LabelExpansion_DoesNotMatchSlash() + { + // Label expansion should not match slashes + await AssertNoMatchAsync( + uriTemplate: "test://www{.dom}", + method: (string dom) => $"dom:{dom}", + uri: "test://www.foo/bar"); + } + + #endregion + + #region Level 3: Path-Style Parameter Expansion {;var} - BUG FIX + + /// + /// FIXED BUG: Path-style parameter expansion {;var} should match semicolon-prefixed name=value pairs. + /// The ; operator was falling through to the default case which didn't handle the semicolon prefix or name=value format. + /// + [Fact] + public async Task PathParameterExpansion_MatchesSingleParameter() + { + // FIXED: {;x} should match ;x=1024 + await AssertMatchAsync( + uriTemplate: "test:///path{;x}", + method: (string x) => $"x:{x}", + uri: "test:///path;x=1024", + expectedResult: "x:1024"); + } + + /// + /// FIXED BUG: Path-style parameter expansion with multiple parameters. + /// + [Fact] + public async Task PathParameterExpansion_MatchesMultipleParameters() + { + // FIXED: {;x,y} should match ;x=1024;y=768 + await AssertMatchAsync( + uriTemplate: "test:///path{;x,y}", + method: (string x, string y) => $"x:{x},y:{y}", + uri: "test:///path;x=1024;y=768", + expectedResult: "x:1024,y:768"); + } + + [Fact] + public async Task PathParameterExpansion_DoesNotMatchMissingSemicolon() + { + // Path parameter expansion requires the ; prefix + await AssertNoMatchAsync( + uriTemplate: "test:///path{;x}", + method: (string x) => $"x:{x}", + uri: "test:///pathx=1024"); + } + + [Fact] + public async Task PathParameterExpansion_DoesNotMatchWrongParamName() + { + // Parameter name must match + await AssertNoMatchAsync( + uriTemplate: "test:///path{;x}", + method: (string x) => $"x:{x}", + uri: "test:///path;y=1024"); + } + + [Fact] + public async Task PathParameterExpansion_DoesNotMatchSlashInValue() + { + // Path parameter values should not contain slashes + await AssertNoMatchAsync( + uriTemplate: "test:///path{;x}", + method: (string x) => $"x:{x}", + uri: "test:///path;x=foo/bar"); + } + + #endregion + + #region Level 3: Path Segment Expansion {/var} + + [Fact] + public async Task PathSegmentExpansion_MatchesSingleSegment() + { + await AssertMatchAsync( + uriTemplate: "test://{/var}", + method: (string var) => $"var:{var}", + uri: "test:///value", + expectedResult: "var:value"); + } + + [Fact] + public async Task PathSegmentExpansion_MultipleSegments() + { + // Multiple comma-separated variables in path expansion with / operator + // The template {/x,y} expands to paths like "/value1/value2" + await AssertMatchAsync( + uriTemplate: "test://{/x,y}", + method: (string x, string y) => $"x:{x},y:{y}", + uri: "test:///1024/768", + expectedResult: "x:1024,y:768"); + } + + [Fact] + public async Task PathSegmentExpansion_ThreeSegments() + { + // Multiple comma-separated variables in path expansion with / operator + // The template {/x,y,z} expands to paths like "/value1/value2/value3" + await AssertMatchAsync( + uriTemplate: "test://{/x,y,z}", + method: (string x, string y, string z) => $"x:{x},y:{y},z:{z}", + uri: "test:///a/b/c", + expectedResult: "x:a,y:b,z:c"); + } + + [Fact] + public async Task PathSegmentExpansion_DoesNotMatchSlashInValue() + { + // Path segment expansion should NOT match slashes within a single variable's value + // Each variable should match one segment only, so /foo/bar doesn't fully match {/var} + await AssertNoMatchAsync( + uriTemplate: "test://{/var}", + method: (string var) => $"var:{var}", + uri: "test:///foo/bar"); + } + + [Fact] + public async Task PathSegmentExpansion_CombinedWithLiterals() + { + await AssertMatchAsync( + uriTemplate: "test:///users{/id}", + method: (string id) => $"id:{id}", + uri: "test:///users/123", + expectedResult: "id:123"); + } + + [Fact] + public async Task PathSegmentExpansion_MatchesWithoutSlash() + { + // Path segment expansion prefix is optional - matches with captured value even without / + await AssertMatchAsync( + uriTemplate: "test://{/var}", + method: (string var) => $"var:{var}", + uri: "test://value", + expectedResult: "var:value"); + } + + [Fact] + public async Task PathSegmentExpansion_DoesNotMatchFragment() + { + // Path segment expansion should not match fragment + await AssertNoMatchAsync( + uriTemplate: "test://{/var}", + method: (string var) => $"var:{var}", + uri: "test:///value#section"); + } + + [Fact] + public async Task PathSegmentExpansion_DoesNotMatchQuery() + { + // Path segment expansion should not match query + await AssertNoMatchAsync( + uriTemplate: "test://{/var}", + method: (string var) => $"var:{var}", + uri: "test:///value?query"); + } + + #endregion + + #region Level 3: Form-Style Query Expansion {?var} + + [Fact] + public async Task QueryExpansion_MatchesSingleParameter() + { + await AssertMatchAsync( + uriTemplate: "test://example.com/search{?q}", + method: (string q) => $"q:{q}", + uri: "test://example.com/search?q=test", + expectedResult: "q:test"); + } + + [Fact] + public async Task QueryExpansion_MatchesMultipleParameters() + { + await AssertMatchAsync( + uriTemplate: "test://example.com/search{?q,lang}", + method: (string q, string lang) => $"q:{q},lang:{lang}", + uri: "test://example.com/search?q=cat&lang=en", + expectedResult: "q:cat,lang:en"); + } + + [Fact] + public async Task QueryExpansion_ThreeParameters() + { + await AssertMatchAsync( + uriTemplate: "test://params{?a1,a2,a3}", + method: (string a1, string a2, string a3) => $"a1:{a1},a2:{a2},a3:{a3}", + uri: "test://params?a1=a&a2=b&a3=c", + expectedResult: "a1:a,a2:b,a3:c"); + } + + [Fact] + public async Task QueryExpansion_DoesNotMatchWrongPath() + { + // The path must match exactly + await AssertNoMatchAsync( + uriTemplate: "test://example.com/search{?q}", + method: (string q) => $"q:{q}", + uri: "test://example.com/find?q=test"); + } + + [Fact] + public async Task QueryExpansion_DoesNotMatchMissingQuestionMark() + { + // Query expansion requires the ? prefix when parameters are present + await AssertNoMatchAsync( + uriTemplate: "test://example.com/search{?q}", + method: (string q) => $"q:{q}", + uri: "test://example.com/searchq=test"); + } + + [Fact] + public async Task QueryExpansion_DoesNotMatchSlashInValue() + { + // Query parameter values should not contain slashes + await AssertNoMatchAsync( + uriTemplate: "test://example.com/search{?q}", + method: (string q) => $"q:{q}", + uri: "test://example.com/search?q=foo/bar"); + } + + #endregion + + #region Level 3: Form-Style Query Continuation {&var} + + [Fact] + public async Task QueryContinuation_MatchesWithExistingQuery() + { + await AssertMatchAsync( + uriTemplate: "test://example.com/search?fixed=yes{&x}", + method: (string x) => $"x:{x}", + uri: "test://example.com/search?fixed=yes&x=1024", + expectedResult: "x:1024"); + } + + [Fact] + public async Task QueryContinuation_MultipleParameters() + { + await AssertMatchAsync( + uriTemplate: "test://example.com/search?start=0{&x,y}", + method: (string x, string y) => $"x:{x},y:{y}", + uri: "test://example.com/search?start=0&x=1024&y=768", + expectedResult: "x:1024,y:768"); + } + + [Fact] + public async Task QueryContinuation_DoesNotMatchMissingAmpersand() + { + // Query continuation requires & prefix + await AssertNoMatchAsync( + uriTemplate: "test://example.com/search?start=0{&x}", + method: (string x) => $"x:{x}", + uri: "test://example.com/search?start=0x=1024"); + } + + [Fact] + public async Task QueryContinuation_DoesNotMatchMissingFixedQuery() + { + // The fixed query part must be present + await AssertNoMatchAsync( + uriTemplate: "test://example.com/search?start=0{&x}", + method: (string x) => $"x:{x}", + uri: "test://example.com/search&x=1024"); + } + + #endregion + + #region Edge Cases and Special Characters + + [Fact] + public async Task PctEncodedInValue_MatchesEncodedCharacters() + { + // MCP server automatically decodes percent-encoded characters + await AssertMatchAsync( + uriTemplate: "test://{var}", + method: (string var) => $"var:{var}", + uri: "test://Hello%20World", + expectedResult: "var:Hello World"); // MCP decodes the %20 to a space + } + + [Fact] + public async Task EmptyTemplate_MatchesEmpty() + { + await AssertMatchAsync( + uriTemplate: "test://", + method: () => "matched", + uri: "test://", + expectedResult: "matched"); + } + + [Fact] + public async Task LiteralOnlyTemplate_MatchesExactly() + { + await AssertMatchAsync( + uriTemplate: "test://example.com/static", + method: () => "matched", + uri: "test://example.com/static", + expectedResult: "matched"); + } + + [Fact] + public async Task LiteralOnlyTemplate_DoesNotMatchDifferentUri() + { + await AssertNoMatchAsync( + uriTemplate: "test://example.com/static", + method: () => "matched", + uri: "test://example.com/dynamic"); + } + + [Fact] + public async Task CaseInsensitiveMatching() + { + // URI matching should be case-insensitive for the host portion + await AssertMatchAsync( + uriTemplate: "test://EXAMPLE.COM/{var}", + method: (string var) => $"var:{var}", + uri: "test://example.com/value", + expectedResult: "var:value"); + } + + [Fact] + public async Task EmptyTemplate_DoesNotMatchNonEmpty() + { + // Empty template should only match empty string + await AssertNoMatchAsync( + uriTemplate: "test://", + method: () => "matched", + uri: "test://example.com"); + } + + [Fact] + public async Task LiteralOnlyTemplate_DoesNotMatchPartial() + { + // Literal template must match completely + await AssertNoMatchAsync( + uriTemplate: "test://example.com/static", + method: () => "matched", + uri: "test://example.com/static/extra"); + } + + [Fact] + public async Task LiteralOnlyTemplate_DoesNotMatchPrefix() + { + // Literal template must match completely + await AssertNoMatchAsync( + uriTemplate: "test://example.com/static", + method: () => "matched", + uri: "test://example.com/stat"); + } + + #endregion + + #region Complex Real-World Templates + + [Fact] + public async Task RealWorld_GitHubApiStyle() + { + await AssertMatchAsync( + uriTemplate: "test://api.github.com/repos/{owner}/{repo}/contents/{+path}", + method: (string owner, string repo, string path) => $"owner:{owner},repo:{repo},path:{path}", + uri: "test://api.github.com/repos/microsoft/vscode/contents/src/vs/editor/editor.main.ts", + expectedResult: "owner:microsoft,repo:vscode,path:src/vs/editor/editor.main.ts"); + } + + [Fact] + public async Task RealWorld_FileSystemPath() + { + await AssertMatchAsync( + uriTemplate: "test:///{+path}", + method: (string path) => $"path:{path}", + uri: "test:///home/user/documents/file.txt", + expectedResult: "path:home/user/documents/file.txt"); + } + + [Fact] + public async Task RealWorld_ResourceWithQuery() + { + await AssertMatchAsync( + uriTemplate: "test://resource/{id}{?format,version}", + method: (string id, string format, string version) => $"id:{id},format:{format},version:{version}", + uri: "test://resource/12345?format=json&version=2", + expectedResult: "id:12345,format:json,version:2"); + } + + [Fact] + public async Task RealWorld_NonTemplatedUri() + { + // Non-templated URIs should match exactly with no captures + await AssertMatchAsync( + uriTemplate: "test://resource/non-templated", + method: () => "matched", + uri: "test://resource/non-templated", + expectedResult: "matched"); + } + + [Fact] + public async Task RealWorld_MixedTemplateAndLiteral() + { + await AssertMatchAsync( + uriTemplate: "test://example.com/users/{userId}/posts/{postId}", + method: (string userId, string postId) => $"userId:{userId},postId:{postId}", + uri: "test://example.com/users/42/posts/100", + expectedResult: "userId:42,postId:100"); + } + + /// + /// FIXED BUG: The exact case from the bug report - samples scheme with dependency and path. + /// + [Fact] + public async Task RealWorld_SamplesSchemeWithDependency() + { + await AssertMatchAsync( + uriTemplate: "test://{dependency}/{+path}", + method: (string dependency, string path) => $"dependency:{dependency},path:{path}", + uri: "test://csharp-sdk/README.md", + expectedResult: "dependency:csharp-sdk,path:README.md"); + } + + #endregion + + #region Operator Combinations + + [Fact] + public async Task CombinedOperators_PathAndQuery() + { + await AssertMatchAsync( + uriTemplate: "test:///api{/version}/resource{?page,limit}", + method: (string version, string page, string limit) => $"version:{version},page:{page},limit:{limit}", + uri: "test:///api/v2/resource?page=1&limit=10", + expectedResult: "version:v2,page:1,limit:10"); + } + + [Fact] + public async Task CombinedOperators_ReservedAndFragment() + { + // Reserved expansion should stop at # (fragment delimiter) so both parts are captured correctly + await AssertMatchAsync( + uriTemplate: "test://{+base}{#section}", + method: (string @base, string section) => $"base:{@base},section:{section}", + uri: "test://example.com/#intro", + expectedResult: "base:example.com/,section:intro"); + } + + #endregion + + #region Variable Modifiers (prefix `:n`) + + [Fact] + public async Task PrefixModifier_InTemplate() + { + // Templates with prefix modifiers should still parse and match + // The regex captures whatever matches (the parser doesn't enforce prefix length) + await AssertMatchAsync( + uriTemplate: "test://{var:3}", + method: (string var) => $"var:{var}", + uri: "test://val", + expectedResult: "var:val"); + } + + #endregion + + #region Explode Modifier + + [Fact] + public async Task ExplodeModifier_InTemplate() + { + // Templates with explode modifiers should still parse and match single values + await AssertMatchAsync( + uriTemplate: "test://{/list*}", + method: (string list) => $"list:{list}", + uri: "test:///item", + expectedResult: "list:item"); + } + + #endregion } diff --git a/tests/ModelContextProtocol.Tests/UriTemplateCreateParserTests.cs b/tests/ModelContextProtocol.Tests/UriTemplateCreateParserTests.cs deleted file mode 100644 index ba9f568c0..000000000 --- a/tests/ModelContextProtocol.Tests/UriTemplateCreateParserTests.cs +++ /dev/null @@ -1,757 +0,0 @@ -using System.Text.RegularExpressions; - -namespace ModelContextProtocol.Tests; - -/// -/// Comprehensive test suite for UriTemplate.CreateParser method. -/// Tests are based on RFC 6570 (URI Template) specification. -/// -public sealed class UriTemplateCreateParserTests -{ - private static Regex CreateParser(string template) => UriTemplate.CreateParser(template); - - private static Match MatchUri(string template, string uri) - { - var regex = CreateParser(template); - return regex.Match(uri); - } - - private static void AssertMatch(string template, string uri, params (string name, string value)[] expectedGroups) - { - var match = MatchUri(template, uri); - Assert.True(match.Success, $"Template '{template}' should match URI '{uri}'"); - - foreach (var (name, value) in expectedGroups) - { - Assert.True(match.Groups[name].Success, $"Group '{name}' should be captured"); - Assert.Equal(value, match.Groups[name].Value); - } - } - - private static void AssertMatchWithGroupCount(string template, string uri, int expectedCapturedGroupCount, params (string name, string value)[] expectedGroups) - { - var match = MatchUri(template, uri); - Assert.True(match.Success, $"Template '{template}' should match URI '{uri}'"); - - // Count groups that actually captured (excluding the default group 0 which is the full match) - int capturedCount = 0; - for (int i = 1; i < match.Groups.Count; i++) - { - if (match.Groups[i].Success && !string.IsNullOrEmpty(match.Groups[i].Value)) - { - capturedCount++; - } - } - Assert.Equal(expectedCapturedGroupCount, capturedCount); - - foreach (var (name, value) in expectedGroups) - { - Assert.True(match.Groups[name].Success, $"Group '{name}' should be captured"); - Assert.Equal(value, match.Groups[name].Value); - } - } - - private static void AssertNoMatch(string template, string uri) - { - var match = MatchUri(template, uri); - Assert.False(match.Success, $"Template '{template}' should NOT match URI '{uri}'"); - } - - private static void AssertMatchWithNoCaptures(string template, string uri) - { - var match = MatchUri(template, uri); - Assert.True(match.Success, $"Template '{template}' should match URI '{uri}'"); - - // Verify that no named groups captured anything (excluding group 0 which is the full match) - for (int i = 1; i < match.Groups.Count; i++) - { - Assert.True( - !match.Groups[i].Success || string.IsNullOrEmpty(match.Groups[i].Value), - $"Group {i} should not capture any value, but captured '{match.Groups[i].Value}'"); - } - } - - #region Level 1: Simple String Expansion {var} - - [Fact] - public void SimpleExpansion_MatchesSingleVariable() - { - AssertMatch("http://example.com/{var}", "http://example.com/value", ("var", "value")); - } - - [Fact] - public void SimpleExpansion_DoesNotMatchSlash() - { - // Simple expansion should NOT match slashes - AssertNoMatch("http://example.com/{var}", "http://example.com/foo/bar"); - } - - [Fact] - public void SimpleExpansion_DoesNotMatchQuestionMark() - { - // Simple expansion should NOT match query string characters - AssertNoMatch("http://example.com/{var}", "http://example.com/foo?query"); - } - - [Fact] - public void SimpleExpansion_DoesNotMatchFragment() - { - // Simple expansion should NOT match fragment delimiter - AssertNoMatch("http://example.com/{var}", "http://example.com/foo#section"); - } - - [Fact] - public void SimpleExpansion_MatchesWithEmptyValue() - { - // Simple expansion variables are optional - empty value matches but captures nothing - AssertMatchWithGroupCount("http://example.com/{var}", "http://example.com/", 0); - } - - [Fact] - public void SimpleExpansion_DoesNotMatchMissingSegment() - { - // Simple expansion is not optional when it's the only content of a segment - AssertNoMatch("http://example.com/{var}", "http://example.com"); - } - - [Fact] - public void SimpleExpansion_DoesNotMatchExtraPath() - { - // Template requires exact match, extra segments should not match - AssertNoMatch("http://example.com/{var}", "http://example.com/value/extra"); - } - - [Fact] - public void SimpleExpansion_MultipleVariables() - { - AssertMatch( - "http://example.com/{x}/{y}", - "http://example.com/1024/768", - ("x", "1024"), - ("y", "768")); - } - - [Fact] - public void SimpleExpansion_MultipleVariables_MatchesWithMissingSecond() - { - // Second variable is optional - matches with only first captured - AssertMatchWithGroupCount( - "http://example.com/{x}/{y}", - "http://example.com/1024/", - 1, - ("x", "1024")); - } - - [Fact] - public void SimpleExpansion_MultipleVariables_MatchesWithMissingFirst() - { - // First variable is optional - matches with only second captured - AssertMatchWithGroupCount( - "http://example.com/{x}/{y}", - "http://example.com//768", - 1, - ("y", "768")); - } - - #endregion - - #region Level 2: Reserved Expansion {+var} - REGRESSION TESTS FOR BUG FIX - - /// - /// FIXED BUG: Reserved expansion {+var} should match slashes. - /// This was the bug that caused samples://{dependency}/{+path} to fail. - /// Per RFC 6570 Section 3.2.3, the + operator allows reserved characters including "/" to pass through. - /// - [Fact] - public void ReservedExpansion_MatchesSlashes() - { - // FIXED: {+path} should match paths containing slashes - AssertMatch( - "samples://{dependency}/{+path}", - "samples://foo/README.md", - ("dependency", "foo"), - ("path", "README.md")); - } - - /// - /// FIXED BUG: Reserved expansion with nested path containing slashes. - /// This is the exact failing case from the issue. - /// - [Fact] - public void ReservedExpansion_MatchesNestedPath() - { - // FIXED: {+path} should match paths with multiple segments - AssertMatch( - "samples://{dependency}/{+path}", - "samples://foo/examples/example.rs", - ("dependency", "foo"), - ("path", "examples/example.rs")); - } - - /// - /// FIXED BUG: Reserved expansion with deep nested path. - /// - [Fact] - public void ReservedExpansion_MatchesDeeplyNestedPath() - { - // FIXED: {+path} should match deeply nested paths - AssertMatch( - "samples://{dependency}/{+path}", - "samples://mylib/src/components/utils/helper.ts", - ("dependency", "mylib"), - ("path", "src/components/utils/helper.ts")); - } - - [Fact] - public void ReservedExpansion_SimpleValue() - { - // Reserved expansion should still work for simple values without slashes - AssertMatch("{+var}", "value", ("var", "value")); - } - - [Fact] - public void ReservedExpansion_WithPathStartingWithSlash() - { - // Reserved expansion allows reserved URI characters like / - AssertMatch("{+path}", "/foo/bar", ("path", "/foo/bar")); - } - - [Fact] - public void ReservedExpansion_StopsAtQueryString() - { - // Reserved expansion should stop at ? (query string delimiter) - // The template doesn't match because it expects the URI to end after {+path} - // but there's a query string. We should verify it doesn't capture the query. - AssertNoMatch("http://example.com/{+path}", "http://example.com/foo/bar?query=test"); - } - - [Fact] - public void ReservedExpansion_StopsAtFragment() - { - // Reserved expansion should stop at # (fragment delimiter) - AssertNoMatch("http://example.com/{+path}", "http://example.com/foo/bar#section"); - } - - [Fact] - public void ReservedExpansion_MatchesEmpty() - { - // Reserved expansion variables are optional - empty matches with 0 captures - AssertMatchWithGroupCount("{+var}", "", 0); - } - - [Fact] - public void ReservedExpansion_DoesNotMatchWrongScheme() - { - // Scheme must match exactly - AssertNoMatch("http://example.com/{+path}", "https://example.com/foo"); - } - - #endregion - - #region Level 2: Fragment Expansion {#var} - - [Fact] - public void FragmentExpansion_MatchesWithHashPrefix() - { - AssertMatch("http://example.com/page{#section}", "http://example.com/page#intro", ("section", "intro")); - } - - [Fact] - public void FragmentExpansion_MatchesSlashes() - { - // Fragment expansion allows reserved characters including / - AssertMatch("{#path}", "#/foo/bar", ("path", "/foo/bar")); - } - - [Fact] - public void FragmentExpansion_OptionalFragment() - { - // Fragment should be optional - match succeeds but section is not captured - AssertMatchWithGroupCount("http://example.com/page{#section}", "http://example.com/page", 0); - } - - [Fact] - public void FragmentExpansion_MatchesWithoutHash() - { - // Fragment expansion prefix is optional - matches with captured value even without # - AssertMatchWithGroupCount("{#section}", "intro", 1, ("section", "intro")); - } - - [Fact] - public void FragmentExpansion_DoesNotMatchWrongPath() - { - // The path must match exactly - AssertNoMatch("http://example.com/page{#section}", "http://example.com/other#intro"); - } - - #endregion - - #region Level 3: Label Expansion with Dot-Prefix {.var} - BUG FIX - - /// - /// FIXED BUG: Label expansion {.var} should match dot-prefixed values. - /// The . operator was falling through to the default case which didn't handle the dot prefix. - /// - [Fact] - public void LabelExpansion_MatchesDotPrefixedSingleValue() - { - // FIXED: {.var} should match .value - AssertMatch("X{.var}", "X.value", ("var", "value")); - } - - /// - /// FIXED BUG: Label expansion with multiple variables should use dot as separator. - /// - [Fact] - public void LabelExpansion_MatchesMultipleValues() - { - // FIXED: {.x,y} should match .1024.768 (dot separated) - AssertMatch("www{.x,y}", "www.example.com", ("x", "example"), ("y", "com")); - } - - [Fact] - public void LabelExpansion_DomainStyle() - { - // Common use case: domain name labels - AssertMatch("www{.dom}", "www.example", ("dom", "example")); - } - - [Fact] - public void LabelExpansion_MatchesWithoutDot() - { - // Label expansion prefix is optional - matches with captured value even without . - AssertMatchWithGroupCount("www{.dom}", "wwwexample", 1, ("dom", "example")); - } - - [Fact] - public void LabelExpansion_DoesNotMatchSlash() - { - // Label expansion should not match slashes - AssertNoMatch("www{.dom}", "www.foo/bar"); - } - - [Fact] - public void LabelExpansion_MatchesEmptyAfterDot() - { - // Label expansion variables are optional - dot with no value matches with 0 captures - AssertMatchWithGroupCount("www{.dom}", "www.", 0); - } - - #endregion - - #region Level 3: Path-Style Parameter Expansion {;var} - BUG FIX - - /// - /// FIXED BUG: Path-style parameter expansion {;var} should match semicolon-prefixed name=value pairs. - /// The ; operator was falling through to the default case which didn't handle the semicolon prefix or name=value format. - /// - [Fact] - public void PathParameterExpansion_MatchesSingleParameter() - { - // FIXED: {;x} should match ;x=1024 - AssertMatch("/path{;x}", "/path;x=1024", ("x", "1024")); - } - - /// - /// FIXED BUG: Path-style parameter expansion with multiple parameters. - /// - [Fact] - public void PathParameterExpansion_MatchesMultipleParameters() - { - // FIXED: {;x,y} should match ;x=1024;y=768 - AssertMatch("/path{;x,y}", "/path;x=1024;y=768", ("x", "1024"), ("y", "768")); - } - - [Fact] - public void PathParameterExpansion_MatchesNameOnly() - { - // Path parameters can be just ;name (without =value) when the value is empty - // The name is present but no value is captured - AssertMatchWithGroupCount("/path{;empty}", "/path;empty", 0); - } - - [Fact] - public void PathParameterExpansion_DoesNotMatchMissingSemicolon() - { - // Path parameter expansion requires the ; prefix - AssertNoMatch("/path{;x}", "/pathx=1024"); - } - - [Fact] - public void PathParameterExpansion_DoesNotMatchWrongParamName() - { - // Parameter name must match - AssertNoMatch("/path{;x}", "/path;y=1024"); - } - - [Fact] - public void PathParameterExpansion_DoesNotMatchSlashInValue() - { - // Path parameter values should not contain slashes - AssertNoMatch("/path{;x}", "/path;x=foo/bar"); - } - - #endregion - - #region Level 3: Path Segment Expansion {/var} - - [Fact] - public void PathSegmentExpansion_MatchesSingleSegment() - { - AssertMatch("{/var}", "/value", ("var", "value")); - } - - [Fact] - public void PathSegmentExpansion_MultipleSegments() - { - // Multiple comma-separated variables in path expansion with / operator - // The template {/x,y} expands to paths like "/value1/value2" - AssertMatchWithGroupCount( - "{/x,y}", - "/1024/768", - 2, - ("x", "1024"), - ("y", "768")); - } - - [Fact] - public void PathSegmentExpansion_ThreeSegments() - { - // Multiple comma-separated variables in path expansion with / operator - // The template {/x,y,z} expands to paths like "/value1/value2/value3" - AssertMatchWithGroupCount( - "{/x,y,z}", - "/a/b/c", - 3, - ("x", "a"), - ("y", "b"), - ("z", "c")); - } - - [Fact] - public void PathSegmentExpansion_DoesNotMatchSlashInValue() - { - // Path segment expansion should NOT match slashes within a single variable's value - // Each variable should match one segment only, so /foo/bar doesn't fully match {/var} - AssertNoMatch("{/var}", "/foo/bar"); - } - - [Fact] - public void PathSegmentExpansion_CombinedWithLiterals() - { - AssertMatch("/users{/id}", "/users/123", ("id", "123")); - } - - [Fact] - public void PathSegmentExpansion_MatchesWithoutSlash() - { - // Path segment expansion prefix is optional - matches with captured value even without / - AssertMatchWithGroupCount("{/var}", "value", 1, ("var", "value")); - } - - [Fact] - public void PathSegmentExpansion_MatchesEmptyAfterSlash() - { - // Path segment expansion variables are optional - slash with no value matches with 0 captures - AssertMatchWithGroupCount("{/var}", "/", 0); - } - - [Fact] - public void PathSegmentExpansion_DoesNotMatchFragment() - { - // Path segment expansion should not match fragment - AssertNoMatch("{/var}", "/value#section"); - } - - [Fact] - public void PathSegmentExpansion_DoesNotMatchQuery() - { - // Path segment expansion should not match query - AssertNoMatch("{/var}", "/value?query"); - } - - #endregion - - #region Level 3: Form-Style Query Expansion {?var} - - [Fact] - public void QueryExpansion_MatchesSingleParameter() - { - AssertMatch("http://example.com/search{?q}", "http://example.com/search?q=test", ("q", "test")); - } - - [Fact] - public void QueryExpansion_MatchesMultipleParameters() - { - AssertMatch( - "http://example.com/search{?q,lang}", - "http://example.com/search?q=cat&lang=en", - ("q", "cat"), - ("lang", "en")); - } - - [Fact] - public void QueryExpansion_OptionalParameters() - { - // All query parameters should be optional - no parameters means no captures - AssertMatchWithGroupCount("http://example.com/search{?q,lang}", "http://example.com/search", 0); - } - - [Fact] - public void QueryExpansion_PartialParameters() - { - // Should match even if not all parameters are present - only q is captured - AssertMatchWithGroupCount( - "http://example.com/search{?q,lang}", - "http://example.com/search?q=test", - 1, - ("q", "test")); - } - - [Fact] - public void QueryExpansion_ThreeParameters() - { - AssertMatch( - "test://params{?a1,a2,a3}", - "test://params?a1=a&a2=b&a3=c", - ("a1", "a"), - ("a2", "b"), - ("a3", "c")); - } - - [Fact] - public void QueryExpansion_DoesNotMatchWrongPath() - { - // The path must match exactly - AssertNoMatch("http://example.com/search{?q}", "http://example.com/find?q=test"); - } - - [Fact] - public void QueryExpansion_DoesNotMatchMissingQuestionMark() - { - // Query expansion requires the ? prefix when parameters are present - AssertNoMatch("http://example.com/search{?q}", "http://example.com/searchq=test"); - } - - [Fact] - public void QueryExpansion_DoesNotMatchSlashInValue() - { - // Query parameter values should not contain slashes - AssertNoMatch("http://example.com/search{?q}", "http://example.com/search?q=foo/bar"); - } - - #endregion - - #region Level 3: Form-Style Query Continuation {&var} - - [Fact] - public void QueryContinuation_MatchesWithExistingQuery() - { - AssertMatch( - "http://example.com/search?fixed=yes{&x}", - "http://example.com/search?fixed=yes&x=1024", - ("x", "1024")); - } - - [Fact] - public void QueryContinuation_MultipleParameters() - { - AssertMatch( - "http://example.com/search?start=0{&x,y}", - "http://example.com/search?start=0&x=1024&y=768", - ("x", "1024"), - ("y", "768")); - } - - [Fact] - public void QueryContinuation_DoesNotMatchMissingAmpersand() - { - // Query continuation requires & prefix - AssertNoMatch("http://example.com/search?start=0{&x}", "http://example.com/search?start=0x=1024"); - } - - [Fact] - public void QueryContinuation_DoesNotMatchMissingFixedQuery() - { - // The fixed query part must be present - AssertNoMatch("http://example.com/search?start=0{&x}", "http://example.com/search&x=1024"); - } - - #endregion - - #region Edge Cases and Special Characters - - [Fact] - public void PctEncodedInValue_MatchesEncodedCharacters() - { - // Values containing percent-encoded characters - AssertMatch("{var}", "Hello%20World", ("var", "Hello%20World")); - } - - [Fact] - public void EmptyTemplate_MatchesEmpty() - { - AssertMatchWithNoCaptures("", ""); - } - - [Fact] - public void LiteralOnlyTemplate_MatchesExactly() - { - AssertMatchWithNoCaptures("http://example.com/static", "http://example.com/static"); - } - - [Fact] - public void LiteralOnlyTemplate_DoesNotMatchDifferentUri() - { - AssertNoMatch("http://example.com/static", "http://example.com/dynamic"); - } - - [Fact] - public void CaseInsensitiveMatching() - { - // URI matching should be case-insensitive for the host portion - AssertMatchWithGroupCount( - "http://EXAMPLE.COM/{var}", - "http://example.com/value", - 1, - ("var", "value")); - } - - [Fact] - public void EmptyTemplate_DoesNotMatchNonEmpty() - { - // Empty template should only match empty string - AssertNoMatch("", "http://example.com"); - } - - [Fact] - public void LiteralOnlyTemplate_DoesNotMatchPartial() - { - // Literal template must match completely - AssertNoMatch("http://example.com/static", "http://example.com/static/extra"); - } - - [Fact] - public void LiteralOnlyTemplate_DoesNotMatchPrefix() - { - // Literal template must match completely - AssertNoMatch("http://example.com/static", "http://example.com/stat"); - } - - #endregion - - #region Complex Real-World Templates - - [Fact] - public void RealWorld_GitHubApiStyle() - { - AssertMatch( - "https://api.github.com/repos/{owner}/{repo}/contents/{+path}", - "https://api.github.com/repos/microsoft/vscode/contents/src/vs/editor/editor.main.ts", - ("owner", "microsoft"), - ("repo", "vscode"), - ("path", "src/vs/editor/editor.main.ts")); - } - - [Fact] - public void RealWorld_FileSystemPath() - { - AssertMatch( - "file:///{+path}", - "file:///home/user/documents/file.txt", - ("path", "home/user/documents/file.txt")); - } - - [Fact] - public void RealWorld_ResourceWithOptionalQuery() - { - AssertMatch( - "test://resource/{id}{?format,version}", - "test://resource/12345?format=json&version=2", - ("id", "12345"), - ("format", "json"), - ("version", "2")); - } - - [Fact] - public void RealWorld_NonTemplatedUri() - { - // Non-templated URIs should match exactly with no captures - AssertMatchWithNoCaptures("test://resource/non-templated", "test://resource/non-templated"); - } - - [Fact] - public void RealWorld_MixedTemplateAndLiteral() - { - AssertMatch( - "http://example.com/users/{userId}/posts/{postId}", - "http://example.com/users/42/posts/100", - ("userId", "42"), - ("postId", "100")); - } - - /// - /// FIXED BUG: The exact case from the bug report - samples scheme with dependency and path. - /// - [Fact] - public void RealWorld_SamplesSchemeWithDependency() - { - AssertMatch( - "samples://{dependency}/{+path}", - "samples://csharp-sdk/README.md", - ("dependency", "csharp-sdk"), - ("path", "README.md")); - } - - #endregion - - #region Operator Combinations - - [Fact] - public void CombinedOperators_PathAndQuery() - { - AssertMatch( - "/api{/version}/resource{?page,limit}", - "/api/v2/resource?page=1&limit=10", - ("version", "v2"), - ("page", "1"), - ("limit", "10")); - } - - [Fact] - public void CombinedOperators_ReservedAndFragment() - { - // Reserved expansion should stop at # (fragment delimiter) so both parts are captured correctly - AssertMatchWithGroupCount( - "{+base}{#section}", - "http://example.com/#intro", - 2, - ("base", "http://example.com/"), - ("section", "intro")); - } - - #endregion - - #region Variable Modifiers (prefix `:n`) - - [Fact] - public void PrefixModifier_InTemplate() - { - // Templates with prefix modifiers should still parse and match - // The regex captures whatever matches (the parser doesn't enforce prefix length) - AssertMatchWithGroupCount("{var:3}", "val", 1, ("var", "val")); - } - - #endregion - - #region Explode Modifier (`*`) - - [Fact] - public void ExplodeModifier_InTemplate() - { - // Templates with explode modifiers should still parse and match single values - AssertMatchWithGroupCount("{/list*}", "/item", 1, ("list", "item")); - } - - #endregion -} From 29cab8fb070d87e175b0f79b2da4ba7f8debb388 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 15 Jan 2026 16:46:31 -0800 Subject: [PATCH 4/6] s/AppendFormatted/AppendLiteral/ in AppendPathParameterExpression --- src/ModelContextProtocol.Core/UriTemplate.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ModelContextProtocol.Core/UriTemplate.cs b/src/ModelContextProtocol.Core/UriTemplate.cs index 83124f6a5..b91db26fa 100644 --- a/src/ModelContextProtocol.Core/UriTemplate.cs +++ b/src/ModelContextProtocol.Core/UriTemplate.cs @@ -206,11 +206,11 @@ static void AppendParameter(ref DefaultInterpolatedStringHandler pattern, string { // Match ;name or ;name=value paramName = Regex.Escape(paramName); - pattern.AppendFormatted("(?:;"); - pattern.AppendFormatted(paramName); - pattern.AppendFormatted("(?:=(?<"); - pattern.AppendFormatted(paramName); - pattern.AppendFormatted(">[^;/?&]+))?)?"); + pattern.AppendLiteral("(?:;"); + pattern.AppendLiteral(paramName); + pattern.AppendLiteral("(?:=(?<"); + pattern.AppendLiteral(paramName); + pattern.AppendLiteral(">[^;/?&]+))?)?"); } } } From ab07843183f3ebf7829558c0d85e1c47702ba918 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 16 Jan 2026 12:39:56 -0800 Subject: [PATCH 5/6] Allow reserved expansion to include empty values --- src/ModelContextProtocol.Core/UriTemplate.cs | 14 +++--- .../McpServerResourceRoutingTests.cs | 43 +++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/ModelContextProtocol.Core/UriTemplate.cs b/src/ModelContextProtocol.Core/UriTemplate.cs index b91db26fa..52b15f54c 100644 --- a/src/ModelContextProtocol.Core/UriTemplate.cs +++ b/src/ModelContextProtocol.Core/UriTemplate.cs @@ -84,12 +84,12 @@ public static Regex CreateParser(string uriTemplate) switch (m.Groups["operator"].Value) { - case "+": AppendExpression(ref pattern, paramNames, null, "[^?&#]+"); break; - case "#": AppendExpression(ref pattern, paramNames, '#', "[^,]+"); break; - case ".": AppendExpression(ref pattern, paramNames, '.', "[^./?#]+"); break; - case "/": AppendExpression(ref pattern, paramNames, '/', "[^/?#]+"); break; + case "+": AppendExpression(ref pattern, paramNames, null, "[^?&#]*"); break; + case "#": AppendExpression(ref pattern, paramNames, '#', "[^,]*"); break; + case ".": AppendExpression(ref pattern, paramNames, '.', "[^./?#]*"); break; + case "/": AppendExpression(ref pattern, paramNames, '/', "[^/?#]*"); break; case ";": AppendPathParameterExpression(ref pattern, paramNames); break; - default: AppendExpression(ref pattern, paramNames, null, "[^/?&#]+"); break; + default: AppendExpression(ref pattern, paramNames, null, "[^/?&#]*"); break; case "?": AppendQueryExpression(ref pattern, paramNames, '?'); break; case "&": AppendQueryExpression(ref pattern, paramNames, '&'); break; @@ -135,7 +135,7 @@ static void AppendParameter(ref DefaultInterpolatedStringHandler pattern, string pattern.AppendFormatted(paramName); pattern.AppendFormatted("=(?<"); pattern.AppendFormatted(paramName); - pattern.AppendFormatted(">[^/?&]+))?"); + pattern.AppendFormatted(">[^/?&]*))?"); } } @@ -210,7 +210,7 @@ static void AppendParameter(ref DefaultInterpolatedStringHandler pattern, string pattern.AppendLiteral(paramName); pattern.AppendLiteral("(?:=(?<"); pattern.AppendLiteral(paramName); - pattern.AppendLiteral(">[^;/?&]+))?)?"); + pattern.AppendLiteral(">[^;/?&]*))?)?"); } } } diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs index 175f8bb46..68a3ea8c6 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs @@ -271,6 +271,49 @@ await AssertNoMatchAsync( uri: "wrongscheme://example.com/foo"); } + /// + /// RFC 6570 specifies that empty values should expand to empty strings. + /// See https://datatracker.ietf.org/doc/html/rfc6570#page-22 test cases: O{+empty}X matches OX. + /// + [Fact] + public async Task ReservedExpansion_MatchesEmptyValue() + { + // Per RFC 6570: O{+empty}X should match OX when empty is "" + await AssertMatchAsync( + uriTemplate: "test://O{+empty}X", + method: (string empty) => $"empty:[{empty}]", + uri: "test://OX", + expectedResult: "empty:[]"); + } + + /// + /// RFC 6570 empty expansion test - reserved expansion at end of template. + /// + [Fact] + public async Task ReservedExpansion_MatchesEmptyValueAtEnd() + { + // {+var} at the end should match empty string + await AssertMatchAsync( + uriTemplate: "test://prefix{+suffix}", + method: (string suffix) => $"suffix:[{suffix}]", + uri: "test://prefix", + expectedResult: "suffix:[]"); + } + + /// + /// RFC 6570 empty expansion test - reserved expansion at start of template. + /// + [Fact] + public async Task ReservedExpansion_MatchesEmptyValueAtStart() + { + // {+var} at the start should match empty string + await AssertMatchAsync( + uriTemplate: "test://{+prefix}suffix", + method: (string prefix) => $"prefix:[{prefix}]", + uri: "test://suffix", + expectedResult: "prefix:[]"); + } + #endregion #region Level 2: Fragment Expansion {#var} From 8036ddd87460bbe01c71f84d1944890dbd7df5aa Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Fri, 16 Jan 2026 16:21:11 -0800 Subject: [PATCH 6/6] Use AppendLiteral where possible in UriTemplate --- src/ModelContextProtocol.Core/UriTemplate.cs | 36 ++++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/ModelContextProtocol.Core/UriTemplate.cs b/src/ModelContextProtocol.Core/UriTemplate.cs index 52b15f54c..4110e0a10 100644 --- a/src/ModelContextProtocol.Core/UriTemplate.cs +++ b/src/ModelContextProtocol.Core/UriTemplate.cs @@ -67,7 +67,7 @@ internal static partial class UriTemplate public static Regex CreateParser(string uriTemplate) { DefaultInterpolatedStringHandler pattern = new(0, 0, CultureInfo.InvariantCulture, stackalloc char[256]); - pattern.AppendFormatted('^'); + pattern.AppendLiteral("^"); int lastIndex = 0; for (Match m = UriTemplateExpression().Match(uriTemplate); m.Success; m = m.NextMatch()) @@ -97,7 +97,7 @@ public static Regex CreateParser(string uriTemplate) } pattern.AppendFormatted(Regex.Escape(uriTemplate.Substring(lastIndex))); - pattern.AppendFormatted('$'); + pattern.AppendLiteral("$"); return new Regex( pattern.ToStringAndClear(), @@ -116,7 +116,7 @@ static void AppendQueryExpression(ref DefaultInterpolatedStringHandler pattern, { Debug.Assert(prefix is '?' or '&'); - pattern.AppendFormatted("(?:\\"); + pattern.AppendLiteral("(?:\\"); pattern.AppendFormatted(prefix); if (paramNames.Count > 0) @@ -124,22 +124,22 @@ static void AppendQueryExpression(ref DefaultInterpolatedStringHandler pattern, AppendParameter(ref pattern, paramNames[0]); for (int i = 1; i < paramNames.Count; i++) { - pattern.AppendFormatted("\\&?"); + pattern.AppendLiteral("\\&?"); AppendParameter(ref pattern, paramNames[i]); } static void AppendParameter(ref DefaultInterpolatedStringHandler pattern, string paramName) { paramName = Regex.Escape(paramName); - pattern.AppendFormatted("(?:"); + pattern.AppendLiteral("(?:"); pattern.AppendFormatted(paramName); - pattern.AppendFormatted("=(?<"); + pattern.AppendLiteral("=(?<"); pattern.AppendFormatted(paramName); - pattern.AppendFormatted(">[^/?&]*))?"); + pattern.AppendLiteral(">[^/?&]*))?"); } } - pattern.AppendFormatted(")?"); + pattern.AppendLiteral(")?"); } // Chooses a regex character‐class (`valueChars`) based on the initial `prefix` to define which @@ -156,9 +156,9 @@ static void AppendExpression(ref DefaultInterpolatedStringHandler pattern, List< { if (prefix is not null) { - pattern.AppendFormatted('\\'); + pattern.AppendLiteral("\\"); pattern.AppendFormatted(prefix); - pattern.AppendFormatted('?'); + pattern.AppendLiteral("?"); } AppendParameter(ref pattern, paramNames[0], valueChars); @@ -174,17 +174,17 @@ static void AppendExpression(ref DefaultInterpolatedStringHandler pattern, List< for (int i = 1; i < paramNames.Count; i++) { pattern.AppendFormatted(separator); - pattern.AppendFormatted('?'); + pattern.AppendLiteral("?"); AppendParameter(ref pattern, paramNames[i], valueChars); } static void AppendParameter(ref DefaultInterpolatedStringHandler pattern, string paramName, string valueChars) { - pattern.AppendFormatted("(?<"); + pattern.AppendLiteral("(?<"); pattern.AppendFormatted(Regex.Escape(paramName)); - pattern.AppendFormatted('>'); + pattern.AppendLiteral(">"); pattern.AppendFormatted(valueChars); - pattern.AppendFormatted(")?"); + pattern.AppendLiteral(")?"); } } } @@ -207,9 +207,9 @@ static void AppendParameter(ref DefaultInterpolatedStringHandler pattern, string // Match ;name or ;name=value paramName = Regex.Escape(paramName); pattern.AppendLiteral("(?:;"); - pattern.AppendLiteral(paramName); + pattern.AppendFormatted(paramName); pattern.AppendLiteral("(?:=(?<"); - pattern.AppendLiteral(paramName); + pattern.AppendFormatted(paramName); pattern.AppendLiteral(">[^;/?&]*))?)?"); } } @@ -476,7 +476,7 @@ static void AppendHex(ref DefaultInterpolatedStringHandler builder, char c) if (c <= 0x7F) { - builder.AppendFormatted('%'); + builder.AppendLiteral("%"); builder.AppendFormatted(hexDigits[c >> 4]); builder.AppendFormatted(hexDigits[c & 0xF]); } @@ -489,7 +489,7 @@ static void AppendHex(ref DefaultInterpolatedStringHandler builder, char c) foreach (byte b in Encoding.UTF8.GetBytes([c])) #endif { - builder.AppendFormatted('%'); + builder.AppendLiteral("%"); builder.AppendFormatted(hexDigits[b >> 4]); builder.AppendFormatted(hexDigits[b & 0xF]); }