Skip to content

Commit 10e6422

Browse files
committed
feat(server): Add WithOAuth for composable OAuth middleware integration
Signed-off-by: Tommy Nguyen <tuannvm@hotmail.com>
1 parent 33136ad commit 10e6422

File tree

6 files changed

+575
-37
lines changed

6 files changed

+575
-37
lines changed

docs/implementation.md

Lines changed: 113 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -412,22 +412,124 @@ type TokenValidator interface {
412412

413413
## Phase 3: Simple API Implementation
414414

415-
**Status:** ⏳ Not Started
415+
**Status:** ✅ Completed
416416

417-
### Tasks
417+
**Started:** 2025-10-19
418+
**Completed:** 2025-10-19
418419

419-
- [ ] Implement oauth.EnableOAuth() in ROOT package
420-
- [ ] Call NewServer() with validation
421-
- [ ] Apply middleware to mcpServer
422-
- [ ] Register handlers on mux
423-
- [ ] Set up HTTPContextFunc
424-
- [ ] Auto-detect mode with validation
425-
- [ ] Test both native and proxy modes
426-
- [ ] Test error handling
420+
### Tasks Completed
421+
422+
- [x] Implement `oauth.WithOAuth()` in ROOT package
423+
- [x] Call NewServer() with validation
424+
- [x] Apply middleware via server option
425+
- [x] Register handlers on mux
426+
- [x] Return mcpserver.ServerOption
427+
- [x] HTTPContextFunc already exists (CreateHTTPContextFunc)
428+
- [x] Test both native and proxy modes
429+
- [x] Test error handling
430+
- [x] Create simple example
431+
- [x] Update documentation
427432

428433
### Implementation Notes
429434

430-
*TBD*
435+
**API Design Decision:**
436+
437+
Following Gemini 2.5 Pro's recommendation, implemented **composable API** instead of monolithic `EnableOAuth()`.
438+
439+
**Why:**
440+
- mcp-go v0.41.1 requires middleware at server **construction** (not after)
441+
- `server.NewMCPServer()` accepts options, not middleware methods
442+
- Composable API fits mcp-go patterns better
443+
444+
**Implemented API:**
445+
446+
```go
447+
// oauth.go
448+
func WithOAuth(mux *http.ServeMux, cfg *Config) (mcpserver.ServerOption, error)
449+
```
450+
451+
**Usage Pattern (2 lines):**
452+
```go
453+
mux := http.NewServeMux()
454+
455+
// Line 1: Get OAuth option
456+
oauthOption, _ := oauth.WithOAuth(mux, &oauth.Config{
457+
Provider: "hmac",
458+
Audience: "api://test",
459+
JWTSecret: []byte("secret"),
460+
})
461+
462+
// Line 2: Create server with OAuth
463+
mcpServer := server.NewMCPServer("Server", "1.0.0", oauthOption)
464+
465+
// Done! All tools are OAuth-protected
466+
```
467+
468+
**What WithOAuth() Does:**
469+
1. Creates OAuth Server internally (`NewServer(cfg)`)
470+
2. Validates config (via `cfg.Validate()`)
471+
3. Registers HTTP handlers on mux
472+
4. Returns `server.WithToolHandlerMiddleware(middleware)`
473+
474+
**Key Features:**
475+
- ✅ Server-wide middleware (all tools protected)
476+
- ✅ Composable with other `server.ServerOption`
477+
- ✅ Auto-detects mode (native vs proxy)
478+
- ✅ Validates config early (fail fast)
479+
- ✅ Compatible with mcp-go v0.41.1
480+
481+
**Helper Function:**
482+
```go
483+
func CreateHTTPContextFunc() func(context.Context, *http.Request) context.Context
484+
```
485+
- Extracts Bearer token from HTTP headers
486+
- Adds to context via `WithOAuthToken()`
487+
- Use with `mcpserver.WithHTTPContextFunc()`
488+
489+
**Files Created:**
490+
- `oauth.go` - Added `WithOAuth()` function
491+
- `examples/simple/main.go` - NEW: Simple API example
492+
- `phase3_test.go` - NEW: WithOAuth() tests
493+
- `examples/README.md` - Updated with comparison
494+
495+
**Files Modified:**
496+
- `examples/embedded/main.go` - Moved from examples/embedded.go
497+
- `examples/README.md` - Added Simple vs Embedded comparison
498+
499+
**Test Coverage:**
500+
- `TestWithOAuth` - 4 subtests
501+
- BasicUsage_NativeMode
502+
- ProxyMode
503+
- InvalidConfig
504+
- EndToEndWithHTTPContextFunc
505+
- `TestPhase3API` - 2 subtests
506+
- TwoLineSetup
507+
- ComposableWithOtherOptions
508+
509+
**Build & Test Status:**
510+
- ✅ `go build ./...` - Success
511+
- ✅ `make test` - All tests passing
512+
- ✅ `examples/simple/main.go` - Compiles
513+
- ✅ `examples/embedded/main.go` - Compiles
514+
515+
**Comparison to Original Plan:**
516+
517+
Original plan called for `EnableOAuth(mcpServer, mux, cfg)` but this was impossible because:
518+
- mcp-go v0.41.1 requires middleware at server creation
519+
- Can't modify server after construction
520+
521+
**New API is better:**
522+
- More composable (functional options pattern)
523+
- Idiomatic for mcp-go users
524+
- Same simplicity (2 lines vs 1 line)
525+
- More flexible (can combine with other options)
526+
527+
**Key Achievements:**
528+
- ✅ 2-line OAuth setup (goal achieved)
529+
- ✅ Server-wide protection (all tools secured)
530+
- ✅ mcp-go v0.41.1 compatible
531+
- ✅ Composable design
532+
- ✅ Both examples working
431533

432534
---
433535

examples/README.md

Lines changed: 80 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,99 @@
11
# OAuth MCP Proxy Examples
22

3-
## Embedded Mode Example
3+
## 1. Simple API (Phase 3) - `simple/`
44

5-
`embedded.go` - Complete MCP server with OAuth authentication
5+
**Simplest OAuth setup using `WithOAuth()` API:**
66

7-
**Features:**
8-
- Full MCP server implementation (using mark3labs/mcp-go v0.41.1)
9-
- **Server-wide OAuth middleware** (WithToolHandlerMiddleware)
10-
- OAuth-protected MCP tool ("hello")
11-
- Context propagation (HTTP → MCP → OAuth → Tool)
12-
- HMAC token validation with caching
13-
- OAuth metadata endpoints
14-
- Auto-generates test token for easy testing
7+
```go
8+
mux := http.NewServeMux()
159

16-
**Run:**
17-
```bash
18-
go run examples/embedded.go
10+
// Line 1: Get OAuth option (registers HTTP handlers)
11+
oauthOption, _ := oauth.WithOAuth(mux, &oauth.Config{
12+
Provider: "hmac",
13+
Audience: "api://my-server",
14+
JWTSecret: []byte("secret"),
15+
})
16+
17+
// Line 2: Create MCP server with OAuth
18+
mcpServer := server.NewMCPServer("Server", "1.0.0", oauthOption)
19+
20+
// Add tools - automatically OAuth-protected!
21+
mcpServer.AddTool(tool, handler)
22+
23+
// Setup MCP endpoint with token extraction
24+
streamable := server.NewStreamableHTTPServer(mcpServer,
25+
server.WithHTTPContextFunc(oauth.CreateHTTPContextFunc()),
26+
)
1927
```
2028

29+
**Features:**
30+
- **2-line OAuth setup** (Phase 3 goal achieved!)
31+
- Server-wide middleware (all tools protected automatically)
32+
- Composable with other server options
33+
- Uses mcp-go v0.41.1 `WithToolHandlerMiddleware` pattern
34+
35+
**Run:** `go run examples/simple/main.go`
36+
2137
**Test:**
2238
```bash
23-
# Server will print the test command on startup
24-
25-
# Test MCP tool call with OAuth:
2639
curl -X POST http://localhost:8080/mcp \
2740
-H 'Authorization: Bearer <token-from-output>' \
2841
-H 'Content-Type: application/json' \
2942
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"hello","arguments":{}}}'
3043
```
3144

32-
**What It Demonstrates (Phase 2 Features):**
45+
---
46+
47+
## 2. Embedded Mode (Phase 2) - `embedded/`
48+
49+
**Detailed implementation showing internal architecture:**
50+
51+
`embedded/main.go` - Complete MCP server with OAuth authentication
52+
53+
**Features:**
54+
- Full MCP server implementation (using mark3labs/mcp-go v0.41.1)
55+
- Shows internal `NewServer()` API
56+
- Demonstrates `Server.Middleware()` usage
57+
- Shows manual middleware application
58+
- provider/ package architecture visible
59+
- Context propagation explained
60+
- Instance-scoped state (no globals)
61+
- HMAC token validation with caching
62+
- OAuth metadata endpoints
63+
- Auto-generates test token
64+
65+
**Run:** `go run examples/embedded/main.go`
66+
67+
**What It Demonstrates (Phase 2 internals):**
3368
1. Creating OAuth server (`oauth.NewServer()`)
34-
2. **Server-wide middleware** (`WithToolHandlerMiddleware`) - mcp-go v0.41.1
35-
3. provider/ package isolation (HMACValidator from provider/)
36-
4. Context propagation (`ValidateToken(ctx, token)`)
37-
5. Instance-scoped state (Server.cache, no globals)
38-
6. OAuth context extraction from HTTP headers
39-
7. User context available in MCP tools
40-
8. Complete end-to-end OAuth flow
41-
42-
**Endpoints:**
69+
2. Getting middleware (`server.Middleware()`)
70+
3. Server-wide middleware with `WithToolHandlerMiddleware`
71+
4. provider/ package isolation (HMACValidator from provider/)
72+
5. Context propagation (`ValidateToken(ctx, token)`)
73+
6. Instance-scoped cache (Server.cache, no globals)
74+
7. OAuth context extraction from HTTP headers
75+
8. User context available in MCP tools
76+
77+
---
78+
79+
## Comparison
80+
81+
| Feature | `simple/` (Phase 3) | `embedded/` (Phase 2) |
82+
|---------|---------------------|----------------------|
83+
| **Setup Lines** | 2 lines | ~10 lines |
84+
| **API** | `WithOAuth()` convenience | `NewServer()` + manual setup |
85+
| **Recommended** | ✅ For production | For learning internals |
86+
| **Shows** | Simplest usage | Architecture details |
87+
88+
**Recommendation:** Use `simple/` for real projects, read `embedded/` to understand how it works.
89+
90+
---
91+
92+
## OAuth Endpoints
93+
94+
Both examples expose:
4395
- `POST /mcp` - MCP protocol endpoint (OAuth protected)
4496
- `GET /.well-known/oauth-authorization-server` - OAuth metadata
4597
- `GET /.well-known/oauth-protected-resource` - Resource metadata
98+
- `GET /.well-known/jwks.json` - JWKS keys (HMAC mode)
99+
- `GET /.well-known/openid-configuration` - OIDC discovery
File renamed without changes.

examples/simple/main.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"net/http"
8+
"time"
9+
10+
"github.com/golang-jwt/jwt/v5"
11+
"github.com/mark3labs/mcp-go/mcp"
12+
mcpserver "github.com/mark3labs/mcp-go/server"
13+
oauth "github.com/tuannvm/oauth-mcp-proxy"
14+
)
15+
16+
func main() {
17+
log.Println("=== OAuth MCP Proxy - Simple API (Phase 3) ===")
18+
log.Println()
19+
20+
// Setup HTTP mux
21+
mux := http.NewServeMux()
22+
23+
// Line 1: Get OAuth server option (registers HTTP handlers)
24+
oauthOption, err := oauth.WithOAuth(mux, &oauth.Config{
25+
Provider: "hmac",
26+
Issuer: "https://test.example.com",
27+
Audience: "api://simple-server",
28+
JWTSecret: []byte("test-secret-key-must-be-32-bytes-long!"),
29+
})
30+
if err != nil {
31+
log.Fatalf("WithOAuth failed: %v", err)
32+
}
33+
34+
log.Println("✅ OAuth configured")
35+
log.Println(" - HTTP handlers registered")
36+
log.Println(" - Middleware ready")
37+
38+
// Line 2: Create MCP server with OAuth option
39+
mcpServer := mcpserver.NewMCPServer("Simple OAuth Server", "1.0.0",
40+
oauthOption, // OAuth middleware applied to ALL tools!
41+
)
42+
43+
log.Println("✅ MCP server created with OAuth middleware")
44+
45+
// Add tools (automatically protected by OAuth)
46+
mcpServer.AddTool(
47+
mcp.Tool{
48+
Name: "hello",
49+
Description: "Says hello to authenticated user",
50+
},
51+
func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
52+
user, ok := oauth.GetUserFromContext(ctx)
53+
if !ok {
54+
return nil, fmt.Errorf("authentication required")
55+
}
56+
57+
message := fmt.Sprintf("Hello, %s! (Subject: %s)", user.Username, user.Subject)
58+
return mcp.NewToolResultText(message), nil
59+
},
60+
)
61+
62+
log.Println("✅ Tools added (automatically OAuth-protected)")
63+
64+
// Setup MCP endpoint with OAuth context extraction
65+
streamableServer := mcpserver.NewStreamableHTTPServer(
66+
mcpServer,
67+
mcpserver.WithEndpointPath("/mcp"),
68+
mcpserver.WithHTTPContextFunc(oauth.CreateHTTPContextFunc()),
69+
)
70+
71+
mux.Handle("/mcp", streamableServer)
72+
73+
// Generate test token
74+
testToken := generateTestToken(&oauth.Config{
75+
Issuer: "https://test.example.com",
76+
Audience: "api://simple-server",
77+
JWTSecret: []byte("test-secret-key-must-be-32-bytes-long!"),
78+
})
79+
80+
log.Println()
81+
log.Println("📋 Test Command:")
82+
log.Printf("curl -X POST http://localhost:8080/mcp \\\n")
83+
log.Printf(" -H 'Authorization: Bearer %s' \\\n", testToken[:50]+"...")
84+
log.Printf(" -H 'Content-Type: application/json' \\\n")
85+
log.Printf(" -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"hello\",\"arguments\":{}}}'\n")
86+
log.Println()
87+
88+
log.Println("🚀 Server starting on http://localhost:8080")
89+
if err := http.ListenAndServe(":8080", mux); err != nil {
90+
log.Fatalf("Server failed: %v", err)
91+
}
92+
}
93+
94+
func generateTestToken(cfg *oauth.Config) string {
95+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
96+
"sub": "test-user",
97+
"email": "test@example.com",
98+
"preferred_username": "testuser",
99+
"aud": cfg.Audience,
100+
"iss": cfg.Issuer,
101+
"exp": time.Now().Add(time.Hour).Unix(),
102+
"iat": time.Now().Unix(),
103+
})
104+
105+
tokenString, _ := token.SignedString(cfg.JWTSecret)
106+
return tokenString
107+
}

0 commit comments

Comments
 (0)