Skip to content
16 changes: 10 additions & 6 deletions src/ModelContextProtocol.Core/UriTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -464,29 +464,33 @@ 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<char> chars)
{
ReadOnlySpan<char> 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]);
}
else
{
#if NET
Span<byte> utf8 = stackalloc byte[Encoding.UTF8.GetMaxByteCount(1)];
foreach (byte b in utf8.Slice(0, new Rune(c).EncodeToUtf8(utf8)))
Span<byte> 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("%");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,35 @@ 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<string, object?> { ["name"] = value }, null, TestContext.Current.CancellationToken);

var actualUri = Assert.IsType<TextResourceContents>(Assert.Single(result.Contents)).Text;
Assert.Equal(expectedUri, actualUri);
}

// Not InlineData: attribute strings are stored as UTF-8, which would turn a lone surrogate into U+FFFD.
[Fact]
public async Task ReservedExpansion_LoneSurrogate_EncodesReplacementCharacter()
{
await using McpClient client = await CreateMcpClientForServer();

var result = await client.ReadResourceAsync(
"test://resource/{+name}", new Dictionary<string, object?> { ["name"] = "\uD83D" }, null, TestContext.Current.CancellationToken);

var actualUri = Assert.IsType<TextResourceContents>(Assert.Single(result.Contents)).Text;
Assert.Equal("test://resource/%EF%BF%BD", actualUri);
}

public class TestGroup
{
[JsonPropertyName("level")]
Expand Down
Loading