Skip to content

MCP client OAuth resource parameter normalization #1122

@FICTURE7

Description

@FICTURE7

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.

https://github.com/dotnet/runtime/blob/6aecb65a7eff3e91dd6b8856401854e03c30a839/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/UriConverter.cs#L38-L41

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions