-
Notifications
You must be signed in to change notification settings - Fork 590
Description
Currently when the MCP client reads the protected resource metadata document, it will normalize the resource URI with a trailing slash and this can be problematic in cases where the IdP performs strict string comparison of the resource parameter.
To further illustrate, Visual Studio Code will normalize the resource parameter by removing the trailing slash while the C# SDK will normalize by adding a trailing slash. This means 2 duplicate resource configuration would be needed on the IdP to support Visual Studio Code & the C# SDK MCP clients.
The MCP specification requires that the resource parameter be the canonical server URI. Notably this bit of the specification:
Note: While both https://mcp.example.com/ (with trailing slash) and https://mcp.example.com (without trailing slash) are technically valid absolute URIs according to RFC 3986, implementations SHOULD consistently use the form without the trailing slash for better interoperability unless the trailing slash is semantically significant for the specific resource.
Which seems to want the resource parameter processing to be on a per-case basis.
To produce a case where this normalization is problematic, here is a small patch that can be applied on this repo:
Patch
diff --git a/samples/ProtectedMcpServer/Program.cs b/samples/ProtectedMcpServer/Program.cs
index 83b4f6d..8068d1d 100644
--- a/samples/ProtectedMcpServer/Program.cs
+++ b/samples/ProtectedMcpServer/Program.cs
@@ -7,7 +7,7 @@
var builder = WebApplication.CreateBuilder(args);
-var serverUrl = "http://localhost:7071/";
+var serverUrl = "http://localhost:7071";
var inMemoryOAuthServerUrl = "https://localhost:7029";
builder.Services.AddAuthentication(options =>
@@ -56,6 +56,7 @@
{
options.ResourceMetadata = new()
{
+ Resource = new Uri(serverUrl),
ResourceDocumentation = new Uri("https://docs.example.com/api/weather"),
AuthorizationServers = { new Uri(inMemoryOAuthServerUrl) },
ScopesSupported = ["mcp:tools"],
diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
index e13c731..b1c8b24 100644
--- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs
+++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
@@ -19,7 +19,7 @@ public sealed class Program
private static readonly string[] ValidResources = [
"http://localhost:5000/",
"http://localhost:5000/mcp",
- "http://localhost:7071/"
+ "http://localhost:7071"
];
private readonly ConcurrentDictionary<string, AuthorizationCodeInfo> _authCodes = new();
Run ProtectedMcpServer, ProtectedMcpClient & ModelContextProtocol.TestOAuthServer, and it should fail. ProtectedMcpClient will normalize the resource URI in the PRM of ProtectedMcpServer with a trailing slash then send that to ModelContextProtocol.TestOAuthServer which rejects it since it does not know about the trailing slash resource.
The normalization happens in System.Text.Json itself where by the Uri is parsed with normalization.
And to workaround a different normalization scheme here is a hack:
Hack
private static void HackPrmJsonType()
{
Type type = typeof(McpJsonUtilities);
Type jsonContextType = type.GetNestedType("JsonContext", BindingFlags.NonPublic)!;
PropertyInfo defaultProperty = jsonContextType.GetProperty("Default", BindingFlags.Public | BindingFlags.Static)!;
PropertyInfo prmProperty = jsonContextType.GetProperty("ProtectedResourceMetadata", BindingFlags.Instance | BindingFlags.Public)!;
var prmJsonInfo = (JsonTypeInfo<ProtectedResourceMetadata>)prmProperty.GetValue(defaultProperty.GetValue(null))!;
MakeMutable(prmJsonInfo);
prmJsonInfo.OnDeserialized = (obj) =>
{
ProtectedResourceMetadata? prm = obj as ProtectedResourceMetadata;
// Normalize by removing the trailing slash.
if (prm is { Resource: not null } && Uri.TryCreate(prm.Resource.OriginalString.TrimEnd('/'), new UriCreationOptions { DangerousDisablePathAndQueryCanonicalization = true}, out Uri? resourceUri))
{
prm.Resource = resourceUri;
}
};
prmJsonInfo.MakeReadOnly();
static void MakeMutable(JsonTypeInfo options)
{
Type type = typeof(JsonTypeInfo);
PropertyInfo propertyInfo = type.GetProperty("IsReadOnly", BindingFlags.Instance | BindingFlags.Public)!;
propertyInfo.GetSetMethod(nonPublic: true)!.Invoke(options, [false]);
}
}A different policy would be to keep the resource value as is from the PRM, without any normalization.