docs/plans/2025-11-27-phase0-oauth-diagnostics.md
Status: Ready for Implementation Priority: P0 (Prerequisite for OAuth extra params) Estimated Duration: 2 days Parent Plan: docs/plans/2025-11-27-oauth-extra-params.md
Users cannot diagnose OAuth authentication failures because:
"oauth": {} in config)auth status reports no OAuth servers foundThis creates a visibility gap where OAuth appears broken but users can't tell why.
$ ./mcpproxy auth status
ℹ️ No servers with OAuth configuration found.
Configure OAuth in mcp_config.json to enable authentication.
# Logs show OAuth is configured and failing:
INFO | 🌟 Starting OAuth authentication flow | {"scopes": [], "pkce_enabled": true}
ERROR | ❌ MCP initialization failed | {"error": "no valid token available, authorization required"}
INFO | 🎯 OAuth authorization required during MCP init - deferring OAuth
WARN | Connection error, will attempt reconnection | {"retry_count": 101}
$ ./mcpproxy upstream list --output json | jq '.[] | select(.name == "slack")'
{
"authenticated": false,
"name": "slack",
"oauth": null, // ← Should contain OAuth config
"status": "connecting"
}
File: internal/contracts/converters.go
Line: ~35
func ToServerContract(cfg *config.ServerConfig, status *upstream.ServerStatus) contracts.Server {
return contracts.Server{
Name: cfg.Name,
OAuth: nil, // ← TODO: Convert config.OAuth to contracts.OAuthConfig
Authenticated: status.Authenticated,
}
}
Problem: The conversion function doesn't map config.OAuth to contracts.OAuth, so the API returns null.
File: internal/upstream/core/connection.go
Line: ~1078
if err != nil {
return fmt.Errorf("no valid token available, authorization required")
}
Problem: Error doesn't capture provider-specific requirements like missing resource parameter.
File: internal/management/diagnostics.go
Line: ~58
if hasOAuth && !authenticated {
diag.OAuthRequired = append(diag.OAuthRequired, contracts.OAuthRequirement{
ServerName: serverName,
State: "unauthenticated",
Message: "Run: mcpproxy auth login --server=slack",
})
}
Problem: Diagnostics only report "not authenticated" without explaining why authentication failed.
$ ./mcpproxy auth status
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔐 OAuth Authentication Status
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Server: slack
Status: ❌ Authentication Failed
Error: OAuth provider requires 'resource' parameter (RFC 8707)
Auth URL: https://oauth.example.com/.well-known/oauth-authorization-server
Token URL: https://oauth.example.com/api/v1/oauth/token
Last Attempt: 2025-11-27 15:45:10
Retry Count: 101
💡 Suggestion:
The OAuth provider requires additional parameters that MCPProxy
doesn't currently support. This will be fixed in an upcoming release.
As a workaround, you can try:
1. Check if the provider has alternative auth methods
2. Contact the provider about OAuth parameter requirements
3. Wait for MCPProxy extra_params support (coming soon)
Technical Details:
- Missing parameter: resource
- Expected format: resource=<MCP_ENDPOINT_URL>
- RFC 8707: https://www.rfc-editor.org/rfc/rfc8707.html
$ ./mcpproxy doctor
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📋 System Diagnostics
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔍 OAuth Configuration Issues (1)
Server: slack
Issue: OAuth provider parameter mismatch
Error: Provider requires 'resource' parameter (RFC 8707)
Impact: Server cannot authenticate until parameter is provided
Resolution:
This requires MCPProxy support for OAuth extra_params.
Track progress: https://github.com/smart-mcp-proxy/mcpproxy-go/issues/XXX
$ ./mcpproxy upstream list --output json | jq '.[] | select(.name == "slack")'
{
"authenticated": false,
"name": "slack",
"oauth": {
"auth_url": "https://oauth.example.com/api/v1/oauth/authorize",
"token_url": "https://oauth.example.com/api/v1/oauth/token",
"scopes": []
},
"last_error": "OAuth provider requires 'resource' parameter",
"status": "connecting"
}
File: internal/contracts/converters.go
Changes:
func ToServerContract(cfg *config.ServerConfig, status *upstream.ServerStatus) contracts.Server {
var oauthConfig *contracts.OAuthConfig
if cfg.OAuth != nil {
// Get discovered OAuth endpoints from status if available
authURL := ""
tokenURL := ""
if status.OAuthMetadata != nil {
authURL = status.OAuthMetadata.AuthorizationEndpoint
tokenURL = status.OAuthMetadata.TokenEndpoint
}
oauthConfig = &contracts.OAuthConfig{
AuthURL: authURL,
TokenURL: tokenURL,
ClientID: cfg.OAuth.ClientID,
Scopes: cfg.OAuth.Scopes,
}
}
return contracts.Server{
Name: cfg.Name,
OAuth: oauthConfig,
Authenticated: status.Authenticated,
LastError: status.LastError,
// ... other fields ...
}
}
Test:
$ ./mcpproxy upstream list --output json | jq '.[] | select(.name == "slack") | .oauth'
{
"auth_url": "https://oauth.example.com/authorize",
"token_url": "https://oauth.example.com/token",
"scopes": []
}
File: internal/upstream/core/connection.go
Add struct field:
type ServerStatus struct {
// ... existing fields ...
OAuthMetadata *OAuthMetadata // NEW
}
type OAuthMetadata struct {
AuthorizationEndpoint string
TokenEndpoint string
Issuer string
}
Store metadata after discovery:
func (c *Client) connectWithOAuth(ctx context.Context) error {
// ... existing OAuth discovery code ...
// After CreateOAuthConfig succeeds
if oauthConfig != nil {
c.status.OAuthMetadata = &OAuthMetadata{
AuthorizationEndpoint: discoveredAuthURL,
TokenEndpoint: discoveredTokenURL,
Issuer: discoveredIssuer,
}
}
// ... continue OAuth flow ...
}
File: internal/upstream/core/connection.go
Add error parsing:
// parseOAuthError extracts structured error information from OAuth provider responses
func parseOAuthError(err error, responseBody []byte) error {
// Try to parse as FastAPI validation error (Runlayer format)
var fapiErr struct {
Detail []struct {
Type string `json:"type"`
Loc []string `json:"loc"`
Msg string `json:"msg"`
Input any `json:"input"`
} `json:"detail"`
}
if json.Unmarshal(responseBody, &fapiErr) == nil && len(fapiErr.Detail) > 0 {
for _, detail := range fapiErr.Detail {
if detail.Type == "missing" && len(detail.Loc) >= 2 {
if detail.Loc[0] == "query" {
paramName := detail.Loc[1]
return &OAuthParameterError{
Parameter: paramName,
Location: "authorization_url",
Message: detail.Msg,
OriginalErr: err,
}
}
}
}
}
// Try to parse as RFC 6749 OAuth error response
var oauthErr struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
ErrorURI string `json:"error_uri"`
}
if json.Unmarshal(responseBody, &oauthErr) == nil && oauthErr.Error != "" {
return fmt.Errorf("OAuth error: %s - %s", oauthErr.Error, oauthErr.ErrorDescription)
}
// Fallback to original error
return err
}
// OAuthParameterError represents a missing or invalid OAuth parameter
type OAuthParameterError struct {
Parameter string
Location string // "authorization_url" or "token_request"
Message string
OriginalErr error
}
func (e *OAuthParameterError) Error() string {
return fmt.Sprintf("OAuth provider requires '%s' parameter: %s", e.Parameter, e.Message)
}
func (e *OAuthParameterError) Unwrap() error {
return e.OriginalErr
}
Use in connection flow:
func (c *Client) handleOAuthAuthorization(ctx context.Context, authErr error, oauthConfig *client.OAuthConfig) error {
// ... existing code ...
resp, err := http.Get(authURL)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return parseOAuthError(err, body)
}
// ... continue OAuth flow ...
}
File: cmd/mcpproxy/auth_cmd.go
Update display logic:
func runAuthStatusClientMode(ctx context.Context, dataDir, serverName string, allServers bool) error {
// ... existing server fetching code ...
hasOAuthServers := false
for _, srv := range servers {
name, _ := srv["name"].(string)
oauth, hasOAuth := srv["oauth"].(map[string]interface{})
if !hasOAuth {
continue
}
hasOAuthServers = true
authenticated, _ := srv["authenticated"].(bool)
lastError, _ := srv["last_error"].(string)
// Determine status emoji and text
var status string
if authenticated {
status = "✅ Authenticated"
} else if lastError != "" {
status = "❌ Authentication Failed"
} else {
status = "⏳ Pending Authentication"
}
fmt.Printf("Server: %s\n", name)
fmt.Printf(" Status: %s\n", status)
if authURL, ok := oauth["auth_url"].(string); ok && authURL != "" {
fmt.Printf(" Auth URL: %s\n", authURL)
}
if tokenURL, ok := oauth["token_url"].(string); ok && tokenURL != "" {
fmt.Printf(" Token URL: %s\n", tokenURL)
}
if lastError != "" {
fmt.Printf(" Error: %s\n", lastError)
// Provide suggestions based on error type
if strings.Contains(lastError, "requires") && strings.Contains(lastError, "parameter") {
fmt.Println()
fmt.Println(" 💡 Suggestion:")
fmt.Println(" This OAuth provider requires additional parameters that")
fmt.Println(" MCPProxy doesn't currently support. Support for custom")
fmt.Println(" OAuth parameters (extra_params) is coming soon.")
fmt.Println()
fmt.Println(" For more information:")
fmt.Println(" - RFC 8707: https://www.rfc-editor.org/rfc/rfc8707.html")
fmt.Println(" - Track progress: https://github.com/smart-mcp-proxy/mcpproxy-go/issues/XXX")
}
}
fmt.Println()
}
if !hasOAuthServers {
fmt.Println("ℹ️ No servers with OAuth configuration found.")
fmt.Println(" Configure OAuth in mcp_config.json to enable authentication.")
}
return nil
}
File: internal/management/diagnostics.go
Add new diagnostic type:
type OAuthIssue struct {
ServerName string `json:"server_name"`
Issue string `json:"issue"`
Error string `json:"error"`
MissingParams []string `json:"missing_params,omitempty"`
Resolution string `json:"resolution"`
DocumentationURL string `json:"documentation_url,omitempty"`
}
Update Diagnostics struct:
type Diagnostics struct {
Timestamp time.Time
UpstreamErrors []UpstreamError
OAuthRequired []OAuthRequirement
OAuthIssues []OAuthIssue // NEW
MissingSecrets []MissingSecretInfo
RuntimeWarnings []string
DockerStatus *DockerStatus
TotalIssues int
}
Add OAuth issue detection:
func (s *service) Doctor(ctx context.Context) (*contracts.Diagnostics, error) {
// ... existing code ...
// Check for OAuth issues
diag.OAuthIssues = s.detectOAuthIssues(serversRaw)
// Update total issues
diag.TotalIssues = len(diag.UpstreamErrors) + len(diag.OAuthRequired) +
len(diag.OAuthIssues) + len(diag.MissingSecrets) + len(diag.RuntimeWarnings)
return diag, nil
}
func (s *service) detectOAuthIssues(servers []map[string]interface{}) []contracts.OAuthIssue {
var issues []contracts.OAuthIssue
for _, srvRaw := range servers {
serverName := getStringFromMap(srvRaw, "name")
hasOAuth := srvRaw["oauth"] != nil
lastError := getStringFromMap(srvRaw, "last_error")
authenticated := getBoolFromMap(srvRaw, "authenticated")
// Skip servers without OAuth or already authenticated
if !hasOAuth || authenticated {
continue
}
// Check for parameter-related errors
if strings.Contains(lastError, "requires") && strings.Contains(lastError, "parameter") {
// Extract parameter name from error
paramName := extractParameterName(lastError)
issues = append(issues, contracts.OAuthIssue{
ServerName: serverName,
Issue: "OAuth provider parameter mismatch",
Error: lastError,
MissingParams: []string{paramName},
Resolution: fmt.Sprintf(
"This requires MCPProxy support for OAuth extra_params. " +
"Track progress: https://github.com/smart-mcp-proxy/mcpproxy-go/issues/XXX"),
DocumentationURL: "https://www.rfc-editor.org/rfc/rfc8707.html",
})
}
}
return issues
}
func extractParameterName(errorMsg string) string {
// Extract parameter name from error like "requires 'resource' parameter"
re := regexp.MustCompile(`'([^']+)' parameter`)
matches := re.FindStringSubmatch(errorMsg)
if len(matches) > 1 {
return matches[1]
}
return "unknown"
}
Update doctor command output (cmd/mcpproxy/doctor_cmd.go):
func outputDiagnostics(diag map[string]interface{}, format string) error {
// ... existing code ...
// Add OAuth issues section
if oauthIssues := getArrayField(diag, "oauth_issues"); len(oauthIssues) > 0 {
fmt.Println()
fmt.Printf("🔍 OAuth Configuration Issues (%d)\n", len(oauthIssues))
fmt.Println()
for _, issue := range oauthIssues {
issueMap := issue.(map[string]interface{})
serverName := issueMap["server_name"].(string)
issueDesc := issueMap["issue"].(string)
errorMsg := issueMap["error"].(string)
resolution := issueMap["resolution"].(string)
fmt.Printf(" Server: %s\n", serverName)
fmt.Printf(" Issue: %s\n", issueDesc)
fmt.Printf(" Error: %s\n", errorMsg)
fmt.Printf(" Impact: Server cannot authenticate until parameter is provided\n")
fmt.Println()
fmt.Printf(" Resolution:\n")
fmt.Printf(" %s\n", resolution)
if docURL := issueMap["documentation_url"]; docURL != nil {
fmt.Printf(" Documentation: %s\n", docURL)
}
fmt.Println()
}
}
// ... rest of output ...
}
Test OAuth Config Serialization:
func TestToServerContract_WithOAuth(t *testing.T) {
cfg := &config.ServerConfig{
Name: "test-server",
OAuth: &config.OAuthConfig{
ClientID: "client123",
Scopes: []string{"read", "write"},
},
}
status := &upstream.ServerStatus{
Authenticated: false,
OAuthMetadata: &upstream.OAuthMetadata{
AuthorizationEndpoint: "https://oauth.example.com/authorize",
TokenEndpoint: "https://oauth.example.com/token",
},
}
contract := converters.ToServerContract(cfg, status)
require.NotNil(t, contract.OAuth)
assert.Equal(t, "https://oauth.example.com/authorize", contract.OAuth.AuthURL)
assert.Equal(t, "https://oauth.example.com/token", contract.OAuth.TokenURL)
assert.Equal(t, "client123", contract.OAuth.ClientID)
assert.Equal(t, []string{"read", "write"}, contract.OAuth.Scopes)
}
Test OAuth Error Parsing:
func TestParseOAuthError_FastAPIValidation(t *testing.T) {
responseBody := []byte(`{
"detail": [
{
"type": "missing",
"loc": ["query", "resource"],
"msg": "Field required",
"input": null
}
]
}`)
err := parseOAuthError(errors.New("validation failed"), responseBody)
require.Error(t, err)
var paramErr *OAuthParameterError
require.True(t, errors.As(err, ¶mErr))
assert.Equal(t, "resource", paramErr.Parameter)
assert.Equal(t, "authorization_url", paramErr.Location)
assert.Contains(t, err.Error(), "requires 'resource' parameter")
}
Test auth status Output:
func TestAuthStatus_ShowsOAuthErrors(t *testing.T) {
// Setup mock server with OAuth config
// ... setup code ...
// Run auth status command
output := captureOutput(func() {
runAuthStatus(nil, nil)
})
// Verify output contains error details
assert.Contains(t, output, "❌ Authentication Failed")
assert.Contains(t, output, "requires 'resource' parameter")
assert.Contains(t, output, "💡 Suggestion:")
}
oauth: {})./mcpproxy auth status - should show slack server with OAuth./mcpproxy doctor - should list OAuth configuration issue/api/v1/servers endpoint - should include oauth configauth status shows OAuth-configured servers (not "no servers found")doctor command detects OAuth parameter mismatchesEach PR can be reviewed and deployed independently.
After implementation, update:
docs/runlayer-oauth-investigation.md - Link to Phase 0 completionREADME.md - Mention improved OAuth diagnosticsdocs/troubleshooting.md - Add section on OAuth error messages