Skip to content

Conversation

@theJC
Copy link
Contributor

@theJC theJC commented Nov 15, 2025

Problem

ToolHive's dynamic client registration was sending the scope parameter as a JSON array instead of a space-delimited string, violating RFC 7591 Section 2.

This caused registration failures with OAuth providers that strictly validate RFC 7591 compliance.

Changes

  • Added RequestScopeList type with custom MarshalJSON method to serialize scopes as space-delimited strings
  • Updated DynamicClientRegistrationRequest.Scopes field to use RequestScopeList
  • Enhanced test coverage with TestRequestScopeList_MarshalJSON to verify RFC compliance
  • Updated existing tests to validate space-delimited string format
  • Removed NOTE/TODO comments about the format violation
  • Added INFO logging to demonstrate RFC 7591 scope marshaling in production

RFC 7591 Compliance

Per RFC 7591 Section 2:

"scope: String containing a space-separated list of scope values"

Before:

{
  "scope": ["openid", "profile", "email"]
}

After (RFC 7591 compliant):

{
  "scope": "openid profile email"
}

Real-World Testing

Tested successfully against Atlassian's Rovo MCP Server](https://support.atlassian.com/atlassian-rovo-mcp-server/docs/using-with-other-supported-mcp-clients/) and its OAuth server (https://cf.mcp.atlassian.com):

RFC 7591 Scope Marshaling Proof

RFC 7591: Marshaled RequestScopeList [openid profile email] -> "openid profile email" (space-delimited string)
Successfully registered OAuth client dynamically

This proves the fix correctly:

  1. Transforms Go slice [openid profile email] to JSON string "openid profile email"
  2. Sends space-delimited string per RFC 7591 Section 2 (not JSON array)
  3. Successfully registers with a real third-party OAuth server

The authorization URL also confirms correct format: scope=openid+profile+email (URL-encoded space-delimited).

Testing

  • All existing OAuth tests pass
  • New test TestRequestScopeList_MarshalJSON validates marshaling behavior
  • Updated test TestDynamicClientRegistrationRequest_ScopeSerialization verifies RFC compliance
  • Linting passes with 0 issues
  • Real-world validation against Atlassian MCP OAuth server

Compatibility

The response handling (ScopeList type) already supports both formats for maximum compatibility with various OAuth providers.

Question

Do any mainstream mcp servers only support non-spec / array of strings behavior? If so, does toolhive customers need a config control over the format to allow customers to force non-spec behavior?

Fixes #2596


🤖 Generated with Claude Code

Co-Authored-By: Claude noreply@anthropic.com

Dynamic client registration was sending scopes as a JSON array
instead of a space-delimited string, violating RFC 7591 Section 2.

Changes:
- Add RequestScopeList type with custom MarshalJSON method
- Convert scope arrays to space-delimited strings per RFC 7591
- Update DynamicClientRegistrationRequest to use RequestScopeList
- Update tests to verify RFC 7591 compliance
- Remove TODO comments about the format violation

The ScopeList type for responses already handles both formats
(string and array) for maximum provider compatibility.

Fixes stacklok#2596

Signed-off-by: Claude <noreply@anthropic.com>
@codecov
Copy link

codecov bot commented Nov 15, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 55.88%. Comparing base (5177441) to head (70fd3a1).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2597      +/-   ##
==========================================
+ Coverage   55.87%   55.88%   +0.01%     
==========================================
  Files         311      311              
  Lines       29479    29488       +9     
==========================================
+ Hits        16471    16480       +9     
  Misses      11569    11569              
  Partials     1439     1439              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

}

// Join scopes with spaces and marshal as a string
scopeString := strings.Join(r, " ")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we haven't done that before but reading the code I wonder if we should check if the scopes have spaces in them and reject if yes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. I've added validation to prevent this from happening.

func (r RequestScopeList) MarshalJSON() ([]byte, error) {
// Handle nil or empty slice - return null so omitempty works
if len(r) == 0 {
return []byte("null"), nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this to make Go's omitempty work?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent question. Ive subsequently dug into omitempty's implementation a bit further to understand how it would interact with this code, and was able to be simplify.

theJC and others added 6 commits November 15, 2025 14:06
Adds informative logging to show the transformation of RequestScopeList
from Go slice to RFC 7591 compliant space-delimited string format.

Tested successfully against Atlassian MCP OAuth server:
- Input: [openid profile email]
- Output: "openid profile email" (space-delimited string)
- Result: Successful dynamic client registration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
The INFO level logging was useful for validating RFC 7591 compliance
during testing, but DEBUG is more appropriate for production use.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
The previous implementation returned null for empty slices to work with
omitempty, but testing revealed that omitempty checks the Go value (empty
slice) BEFORE calling MarshalJSON. This means:

1. Empty/nil slices are omitted by omitempty at the struct level
2. MarshalJSON is never called for empty slices
3. The null return was dead code

Simplified implementation:
- Removed unnecessary empty slice check and null return
- MarshalJSON now only handles non-empty slices
- Added comment explaining omitempty handles empty case
- Updated tests to reflect actual behavior (empty -> "")

All tests pass, including RFC 7591 compliance validation against
Atlassian's production OAuth server.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Clarifies that:
- Go's encoding/json evaluates omitempty by checking the Go value
- Empty slices (len == 0) are checked BEFORE calling MarshalJSON
- MarshalJSON is never invoked for empty slices
- Therefore no need to return null or handle empty case

This documentation helps future developers understand the interaction
between custom marshalers and omitempty, preventing confusion about
why we don't check for empty slices.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dynamic client registration violates RFC 7591 scope format

2 participants