From d96d4ae1fa1febc1f2eb31507e3c183f972678fc Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Wed, 14 Oct 2026 12:57:44 -0400 Subject: [PATCH] Encode supplementary characters in reserved URI template expansion Reserved ({+var}) and fragment ({#var}) expansion built a Rune from each UTF-16 char, which throws for a surrogate half. Encode the full surrogate pair as one UTF-8 scalar instead. Fixes #1913 --- src/ModelContextProtocol.Core/UriTemplate.cs | 16 ++++++++++------ .../Client/McpClientResourceTemplateTests.cs | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/ModelContextProtocol.Core/UriTemplate.cs b/src/ModelContextProtocol.Core/UriTemplate.cs index fc577d5aa..b8964f83e 100644 --- a/src/ModelContextProtocol.Core/UriTemplate.cs +++ b/src/ModelContextProtocol.Core/UriTemplate.cs @@ -464,18 +464,22 @@ private static string Encode(string value, bool allowReserved) } else { - AppendHex(ref builder, c); + // A supplementary character is a surrogate pair and must be UTF-8 encoded as a single scalar. + int charCount = char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1]) ? 2 : 1; + AppendHex(ref builder, value.AsSpan(i, charCount)); + i += charCount - 1; } } return builder.ToStringAndClear(); - static void AppendHex(ref DefaultInterpolatedStringHandler builder, char c) + static void AppendHex(ref DefaultInterpolatedStringHandler builder, ReadOnlySpan chars) { ReadOnlySpan hexDigits = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']; - if (c <= 0x7F) + if (chars.Length == 1 && chars[0] <= 0x7F) { + char c = chars[0]; builder.AppendLiteral("%"); builder.AppendFormatted(hexDigits[c >> 4]); builder.AppendFormatted(hexDigits[c & 0xF]); @@ -483,10 +487,10 @@ static void AppendHex(ref DefaultInterpolatedStringHandler builder, char c) else { #if NET - Span utf8 = stackalloc byte[Encoding.UTF8.GetMaxByteCount(1)]; - foreach (byte b in utf8.Slice(0, new Rune(c).EncodeToUtf8(utf8))) + Span utf8 = stackalloc byte[Encoding.UTF8.GetMaxByteCount(2)]; + foreach (byte b in utf8.Slice(0, Encoding.UTF8.GetBytes(chars, utf8))) #else - foreach (byte b in Encoding.UTF8.GetBytes([c])) + foreach (byte b in Encoding.UTF8.GetBytes(chars.ToArray())) #endif { builder.AppendLiteral("%"); diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientResourceTemplateTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientResourceTemplateTests.cs index 9bafd8ea4..f295a8e3d 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientResourceTemplateTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientResourceTemplateTests.cs @@ -89,6 +89,22 @@ public async Task UriTemplate_InputsProduceExpectedOutputs( } } + [Theory] + [InlineData("test://resource/{+name}", "hello\U0001F600", "test://resource/hello%F0%9F%98%80")] + [InlineData("test://resource/{#name}", "hello\U0001F600", "test://resource/#hello%F0%9F%98%80")] + [InlineData("test://resource/{+name}", "a\U0001F600b\U00010348/c", "test://resource/a%F0%9F%98%80b%F0%90%8D%88/c")] + [InlineData("test://resource/{+name}", "ü\U0001F600€", "test://resource/%C3%BC%F0%9F%98%80%E2%82%AC")] + public async Task ReservedExpansion_EncodesSupplementaryUnicodeScalar(string uriTemplate, string value, string expectedUri) + { + await using McpClient client = await CreateMcpClientForServer(); + + var result = await client.ReadResourceAsync( + uriTemplate, new Dictionary { ["name"] = value }, null, TestContext.Current.CancellationToken); + + var actualUri = Assert.IsType(Assert.Single(result.Contents)).Text; + Assert.Equal(expectedUri, actualUri); + } + public class TestGroup { [JsonPropertyName("level")]