Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ jobs:

- name: Build examples
run: |
go build ./examples/simple/main.go
go build ./examples/advanced/main.go
for example in $(find examples -name main.go); do
echo "Building $example..."
go build "$example"
done

- name: Run go vet
run: go vet ./...
Expand Down
90 changes: 81 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

**oauth-mcp-proxy** is an OAuth 2.1 authentication library for Go MCP servers. It provides server-side OAuth integration with minimal code (3-line integration via `WithOAuth()`), supporting multiple providers (HMAC, Okta, Google, Azure AD).

**Version**: v2.0.0 (Supports both `mark3labs/mcp-go` and official `modelcontextprotocol/go-sdk`)

## Build Commands

```bash
Expand Down Expand Up @@ -36,34 +38,78 @@ make vuln

## Architecture

### Package Structure (v2.0.0)

```
oauth-mcp-proxy/
├── [core package - SDK-agnostic]
│ ├── oauth.go - Server type, NewServer, ValidateTokenCached
│ ├── config.go - Configuration validation and provider setup
│ ├── cache.go - Token cache with 5-minute TTL
│ ├── context.go - Context utilities (WithOAuthToken, GetUserFromContext, etc.)
│ ├── handlers.go - OAuth HTTP endpoints (/.well-known/*, /oauth/*)
│ ├── middleware.go - CreateHTTPContextFunc for token extraction
│ ├── logger.go - Logger interface
│ ├── metadata.go - OAuth metadata structures
│ └── provider/ - Token validators (HMAC, OIDC)
├── mark3labs/ - Adapter for mark3labs/mcp-go SDK
│ ├── oauth.go - WithOAuth → ServerOption
│ └── middleware.go - Middleware for mark3labs types
└── mcp/ - Adapter for official modelcontextprotocol/go-sdk
└── oauth.go - WithOAuth → http.Handler
```

### Core Components

1. **oauth.go** - Main entry point, provides `WithOAuth()` function that creates OAuth server and returns MCP server option
**Core Package** (SDK-agnostic):
1. **oauth.go** - `Server` type, `NewServer()`, `ValidateTokenCached()` (used by adapters)
2. **config.go** - Configuration validation and provider setup
3. **middleware.go** - Token validation middleware with 5-minute caching
4. **handlers.go** - OAuth HTTP endpoints (/.well-known/*, /oauth/*)
5. **provider/provider.go** - Token validators (HMACValidator, OIDCValidator)
3. **cache.go** - Token caching logic (`TokenCache`, `CachedToken`)
4. **context.go** - Context utilities (`WithOAuthToken`, `GetOAuthToken`, `WithUser`, `GetUserFromContext`)
5. **handlers.go** - OAuth HTTP endpoints
6. **provider/provider.go** - Token validators (HMACValidator, OIDCValidator)

**Adapters** (SDK-specific):
- **mark3labs/** - Middleware adapter for `mark3labs/mcp-go`
- **mcp/** - HTTP handler wrapper for official SDK

### Key Design Patterns

- **OpenTelemetry Pattern**: Core logic is SDK-agnostic; adapters provide SDK-specific integration
- **Instance-scoped**: Each `Server` instance has its own token cache and validator (no globals)
- **Provider abstraction**: `TokenValidator` interface supports multiple OAuth providers
- **Caching strategy**: Tokens cached for 5 minutes using SHA-256 hash as key
- **Context propagation**: OAuth token extracted from HTTP header → stored in context → validated by middleware → user added to context
- **Context propagation**: OAuth token extracted from HTTP header → stored in context → validated → user added to context

### Integration Flow

**mark3labs SDK:**
```text
1. HTTP request with "Authorization: Bearer <token>" header
2. CreateHTTPContextFunc() extracts token → adds to context via WithOAuthToken()
3. OAuth middleware (Server.Middleware()) validates token:
- Checks cache first (5-minute TTL)
3. mark3labs middleware validates token:
- Calls Server.ValidateTokenCached() (checks cache first)
- If not cached, validates via provider (HMAC or OIDC)
- Caches result
4. Adds authenticated User to context via userContextKey
- Caches result (5-minute TTL)
4. Adds authenticated User to context via WithUser()
5. Tool handler accesses user via GetUserFromContext(ctx)
```

**Official SDK:**
```text
1. HTTP request with "Authorization: Bearer <token>" header
2. mcp adapter's HTTP handler intercepts request
3. Validates token via Server.ValidateTokenCached():
- Checks cache first (5-minute TTL)
- If not cached, validates via provider
- Caches result
4. Adds token and user to context (WithOAuthToken, WithUser)
5. Passes request to official SDK's StreamableHTTPHandler
6. Tool handler accesses user via GetUserFromContext(ctx)
```

### Provider System

- **HMAC**: Validates JWT tokens with shared secret (testing/dev)
Expand Down Expand Up @@ -96,3 +142,29 @@ go test -v -run TestName ./...
3. **Logging**: Config.Logger is optional. If nil, uses default logger (log.Printf with level prefixes)
4. **Modes**: Library supports "native" (token validation only) and "proxy" (OAuth flow proxy) modes
5. **Security**: All redirect URIs validated, state parameters HMAC-signed, tokens never logged (only hash previews)
6. **v2.0.0 Breaking Change**: `WithOAuth()` moved to adapter packages (`mark3labs.WithOAuth()` or `mcp.WithOAuth()`). See `MIGRATION-V2.md`.

## Using the Library

### With mark3labs/mcp-go
```go
import (
oauth "github.com/tuannvm/oauth-mcp-proxy"
"github.com/tuannvm/oauth-mcp-proxy/mark3labs"
)

_, oauthOption, _ := mark3labs.WithOAuth(mux, &oauth.Config{...})
mcpServer := server.NewMCPServer("name", "1.0.0", oauthOption)
```

### With Official SDK
```go
import (
oauth "github.com/tuannvm/oauth-mcp-proxy"
mcpoauth "github.com/tuannvm/oauth-mcp-proxy/mcp"
)

mcpServer := mcp.NewServer(&mcp.Implementation{...}, nil)
_, handler, _ := mcpoauth.WithOAuth(mux, &oauth.Config{...}, mcpServer)
http.ListenAndServe(":8080", handler)
```
101 changes: 89 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,37 @@

OAuth 2.1 authentication library for Go MCP servers.

**Supports both MCP SDKs:**
- ✅ `mark3labs/mcp-go`
- ✅ `modelcontextprotocol/go-sdk` (official)

**One-time setup:** Configure provider + add `WithOAuth()` to your server.
**Result:** All tools automatically protected with token validation and caching.

### mark3labs/mcp-go
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix heading hierarchy: change h3 to h2.

Line 12 is an h3 heading (###) but should be h2 (##) as a direct child of the "Quick Start" h2 section. Heading levels should increment by one level at a time per markdown linting rules.

-### Using mark3labs/mcp-go
+## Using mark3labs/mcp-go

And similarly for the "Using Official SDK" section at line 138.

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

12-12: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3

(MD001, heading-increment)

🤖 Prompt for AI Agents
In README.md around lines 12 and 138, the headings use h3 (###) but must be h2
(##) to maintain proper incremental hierarchy under the "Quick Start" h2 section
and the "Using Official SDK" section; change the two headings from `###` to `##`
so each is a direct child one level below their parent h2, and verify adjacent
headings follow the same incremental rule.

```go
// Enable OAuth authentication
_, oauthOption, _ := oauth.WithOAuth(mux, &oauth.Config{
import "github.com/tuannvm/oauth-mcp-proxy/mark3labs"

_, oauthOption, _ := mark3labs.WithOAuth(mux, &oauth.Config{
Provider: "okta",
Issuer: "https://your-company.okta.com",
Audience: "api://your-mcp-server",
})

// All tools now require authentication
mcpServer := server.NewMCPServer("Server", "1.0.0", oauthOption)
```

### Official SDK
```go
import mcpoauth "github.com/tuannvm/oauth-mcp-proxy/mcp"

mcpServer := mcp.NewServer(&mcp.Implementation{...}, nil)
_, handler, _ := mcpoauth.WithOAuth(mux, cfg, mcpServer)
http.ListenAndServe(":8080", handler)
```

> **📢 Migrating from v1.x?** See [MIGRATION-V2.md](./MIGRATION-V2.md) (2 line change, ~5 min)

[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/tuannvm/oauth-mcp-proxy/test.yml?branch=main&label=Tests&logo=github)](https://github.com/tuannvm/oauth-mcp-proxy/actions/workflows/test.yml)
[![Go Version](https://img.shields.io/github/go-mod/go-version/tuannvm/oauth-mcp-proxy?logo=go)](https://github.com/tuannvm/oauth-mcp-proxy/blob/main/go.mod)
[![Go Report Card](https://goreportcard.com/badge/github.com/tuannvm/oauth-mcp-proxy)](https://goreportcard.com/report/github.com/tuannvm/oauth-mcp-proxy)
Expand All @@ -28,6 +44,7 @@ mcpServer := server.NewMCPServer("Server", "1.0.0", oauthOption)

## Why Use This Library?

- **Dual SDK support** - Works with both mark3labs and official SDKs
- **Simple integration** - One `WithOAuth()` call protects all tools
- **Zero per-tool config** - All tools automatically protected
- **Fast token caching** - 5-min cache, <5ms validation
Expand Down Expand Up @@ -64,21 +81,26 @@ sequenceDiagram

## Quick Start

### 1. Install
### Using mark3labs/mcp-go

#### 1. Install

```bash
go get github.com/tuannvm/oauth-mcp-proxy
```

### 2. Add to Your Server
#### 2. Add to Your Server

```go
import oauth "github.com/tuannvm/oauth-mcp-proxy"
import (
oauth "github.com/tuannvm/oauth-mcp-proxy"
"github.com/tuannvm/oauth-mcp-proxy/mark3labs"
)

mux := http.NewServeMux()

// Enable OAuth (one time setup)
_, oauthOption, _ := oauth.WithOAuth(mux, &oauth.Config{
_, oauthOption, _ := mark3labs.WithOAuth(mux, &oauth.Config{
Provider: "okta", // or "hmac", "google", "azure"
Issuer: "https://your-company.okta.com",
Audience: "api://your-mcp-server",
Expand All @@ -99,7 +121,7 @@ streamable := mcpserver.NewStreamableHTTPServer(
mux.Handle("/mcp", streamable)
```

### 3. Access Authenticated User
#### 3. Access Authenticated User

```go
func myHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
Expand All @@ -111,16 +133,71 @@ func myHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResul
}
```

---

### Using Official SDK

#### 1. Install

```bash
go get github.com/modelcontextprotocol/go-sdk
go get github.com/tuannvm/oauth-mcp-proxy
```

#### 2. Add to Your Server

```go
import (
"github.com/modelcontextprotocol/go-sdk/mcp"
oauth "github.com/tuannvm/oauth-mcp-proxy"
mcpoauth "github.com/tuannvm/oauth-mcp-proxy/mcp"
)

mux := http.NewServeMux()

// Create MCP server
mcpServer := mcp.NewServer(&mcp.Implementation{
Name: "my-server",
Version: "1.0.0",
}, nil)

// Add tools
mcp.AddTool(mcpServer, &mcp.Tool{
Name: "greet",
Description: "Greet user",
}, func(ctx context.Context, req *mcp.CallToolRequest, params *struct{}) (*mcp.CallToolResult, any, error) {
user, _ := oauth.GetUserFromContext(ctx)
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Hello, " + user.Username},
},
}, nil, nil
})

// Add OAuth protection
_, handler, _ := mcpoauth.WithOAuth(mux, &oauth.Config{
Provider: "okta",
Issuer: "https://your-company.okta.com",
Audience: "api://your-mcp-server",
}, mcpServer)

http.ListenAndServe(":8080", handler)
```

Your MCP server now requires OAuth authentication.

---

## Examples

| Example | Description |
|---------|-------------|
| **[Simple](examples/simple/)** | Minimal setup - copy/paste ready |
| **[Advanced](examples/advanced/)** | All features - ConfigBuilder, WrapHandler, LogStartup |
See [examples/README.md](examples/README.md) for detailed setup guide including Okta configuration.

| SDK | Example | Description |
|-----|---------|-------------|
| **mark3labs** | [Simple](examples/mark3labs/simple/) | Minimal setup - copy/paste ready |
| **mark3labs** | [Advanced](examples/mark3labs/advanced/) | ConfigBuilder, multiple tools, logging |
| **Official** | [Simple](examples/official/simple/) | Minimal setup - copy/paste ready |
| **Official** | [Advanced](examples/official/advanced/) | ConfigBuilder, multiple tools, logging |

---

Expand Down
64 changes: 64 additions & 0 deletions cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package oauth

import (
"sync"
"time"

"github.com/tuannvm/oauth-mcp-proxy/provider"
)

// Re-export User from provider for backwards compatibility
type User = provider.User

// TokenCache stores validated tokens to avoid re-validation
type TokenCache struct {
mu sync.RWMutex
cache map[string]*CachedToken
}

// CachedToken represents a cached token validation result
type CachedToken struct {
User *User
ExpiresAt time.Time
}

// getCachedToken retrieves a cached token validation result
func (tc *TokenCache) getCachedToken(tokenHash string) (*CachedToken, bool) {
tc.mu.RLock()

cached, exists := tc.cache[tokenHash]
if !exists {
tc.mu.RUnlock()
return nil, false
}

if time.Now().After(cached.ExpiresAt) {
tc.mu.RUnlock()
go tc.deleteExpiredToken(tokenHash)
return nil, false
}

tc.mu.RUnlock()
return cached, true
}

// deleteExpiredToken safely deletes an expired token from the cache
func (tc *TokenCache) deleteExpiredToken(tokenHash string) {
tc.mu.Lock()
defer tc.mu.Unlock()

if cached, exists := tc.cache[tokenHash]; exists && time.Now().After(cached.ExpiresAt) {
delete(tc.cache, tokenHash)
}
}

// setCachedToken stores a token validation result
func (tc *TokenCache) setCachedToken(tokenHash string, user *User, expiresAt time.Time) {
tc.mu.Lock()
defer tc.mu.Unlock()

tc.cache[tokenHash] = &CachedToken{
User: user,
ExpiresAt: expiresAt,
}
}
Comment on lines +55 to +64
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make TokenCache zero‑value safe (prevent map panic).

tc.cache may be nil, leading to a panic on first write.

Apply this diff:

 func (tc *TokenCache) setCachedToken(tokenHash string, user *User, expiresAt time.Time) {
   tc.mu.Lock()
   defer tc.mu.Unlock()

+  if tc.cache == nil {
+    tc.cache = make(map[string]*CachedToken)
+  }
   tc.cache[tokenHash] = &CachedToken{
     User:      user,
     ExpiresAt: expiresAt,
   }
 }

Optionally add:

// NewTokenCache constructs a ready-to-use cache.
func NewTokenCache() *TokenCache { return &TokenCache{cache: make(map[string]*CachedToken)} }
🤖 Prompt for AI Agents
In cache.go around lines 55 to 64, the setCachedToken method writes to tc.cache
without ensuring the map is initialized, which can panic if tc is a zero value;
modify setCachedToken to check if tc.cache == nil and initialize it with
make(map[string]*CachedToken) before assigning, and keep the mutex semantics
(lock/unlock) as-is; optionally add a NewTokenCache constructor that returns
&TokenCache{cache: make(map[string]*CachedToken)} so callers can obtain a
ready-to-use cache.

Loading
Loading