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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ Server.run() ← dispatch loop
| `gomcp/protocol.go` | Protocol versions, `_meta` negotiation, pagination |
| `gomcp/path.go` | `SafeJoin` — path-traversal-safe filesystem helper |
| `gomcp/server.go` | Server struct, Run(), all JSON-RPC method handlers |
| `gomcp/httpserver.go` | MCP Streamable HTTP transport (`Handler`, `ListenAndServe`, `Serve`) |
| `gomcp/client.go` | MCP client (`Client` interface, `NewHTTPClient`, `NewStdioClient`) |
| `gomcp/httpserver.go` | MCP Streamable HTTP transport (`Handler`, `ListenAndServe`, `Serve`); pluggable auth and the public well-known endpoint |
| `gomcp/client.go` | MCP client (`Client` interface, `NewHTTPClient`, `NewStdioClient`); `SetAuthProvider`, typed `AuthError` |
| `gomcp/server_test.go` | Unit + integration tests (pipe-based) |
| `gomcp/protocol_test.go` | 2026-07-28 + security tests |
| `gomcp/httpserver_test.go`, `gomcp/client_test.go`, `gomcp/http_e2e_test.go` | HTTP transport, client, and loopback E2E tests |
Expand All @@ -58,6 +58,8 @@ Server.run() ← dispatch loop
- **Inbound messages are size-capped.** One line may carry at most `Server.MaxRequestBytes` bytes (default `DefaultMaxRequestBytes`, 10 MiB; negative disables — not recommended). An oversized line is answered with `-32600` (id null), its remainder discarded, and the loop keeps serving — a single line can never exhaust memory.
- **Go naming.** Exported types are PascalCase. Unexported internals are camelCase. Test functions are `TestXxx`.
- **Protocol versions.** Default is `2026-07-28`. Legacy `initialize` still works and echoes `2024-11-05`, `2025-03-26`, or `2025-11-25` when the client asks for them. 2026-only fields (`resultType`, `ttlMs`, `cacheScope`, result `_meta`) are emitted only when the request declares `2026-07-28`.
- **Pluggable HTTP auth.** `SetAuthFunc` replaces (wins over) the `SetAuthToken` static check; a rejected request gets `401` with `WWW-Authenticate: Bearer realm="mcp"`, plus `resource_metadata="<url>"` when `SetResourceMetadataURL` is set. `SetProtectedResourceMetadata` serves `GET /.well-known/oauth-protected-resource` publicly (before the origin and auth checks). The library validates no tokens itself.
- **Client auth errors are typed.** A `401` becomes `*gomcp.AuthError` (`ResourceMetadata`, `Body`); use `errors.As`, or `errors.Is(err, gomcp.ErrUnauthorized)`. `HTTPClient.SetAuthProvider`/`WithAuthHeader` supply the Authorization header per request (wins over the static token; empty falls back; nil clears).
- **Handlers must not kill the loop.** A panicking or timed-out handler is answered in-band; registration maps are mutex-protected.

## Testing
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,49 @@ srv.SetAuthToken(os.Getenv("MCP_TOKEN"))
srv.SetAllowedOrigins([]string{"https://claude.ai"})
```

#### OAuth resource server (RFC 9728)

For OAuth-protected deployments, plug in your own token validation and advertise where clients find the authorization server. go-mcp stays dependency-free: it validates nothing itself, you bring the verifier.

- `srv.SetAuthFunc(fn func(r *http.Request) bool)` — custom authenticator run on every HTTP request. When set it **wins over** `SetAuthToken` (the static token is not consulted); returning `false` yields `401`. `nil` restores the default.
- `srv.SetResourceMetadataURL(url string)` — adds `resource_metadata="<url>"` to the `WWW-Authenticate: Bearer realm="mcp"` header of every `401`, so clients can discover the authorization server. Empty restores the plain challenge.
- `srv.SetProtectedResourceMetadata(meta map[string]any)` — serves `meta` as JSON on `GET /.well-known/oauth-protected-resource`. The endpoint is public: it skips both the auth and the Origin checks. `nil` (default) disables it.

```go
srv.SetAuthFunc(func(r *http.Request) bool {
return verifyJWT(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) // your validator
})
srv.SetResourceMetadataURL("https://mcp.example.com/.well-known/oauth-protected-resource")
srv.SetProtectedResourceMetadata(map[string]any{
"resource": "https://mcp.example.com",
"authorization_servers": []string{"https://auth.example.com"},
})
```

Flow: the client calls without a token, gets `401` with `resource_metadata`, fetches the well-known document, talks to the listed authorization server to obtain a token, and retries.

#### Client authentication and `AuthError`

`HTTPClient` (from `NewHTTPClient` / `NewHTTPClientWithToken`) supports per-request credentials:

- `c.SetAuthProvider(fn func() string)` / chainable `c.WithAuthHeader(fn)` — `fn` returns the full `Authorization` header value (e.g. `"Bearer abc"`), used verbatim on every request. It wins over the static token from `NewHTTPClientWithToken`; an empty return falls back to the static token; `nil` clears the provider.
- A `401` response is returned as a typed `*gomcp.AuthError` with `ResourceMetadata` (from the challenge, empty if absent) and `Body`. `errors.Is(err, gomcp.ErrUnauthorized)` matches any `*AuthError`.

```go
c := gomcp.NewHTTPClient("https://mcp.example.com").
WithAuthHeader(func() string { return "Bearer " + tokenStore.Current() })

_, err := c.ListTools(ctx)
var ae *gomcp.AuthError
if errors.As(err, &ae) {
// ae.ResourceMetadata points at the protected-resource document:
// fetch it, run your OAuth flow, update tokenStore, then retry.
log.Printf("auth required, metadata at %s", ae.ResourceMetadata)
} else if errors.Is(err, gomcp.ErrUnauthorized) {
log.Print("unauthorized")
}
```

### `srv.SetInstructions(text string)`

Optional natural-language guidance returned by `initialize` and `server/discover`.
Expand Down
104 changes: 104 additions & 0 deletions gomcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -214,6 +215,8 @@ type HTTPClient struct {
core clientCore
nextID int64
mu sync.Mutex

authProvider func() string
}

// NewHTTPClient returns a Client posting JSON-RPC messages to url.
Expand All @@ -230,7 +233,102 @@ func NewHTTPClientWithToken(url, token string) *HTTPClient {
return hc
}

// ErrUnauthorized is matched by errors.Is for any *AuthError.
var ErrUnauthorized = errors.New("unauthorized")

// AuthError is returned when the server answers HTTP 401.
type AuthError struct {
// ResourceMetadata is the resource_metadata URL from the
// WWW-Authenticate challenge (empty if absent).
ResourceMetadata string
// Body is the response body text.
Body string
}

// Error implements error.
func (e *AuthError) Error() string {
msg := "http 401: unauthorized"
if e.Body != "" {
msg = "http 401: " + e.Body
}
if e.ResourceMetadata != "" {
msg += " (resource_metadata=" + e.ResourceMetadata + ")"
}
return msg
}

// Is reports whether target is ErrUnauthorized.
func (e *AuthError) Is(target error) bool { return target == ErrUnauthorized }

// newAuthError builds an AuthError from a 401 response.
func newAuthError(h http.Header, body []byte) *AuthError {
ae := &AuthError{Body: strings.TrimSpace(string(body))}
for _, ch := range h.Values("WWW-Authenticate") {
if v, ok := challengeParam(ch, "resource_metadata"); ok {
ae.ResourceMetadata = v
break
}
}
return ae
}

// challengeParam extracts a quoted auth-param value, honouring backslash
// escapes inside the quoted string.
func challengeParam(challenge, name string) (string, bool) {
rest := challenge
for {
i := strings.Index(rest, name+"=\"")
if i < 0 {
return "", false
}
if i > 0 && rest[i-1] != ' ' && rest[i-1] != ',' {
rest = rest[i+len(name):]
continue
}
var sb strings.Builder
q := rest[i+len(name)+2:]
for j := 0; j < len(q); j++ {
switch q[j] {
case '\\':
if j+1 < len(q) {
j++
sb.WriteByte(q[j])
}
case '"':
return sb.String(), true
default:
sb.WriteByte(q[j])
}
}
return "", false
}
}

// SetAuthProvider sets a per-request Authorization header provider. Its
// value is used verbatim and wins over the static token; an empty value
// falls back to the static token; nil clears the provider.
func (c *HTTPClient) SetAuthProvider(fn func() string) {
c.mu.Lock()
c.authProvider = fn
c.mu.Unlock()
}

// WithAuthHeader is a chainable SetAuthProvider.
func (c *HTTPClient) WithAuthHeader(fn func() string) *HTTPClient {
c.SetAuthProvider(fn)
return c
}

func (c *HTTPClient) setAuth(h http.Header) {
c.mu.Lock()
p := c.authProvider
c.mu.Unlock()
if p != nil {
if v := p(); v != "" {
h.Set("Authorization", v)
return
}
}
if c.token != "" {
h.Set("Authorization", "Bearer "+c.token)
}
Expand Down Expand Up @@ -267,6 +365,9 @@ func (c *HTTPClient) call(ctx context.Context, method string, params any, out an
if resp.StatusCode == http.StatusAccepted {
return fmt.Errorf("unexpected 202 for request %q", method)
}
if resp.StatusCode == http.StatusUnauthorized {
return newAuthError(resp.Header, body)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
Expand Down Expand Up @@ -296,6 +397,9 @@ func (c *HTTPClient) notify(ctx context.Context, method string, params any) erro
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode == http.StatusUnauthorized {
return newAuthError(resp.Header, b)
}
return fmt.Errorf("notify %q: http %d: %s", method, resp.StatusCode, strings.TrimSpace(string(b)))
}
_, _ = io.Copy(io.Discard, resp.Body)
Expand Down
Loading
Loading