Skip to content

Commit cfe9a5c

Browse files
docs(spec): add implementation plan for OAuth token refresh (smart-mcp-proxy#23)
Related smart-mcp-proxy#23 Adds planning artifacts for OAuth token refresh reliability: - plan.md: Technical context, constitution check, project structure - research.md: Key decisions on backoff strategy, mcp-go integration - data-model.md: Entity definitions, state transitions, metrics schema - contracts/: Health status changes, metrics additions - quickstart.md: Implementation guide with testing approach Key findings: - mcp-go has no direct RefreshToken() API; refresh via TokenStore - Misleading logging at connection.go:1239,1696 needs fix - Exponential backoff: 10s base, 5min cap, unlimited retries
1 parent 07da810 commit cfe9a5c

6 files changed

Lines changed: 907 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,8 @@ See `docs/prerelease-builds.md` for download instructions.
391391
- Go 1.24 (toolchain go1.24.10) + Cobra (CLI), Chi router (HTTP), Zap (logging), mark3labs/mcp-go (MCP protocol) (020-oauth-login-feedback)
392392
- Go 1.24 (toolchain go1.24.10) + Cobra (CLI), Chi router (HTTP), Zap (logging), google/uuid (ID generation) (021-request-id-logging)
393393
- BBolt database (`~/.mcpproxy/config.db`) - activity log extended with request_id field (021-request-id-logging)
394+
- Go 1.24 (toolchain go1.24.10) + mcp-go v0.43.1 (OAuth client), BBolt (storage), Prometheus (metrics), Zap (logging) (023-oauth-state-persistence)
395+
- BBolt database (`~/.mcpproxy/config.db`) - `oauth_tokens` bucket with `OAuthTokenRecord` model (023-oauth-state-persistence)
394396

395397
## Recent Changes
396398
- 001-update-version-display: Added Go 1.24 (toolchain go1.24.10)
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Contract Changes: Health Status Extension
2+
3+
**Feature**: 023-oauth-state-persistence
4+
**Date**: 2026-01-12
5+
6+
## Overview
7+
8+
This feature does NOT add new REST API endpoints. It extends the existing health status response format to include refresh retry state information.
9+
10+
## Existing Endpoint
11+
12+
**Endpoint**: `GET /api/v1/servers`
13+
**Response**: Array of server objects with `health` field
14+
15+
## Health Field Extension
16+
17+
### Current Schema (unchanged fields)
18+
19+
```json
20+
{
21+
"health": {
22+
"level": "healthy|degraded|unhealthy",
23+
"admin_state": "enabled|disabled|quarantined",
24+
"summary": "string",
25+
"detail": "string",
26+
"action": "login|restart|enable|approve|view_logs|"
27+
}
28+
}
29+
```
30+
31+
### Extended `detail` Values
32+
33+
The `detail` field will include refresh-specific information when applicable:
34+
35+
| Condition | level | summary | detail | action |
36+
|-----------|-------|---------|--------|--------|
37+
| Refresh scheduled | healthy | Connected | Token refresh scheduled for {time} | - |
38+
| Refresh retrying | degraded | Token refresh pending | Refresh retry {n} scheduled for {time}: {error} | view_logs |
39+
| Refresh failed (network) | degraded | Refresh failed - network error | Network error: {details}. Retry in {time} | retry |
40+
| Refresh failed (permanent) | unhealthy | Refresh token expired | Re-authentication required: {error} | login |
41+
42+
### Example Responses
43+
44+
**Healthy with scheduled refresh**:
45+
```json
46+
{
47+
"health": {
48+
"level": "healthy",
49+
"admin_state": "enabled",
50+
"summary": "Connected (5 tools)",
51+
"detail": "Token refresh scheduled for 2026-01-12T15:30:00Z",
52+
"action": ""
53+
}
54+
}
55+
```
56+
57+
**Degraded during retry**:
58+
```json
59+
{
60+
"health": {
61+
"level": "degraded",
62+
"admin_state": "enabled",
63+
"summary": "Token refresh pending",
64+
"detail": "Refresh retry 3 scheduled for 2026-01-12T14:05:40Z: connection timeout",
65+
"action": "view_logs"
66+
}
67+
}
68+
```
69+
70+
**Unhealthy (permanent failure)**:
71+
```json
72+
{
73+
"health": {
74+
"level": "unhealthy",
75+
"admin_state": "enabled",
76+
"summary": "Refresh token expired",
77+
"detail": "Re-authentication required: invalid_grant",
78+
"action": "login"
79+
}
80+
}
81+
```
82+
83+
## Metrics Endpoint
84+
85+
**Endpoint**: `GET /metrics` (Prometheus format)
86+
87+
### New Metrics
88+
89+
```prometheus
90+
# HELP mcpproxy_oauth_refresh_total Total number of OAuth token refresh attempts
91+
# TYPE mcpproxy_oauth_refresh_total counter
92+
mcpproxy_oauth_refresh_total{server="github-mcp",result="success"} 42
93+
mcpproxy_oauth_refresh_total{server="github-mcp",result="failed_network"} 3
94+
mcpproxy_oauth_refresh_total{server="atlassian-mcp",result="failed_invalid_grant"} 1
95+
96+
# HELP mcpproxy_oauth_refresh_duration_seconds OAuth token refresh duration in seconds
97+
# TYPE mcpproxy_oauth_refresh_duration_seconds histogram
98+
mcpproxy_oauth_refresh_duration_seconds_bucket{server="github-mcp",result="success",le="0.5"} 38
99+
mcpproxy_oauth_refresh_duration_seconds_bucket{server="github-mcp",result="success",le="1"} 41
100+
mcpproxy_oauth_refresh_duration_seconds_bucket{server="github-mcp",result="success",le="+Inf"} 42
101+
mcpproxy_oauth_refresh_duration_seconds_sum{server="github-mcp",result="success"} 18.5
102+
mcpproxy_oauth_refresh_duration_seconds_count{server="github-mcp",result="success"} 42
103+
```
104+
105+
### Label Values
106+
107+
**`result` label**:
108+
- `success`: Token refresh completed successfully
109+
- `failed_network`: Network error (timeout, DNS, connection refused)
110+
- `failed_invalid_grant`: Refresh token expired or revoked
111+
- `failed_other`: Other OAuth errors (server_error, etc.)
112+
113+
## SSE Events
114+
115+
**Endpoint**: `GET /events`
116+
117+
No new event types. Existing `servers.changed` event will be emitted when health status changes due to refresh state transitions.
118+
119+
## Backward Compatibility
120+
121+
All changes are additive:
122+
- `detail` field already exists, just gains more specific content
123+
- New metrics don't affect existing metrics
124+
- No schema breaking changes
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
# Data Model: OAuth Token Refresh Reliability
2+
3+
**Feature**: 023-oauth-state-persistence
4+
**Date**: 2026-01-12
5+
6+
## Entity Overview
7+
8+
This feature primarily extends existing entities rather than creating new ones. The key data structures involved are:
9+
10+
1. **OAuthTokenRecord** (existing) - Token persistence
11+
2. **RefreshSchedule** (existing) - Proactive refresh scheduling
12+
3. **HealthCalculatorInput** (existing) - Health status calculation
13+
4. **OAuth Metrics** (new) - Prometheus metrics for observability
14+
15+
## Entity Definitions
16+
17+
### 1. OAuthTokenRecord (Existing - No Changes)
18+
19+
**Location**: `internal/storage/models.go`
20+
21+
```go
22+
type OAuthTokenRecord struct {
23+
ServerName string `json:"server_name"` // Storage key (serverName_hash)
24+
DisplayName string `json:"display_name"` // Actual server name
25+
AccessToken string `json:"access_token"`
26+
RefreshToken string `json:"refresh_token"` // Required for proactive refresh
27+
TokenType string `json:"token_type"`
28+
ExpiresAt time.Time `json:"expires_at"`
29+
Scopes []string `json:"scopes"`
30+
ClientID string `json:"client_id"` // For DCR
31+
ClientSecret string `json:"client_secret"` // For DCR
32+
Created time.Time `json:"created"`
33+
Updated time.Time `json:"updated"`
34+
}
35+
```
36+
37+
**Relationships**:
38+
- Referenced by: RefreshSchedule (via DisplayName)
39+
- Stored in: BBolt `oauth_tokens` bucket
40+
41+
**Validation Rules**:
42+
- `ServerName`: Required, unique per OAuth server
43+
- `AccessToken`: Required when authenticated
44+
- `RefreshToken`: Optional, enables proactive refresh
45+
- `ExpiresAt`: Required for scheduling refresh
46+
47+
### 2. RefreshSchedule (Existing - Extended)
48+
49+
**Location**: `internal/oauth/refresh_manager.go`
50+
51+
```go
52+
type RefreshSchedule struct {
53+
ServerName string // Server identifier
54+
ExpiresAt time.Time // Token expiration time
55+
ScheduledRefresh time.Time // When refresh will be attempted
56+
RetryCount int // Number of failed attempts
57+
LastError error // Most recent failure reason
58+
Timer *time.Timer // Scheduled timer (internal)
59+
60+
// NEW FIELDS for this feature:
61+
RetryBackoff time.Duration // Current backoff duration
62+
MaxBackoff time.Duration // Maximum backoff (5 minutes)
63+
LastAttempt time.Time // Time of last refresh attempt
64+
RefreshState RefreshState // Current state for health reporting
65+
}
66+
67+
// NEW: Refresh state for health status integration
68+
type RefreshState int
69+
70+
const (
71+
RefreshStateIdle RefreshState = iota // No refresh needed
72+
RefreshStateScheduled // Proactive refresh scheduled
73+
RefreshStateRetrying // Failed, retrying with backoff
74+
RefreshStateFailed // Permanently failed (needs re-auth)
75+
)
76+
```
77+
78+
**Lifecycle Invariant**: Schedule exists iff tokens exist for server (created post-auth or when loaded at startup, destroyed when tokens removed).
79+
80+
**State Transitions**:
81+
```
82+
┌─────────────────────────────────────────────────────────────┐
83+
│ │
84+
│ [Token Saved] ──► Scheduled ──► [80% lifetime] ──► Idle │
85+
│ │ │ │
86+
│ │ [Refresh Failed] │ │
87+
│ ▼ │ │
88+
│ Retrying ◄─────────────────────────┘ │
89+
│ │ │
90+
│ │ [invalid_grant] │
91+
│ ▼ │
92+
│ Failed ──► [Re-auth] ──► Scheduled │
93+
│ │
94+
└─────────────────────────────────────────────────────────────┘
95+
```
96+
97+
### 3. HealthCalculatorInput (Existing - Extended)
98+
99+
**Location**: `internal/health/calculator.go`
100+
101+
```go
102+
type HealthCalculatorInput struct {
103+
// Existing fields...
104+
OAuthRequired bool
105+
OAuthStatus string // "authenticated", "expired", "error", "none"
106+
TokenExpiresAt *time.Time
107+
HasRefreshToken bool
108+
UserLoggedOut bool
109+
110+
// NEW FIELDS for this feature:
111+
RefreshState RefreshState // From RefreshSchedule
112+
RefreshRetryCount int // Number of retry attempts
113+
RefreshLastError string // Human-readable error message
114+
RefreshNextAttempt *time.Time // When next retry will occur
115+
}
116+
```
117+
118+
**Health Output Mapping**:
119+
120+
| RefreshState | Health Level | Summary | Action |
121+
|--------------|--------------|---------|--------|
122+
| Idle | healthy | "Connected" | - |
123+
| Scheduled | healthy | "Token refresh scheduled" | - |
124+
| Retrying | degraded | "Token refresh retry pending" | view_logs |
125+
| Failed | unhealthy | "Refresh token expired" | login |
126+
127+
### 4. OAuth Metrics (New)
128+
129+
**Location**: `internal/observability/metrics.go`
130+
131+
```go
132+
// Added to MetricsManager struct
133+
type MetricsManager struct {
134+
// Existing fields...
135+
136+
// NEW: OAuth refresh metrics
137+
oauthRefreshTotal *prometheus.CounterVec
138+
oauthRefreshDuration *prometheus.HistogramVec
139+
}
140+
```
141+
142+
**Metric Definitions**:
143+
144+
```go
145+
// Counter: Total refresh attempts
146+
oauthRefreshTotal = prometheus.NewCounterVec(
147+
prometheus.CounterOpts{
148+
Name: "mcpproxy_oauth_refresh_total",
149+
Help: "Total number of OAuth token refresh attempts",
150+
},
151+
[]string{"server", "result"},
152+
)
153+
154+
// Histogram: Refresh duration
155+
oauthRefreshDuration = prometheus.NewHistogramVec(
156+
prometheus.HistogramOpts{
157+
Name: "mcpproxy_oauth_refresh_duration_seconds",
158+
Help: "OAuth token refresh duration in seconds",
159+
Buckets: []float64{0.1, 0.5, 1, 2, 5, 10, 30},
160+
},
161+
[]string{"server", "result"},
162+
)
163+
```
164+
165+
**Labels**:
166+
- `server`: Server name (e.g., "github-mcp")
167+
- `result`: One of:
168+
- `success`: Refresh completed successfully
169+
- `failed_network`: Network error (retryable)
170+
- `failed_invalid_grant`: Refresh token expired (permanent)
171+
- `failed_other`: Other failures
172+
173+
## Configuration Constants
174+
175+
**Location**: `internal/oauth/refresh_manager.go`
176+
177+
```go
178+
const (
179+
// Existing
180+
DefaultRefreshThreshold = 0.8 // Refresh at 80% of token lifetime
181+
MinRefreshInterval = 5 * time.Second
182+
183+
// MODIFIED for this feature
184+
RetryBackoffBase = 10 * time.Second // Was: 2s, Now: 10s (FR-008)
185+
MaxRetryBackoff = 5 * time.Minute // NEW: Cap at 5 minutes (FR-009)
186+
MaxRetries = 0 // NEW: Unlimited (until expiration)
187+
)
188+
```
189+
190+
## Data Flow
191+
192+
### Startup Recovery Flow
193+
194+
```
195+
1. RefreshManager.Start()
196+
197+
├─► storage.ListOAuthTokens()
198+
199+
└─► For each token:
200+
201+
├─► If not expired:
202+
│ └─► scheduleRefreshLocked(80% lifetime)
203+
204+
└─► If access token expired, refresh token exists:
205+
└─► executeRefresh() immediately
206+
207+
├─► Success: scheduleRefreshLocked(new 80%)
208+
209+
└─► Failure: rescheduleWithBackoff()
210+
```
211+
212+
### Proactive Refresh Flow
213+
214+
```
215+
1. Timer fires at 80% lifetime
216+
217+
└─► executeRefresh()
218+
219+
├─► runtime.RefreshOAuthToken(serverName)
220+
│ │
221+
│ └─► mcp-go client.Start() with TokenStore
222+
223+
├─► Success:
224+
│ ├─► Emit metrics (success)
225+
│ ├─► Update health status (Idle)
226+
│ └─► Schedule next refresh
227+
228+
└─► Failure:
229+
├─► Emit metrics (failed_*)
230+
├─► Update health status (Retrying/Failed)
231+
└─► Reschedule with backoff (if retryable)
232+
```
233+
234+
### Health Status Integration Flow
235+
236+
```
237+
1. HealthCalculator.Calculate()
238+
239+
├─► Get RefreshSchedule for server
240+
241+
└─► Map RefreshState to health:
242+
243+
├─► Retrying: degraded + "Refresh retry pending"
244+
245+
├─► Failed: unhealthy + "Refresh token expired"
246+
247+
└─► Other: existing logic
248+
```
249+
250+
## Storage Bucket
251+
252+
**Bucket**: `oauth_tokens` (existing)
253+
**Database**: `~/.mcpproxy/config.db` (BBolt)
254+
255+
No schema changes required. The `OAuthTokenRecord` structure is sufficient for all refresh operations.

0 commit comments

Comments
 (0)