diff --git a/coderd/mcp.go b/coderd/mcp.go index 7ba73157c54..07fa1f19b31 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -1702,9 +1702,29 @@ type mcpOAuth2Discovery struct { // protectedResourceMetadata represents the response from a // Protected Resource Metadata endpoint per RFC 9728 §2. type protectedResourceMetadata struct { - Resource string `json:"resource"` - AuthorizationServers []string `json:"authorization_servers"` - ScopesSupported []string `json:"scopes_supported,omitempty"` + Resource resourceIdentifiers `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + ScopesSupported []string `json:"scopes_supported,omitempty"` +} + +// resourceIdentifiers tolerates both a single JSON string and an +// array of strings. RFC 9728 §2 defines "resource" as a string, but +// some servers (e.g. GitLab's official MCP server) return an array +// when the metadata document covers multiple resources. +type resourceIdentifiers []string + +func (r *resourceIdentifiers) UnmarshalJSON(data []byte) error { + var single string + if err := json.Unmarshal(data, &single); err == nil { + *r = resourceIdentifiers{single} + return nil + } + var many []string + if err := json.Unmarshal(data, &many); err != nil { + return xerrors.New("resource must be a string or an array of strings") + } + *r = resourceIdentifiers(many) + return nil } // authServerMetadata represents the response from an Authorization diff --git a/coderd/mcp_internal_test.go b/coderd/mcp_internal_test.go index 8c757a638d9..c5ce6b333fb 100644 --- a/coderd/mcp_internal_test.go +++ b/coderd/mcp_internal_test.go @@ -2,6 +2,7 @@ package coderd import ( "context" + "encoding/json" "sync/atomic" "testing" "time" @@ -214,3 +215,55 @@ func TestOIDCMCPTokenSource(t *testing.T) { require.Empty(t, tok) }) } + +func TestProtectedResourceMetadataResourceUnmarshal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want resourceIdentifiers + wantErr bool + }{ + { + name: "String", + input: `{"resource": "https://gitlab.example.com/api/v4/mcp", "authorization_servers": ["https://gitlab.example.com"]}`, + want: resourceIdentifiers{"https://gitlab.example.com/api/v4/mcp"}, + }, + { + // GitLab's official MCP server returns an array of + // resources despite RFC 9728 defining a string. + name: "Array", + input: `{"resource": ["https://gitlab.example.com/api/v4/mcp", "https://gitlab.example.com/api/v4/orbit/mcp"], "authorization_servers": ["https://gitlab.example.com"]}`, + want: resourceIdentifiers{ + "https://gitlab.example.com/api/v4/mcp", + "https://gitlab.example.com/api/v4/orbit/mcp", + }, + }, + { + name: "Absent", + input: `{"authorization_servers": ["https://gitlab.example.com"]}`, + want: nil, + }, + { + name: "InvalidType", + input: `{"resource": 42, "authorization_servers": ["https://gitlab.example.com"]}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var meta protectedResourceMetadata + err := json.Unmarshal([]byte(tt.input), &meta) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, meta.Resource) + }) + } +}