Skip to content

Commit c9df1cb

Browse files
feat: implement OAuth extra parameters workaround for RFC 8707 Runlayer integration
1 parent abcd512 commit c9df1cb

File tree

2 files changed

+206
-11
lines changed

2 files changed

+206
-11
lines changed

‎docs/plans/2025-11-27-oauth-extra-params.md‎

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Implementation Plan: OAuth Extra Parameters Support
22

3-
**Status**: Proposed
3+
**Status**: Partially Implemented (Workaround Active)
44
**Created**: 2025-11-27
5+
**Updated**: 2025-12-01
56
**Priority**: High (blocks Runlayer integration)
67
**Related**: docs/runlayer-oauth-investigation.md
78

@@ -837,6 +838,177 @@ Initial deployment with feature flag disabled by default:
837838
4. How to handle parameter conflicts with discovered metadata?
838839
- **Decision**: User-specified extra_params take precedence (explicit override)
839840

841+
## Current Implementation Status (2025-12-01)
842+
843+
### ✅ Implemented: Simple Workaround
844+
845+
A **15-line workaround** has been successfully implemented that injects `extraParams` into the OAuth authorization URL after mcp-go constructs it:
846+
847+
**Location**: `internal/upstream/core/connection.go:1845-1865`
848+
849+
**Implementation**:
850+
```go
851+
// Add extra OAuth parameters if configured (workaround until mcp-go supports this natively)
852+
if len(extraParams) > 0 {
853+
u, err := url.Parse(authURL)
854+
if err != nil {
855+
return fmt.Errorf("failed to parse authorization URL: %w", err)
856+
}
857+
858+
q := u.Query()
859+
for k, v := range extraParams {
860+
q.Set(k, v)
861+
}
862+
u.RawQuery = q.Encode()
863+
authURL = u.String()
864+
865+
c.logger.Info("✨ Added extra OAuth parameters to authorization URL",
866+
zap.String("server", c.config.Name),
867+
zap.Any("extra_params", extraParams))
868+
}
869+
```
870+
871+
**Results**:
872+
- ✅ Slack (Runlayer) OAuth working with `resource` parameter
873+
- ✅ Glean (Runlayer) OAuth working with `resource` parameter
874+
- ✅ Zero-configuration for Runlayer servers (auto-detected from `.well-known/oauth-authorization-server`)
875+
- ✅ 14 Slack tools and 7 Glean tools accessible
876+
877+
**Testing**:
878+
```bash
879+
./mcpproxy auth login --server=slack
880+
# ✅ OAuth succeeds with resource parameter injected
881+
# ✅ Authorization URL includes: &resource=https%3A%2F%2F...%2Fmcp
882+
883+
./mcpproxy tools list --server=slack
884+
# ✅ 14 tools available (search_messages, post_message, etc.)
885+
```
886+
887+
### ❌ UX Gap: OAuth Pending State Appears as Error
888+
889+
**Problem**: When OAuth-required servers connect during startup, they show as "failed" even though they just need user authentication.
890+
891+
**Current User Experience**:
892+
1. User adds Runlayer Slack server to config
893+
2. MCPProxy starts, attempts automatic connection
894+
3. Logs show: `"failed to connect: OAuth authorization required - deferred for background processing"`
895+
4. Server state: `Error` (❌)
896+
5. User sees error and thinks something is broken
897+
6. User must discover tray menu or `auth login` command manually
898+
899+
**Code Location**: `internal/upstream/core/connection.go:1164`
900+
```go
901+
return fmt.Errorf("OAuth authorization required - deferred for background processing")
902+
```
903+
904+
**Impact**:
905+
- 🚨 **Confusing**: Appears as an error when it's really a pending state
906+
- 🚨 **Poor Discoverability**: No clear indication that user needs to take action
907+
- 🚨 **Misleading Status**: Server shows "Error" instead of "Pending Auth"
908+
- 🚨 **Hidden Solution**: OAuth login action not prominently surfaced
909+
910+
**Desired User Experience**:
911+
1. User adds Runlayer Slack server to config
912+
2. MCPProxy starts, detects OAuth requirement
913+
3. Logs show: `"⏳ Slack requires authentication - login via tray menu or CLI"`
914+
4. Server state: `PendingAuth` (⏳)
915+
5. Tray shows: "🔐 Slack - Click to authenticate"
916+
6. User clicks tray menu → OAuth flow starts immediately
917+
918+
**Proposed Solutions**:
919+
920+
#### Option A: New Server State (Recommended)
921+
Add a `PendingAuth` state separate from `Error`:
922+
```go
923+
// internal/upstream/manager.go
924+
const (
925+
StateDisconnected = "Disconnected"
926+
StateConnecting = "Connecting"
927+
StatePendingAuth = "PendingAuth" // NEW
928+
StateReady = "Ready"
929+
StateError = "Error"
930+
)
931+
```
932+
933+
**Changes Required**:
934+
1. Add `PendingAuth` state to state machine
935+
2. Return special error type for OAuth deferral: `ErrOAuthPending`
936+
3. Supervisor recognizes `ErrOAuthPending` → transition to `PendingAuth`
937+
4. Tray UI shows ⏳ icon with "Click to authenticate" tooltip
938+
5. `upstream list` shows: `slack ⏳ Pending Auth Login required`
939+
940+
#### Option B: Proactive OAuth Notification
941+
Show system notification when OAuth-required server is detected:
942+
```go
943+
if isDeferOAuthForTray() {
944+
notification.Show("Slack requires authentication", "Click here to login")
945+
// Auto-highlight tray menu item
946+
}
947+
```
948+
949+
#### Option C: Auto-trigger OAuth Flow
950+
Automatically open OAuth flow on first connection attempt (with user consent):
951+
```go
952+
if firstConnectionAttempt && requiresOAuth && !userOptedOut {
953+
// Start OAuth flow immediately instead of deferring
954+
handleOAuthAuthorization(ctx, err, oauthConfig, extraParams)
955+
}
956+
```
957+
958+
**Recommendation**: Implement **Option A (New State)** + **Option B (Notification)** for best UX.
959+
960+
### 🔧 Required Implementation Work
961+
962+
To properly address the UX gap:
963+
964+
#### 1. State Machine Changes
965+
- Add `PendingAuth` state to `internal/upstream/manager.go`
966+
- Create `ErrOAuthPending` error type
967+
- Update state transition logic to handle OAuth deferral
968+
969+
#### 2. Connection Layer Changes
970+
- Return `ErrOAuthPending` instead of generic error (line 1164)
971+
- Add metadata: OAuth URL, server name, instructions
972+
973+
#### 3. UI/UX Changes
974+
- Tray: Show ⏳ icon for `PendingAuth` servers with "Authenticate" action
975+
- CLI: `upstream list` displays "Pending Auth" with clear instructions
976+
- Logs: Use INFO level instead of ERROR for OAuth deferral
977+
- Notification: Optional system notification for new OAuth requirements
978+
979+
#### 4. Testing
980+
- Unit tests for `PendingAuth` state transitions
981+
- E2E test: Add OAuth server, verify state is `PendingAuth` not `Error`
982+
- UX test: Verify tray menu shows authentication action
983+
984+
#### 5. Documentation
985+
- Update user guide: "Understanding OAuth Server States"
986+
- CLI help text: Explain `PendingAuth` status
987+
- Troubleshooting: "Server shows as pending - what to do?"
988+
989+
### Timeline Estimate
990+
991+
| Task | Effort | Priority |
992+
|------|--------|----------|
993+
| State machine refactor | 2-3 hours | High |
994+
| Connection error handling | 1-2 hours | High |
995+
| Tray UI updates | 2-3 hours | High |
996+
| CLI display updates | 1 hour | Medium |
997+
| System notifications | 2 hours | Low |
998+
| Testing | 2-3 hours | High |
999+
| Documentation | 1-2 hours | Medium |
1000+
| **Total** | **11-16 hours** | **~2 days** |
1001+
1002+
### Success Criteria
1003+
1004+
- ✅ OAuth-required servers never show state as `Error` before user authentication
1005+
- ✅ Server state shows `PendingAuth` with clear action required
1006+
- ✅ Tray menu prominently displays "Authenticate" action for pending servers
1007+
-`upstream list` output clearly distinguishes pending auth from actual errors
1008+
- ✅ Logs use INFO level for OAuth deferral, not ERROR
1009+
- ✅ Optional: System notification alerts user to authentication requirement
1010+
- ✅ User can complete authentication within 30 seconds of seeing notification
1011+
8401012
## References
8411013

8421014
- RFC 8707: Resource Indicators - https://www.rfc-editor.org/rfc/rfc8707.html

‎internal/upstream/core/connection.go‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"net/url"
78
"os"
89
"os/exec"
910
"reflect"
@@ -1101,7 +1102,7 @@ func (c *Client) tryOAuthAuth(ctx context.Context) error {
11011102
zap.String("server", c.config.Name))
11021103

11031104
// Handle OAuth authorization manually using the example pattern
1104-
if oauthErr := c.handleOAuthAuthorization(ctx, err, oauthConfig); oauthErr != nil {
1105+
if oauthErr := c.handleOAuthAuthorization(ctx, err, oauthConfig, extraParams); oauthErr != nil {
11051106
c.clearOAuthState() // Clear state on OAuth failure
11061107
return fmt.Errorf("OAuth authorization failed: %w", oauthErr)
11071108
}
@@ -1167,7 +1168,7 @@ func (c *Client) tryOAuthAuth(ctx context.Context) error {
11671168
c.clearOAuthState()
11681169

11691170
// Handle OAuth authorization manually using the example pattern
1170-
if oauthErr := c.handleOAuthAuthorization(ctx, err, oauthConfig); oauthErr != nil {
1171+
if oauthErr := c.handleOAuthAuthorization(ctx, err, oauthConfig, extraParams); oauthErr != nil {
11711172
c.clearOAuthState() // Clear state on OAuth failure
11721173
return fmt.Errorf("OAuth authorization during MCP init failed: %w", oauthErr)
11731174
}
@@ -1312,7 +1313,7 @@ func (c *Client) trySSEOAuthAuth(ctx context.Context) error {
13121313
zap.String("strategy", "SSE OAuth"))
13131314

13141315
// Create OAuth config using the oauth package
1315-
oauthConfig, _ := oauth.CreateOAuthConfig(c.config, c.storage)
1316+
oauthConfig, extraParams := oauth.CreateOAuthConfig(c.config, c.storage)
13161317
if oauthConfig == nil {
13171318
return fmt.Errorf("failed to create OAuth config")
13181319
}
@@ -1420,7 +1421,7 @@ func (c *Client) trySSEOAuthAuth(ctx context.Context) error {
14201421
zap.String("server", c.config.Name))
14211422

14221423
// Handle OAuth authorization manually using the example pattern
1423-
if oauthErr := c.handleOAuthAuthorization(ctx, err, oauthConfig); oauthErr != nil {
1424+
if oauthErr := c.handleOAuthAuthorization(ctx, err, oauthConfig, extraParams); oauthErr != nil {
14241425
c.clearOAuthState() // Clear state on OAuth failure
14251426
return fmt.Errorf("SSE OAuth authorization failed: %w", oauthErr)
14261427
}
@@ -1721,7 +1722,7 @@ func (c *Client) DisconnectWithContext(_ context.Context) error {
17211722
}
17221723

17231724
// handleOAuthAuthorization handles the manual OAuth flow following the mcp-go example pattern
1724-
func (c *Client) handleOAuthAuthorization(ctx context.Context, authErr error, oauthConfig *client.OAuthConfig) error {
1725+
func (c *Client) handleOAuthAuthorization(ctx context.Context, authErr error, oauthConfig *client.OAuthConfig, extraParams map[string]string) error {
17251726
// Check if OAuth is already in progress to prevent duplicate flows (CRITICAL FIX for Phase 1)
17261727
if c.isOAuthInProgress() {
17271728
c.logger.Warn("⚠️ OAuth authorization already in progress, skipping duplicate attempt",
@@ -1842,6 +1843,28 @@ func (c *Client) handleOAuthAuthorization(ctx context.Context, authErr error, oa
18421843
return fmt.Errorf("failed to get authorization URL: %w", authURLErr)
18431844
}
18441845

1846+
// Add extra OAuth parameters if configured (workaround until mcp-go supports this natively)
1847+
if len(extraParams) > 0 {
1848+
u, err := url.Parse(authURL)
1849+
if err != nil {
1850+
c.logger.Error("Failed to parse OAuth authorization URL for extra params injection",
1851+
zap.String("server", c.config.Name),
1852+
zap.Error(err))
1853+
return fmt.Errorf("failed to parse authorization URL: %w", err)
1854+
}
1855+
1856+
q := u.Query()
1857+
for k, v := range extraParams {
1858+
q.Set(k, v)
1859+
}
1860+
u.RawQuery = q.Encode()
1861+
authURL = u.String()
1862+
1863+
c.logger.Info("✨ Added extra OAuth parameters to authorization URL",
1864+
zap.String("server", c.config.Name),
1865+
zap.Any("extra_params", extraParams))
1866+
}
1867+
18451868
// Always log the computed authorization URL so users can copy/paste if auto-launch fails.
18461869
c.logger.Info("OAuth authorization URL ready",
18471870
zap.String("server", c.config.Name),
@@ -2074,7 +2097,7 @@ func (c *Client) ForceOAuthFlow(ctx context.Context) error {
20742097
// forceHTTPOAuthFlow forces OAuth flow for HTTP transport
20752098
func (c *Client) forceHTTPOAuthFlow(ctx context.Context) error {
20762099
// Create OAuth config
2077-
oauthConfig, _ := oauth.CreateOAuthConfig(c.config, c.storage)
2100+
oauthConfig, extraParams := oauth.CreateOAuthConfig(c.config, c.storage)
20782101
if oauthConfig == nil {
20792102
return fmt.Errorf("failed to create OAuth config - server may not support OAuth")
20802103
}
@@ -2111,7 +2134,7 @@ func (c *Client) forceHTTPOAuthFlow(ctx context.Context) error {
21112134
c.logger.Info("✅ OAuth authorization requirement triggered - starting manual OAuth flow")
21122135

21132136
// Handle OAuth authorization manually using the example pattern
2114-
if oauthErr := c.handleOAuthAuthorization(ctx, err, oauthConfig); oauthErr != nil {
2137+
if oauthErr := c.handleOAuthAuthorization(ctx, err, oauthConfig, extraParams); oauthErr != nil {
21152138
return fmt.Errorf("OAuth authorization failed: %w", oauthErr)
21162139
}
21172140

@@ -2133,7 +2156,7 @@ func (c *Client) forceHTTPOAuthFlow(ctx context.Context) error {
21332156
// forceSSEOAuthFlow forces OAuth flow for SSE transport
21342157
func (c *Client) forceSSEOAuthFlow(ctx context.Context) error {
21352158
// Create OAuth config
2136-
oauthConfig, _ := oauth.CreateOAuthConfig(c.config, c.storage)
2159+
oauthConfig, extraParams := oauth.CreateOAuthConfig(c.config, c.storage)
21372160
if oauthConfig == nil {
21382161
return fmt.Errorf("failed to create OAuth config - server may not support OAuth")
21392162
}
@@ -2163,7 +2186,7 @@ func (c *Client) forceSSEOAuthFlow(ctx context.Context) error {
21632186
c.logger.Info("✅ OAuth authorization required from SSE Start() - triggering manual OAuth flow")
21642187

21652188
// Handle OAuth authorization manually
2166-
if oauthErr := c.handleOAuthAuthorization(ctx, err, oauthConfig); oauthErr != nil {
2189+
if oauthErr := c.handleOAuthAuthorization(ctx, err, oauthConfig, extraParams); oauthErr != nil {
21672190
return fmt.Errorf("OAuth authorization failed: %w", oauthErr)
21682191
}
21692192

@@ -2187,7 +2210,7 @@ func (c *Client) forceSSEOAuthFlow(ctx context.Context) error {
21872210
c.logger.Info("✅ OAuth authorization requirement from initialize - starting manual OAuth flow")
21882211

21892212
// Handle OAuth authorization manually using the example pattern
2190-
if oauthErr := c.handleOAuthAuthorization(ctx, err, oauthConfig); oauthErr != nil {
2213+
if oauthErr := c.handleOAuthAuthorization(ctx, err, oauthConfig, extraParams); oauthErr != nil {
21912214
return fmt.Errorf("OAuth authorization failed: %w", oauthErr)
21922215
}
21932216

0 commit comments

Comments
 (0)