Compare commits

..
177 changed files with 6549 additions and 29762 deletions

View file

@ -5,11 +5,3 @@ updates:
directory: "/"
schedule:
interval: "monthly"
# Group all github/codeql-action/* sub-paths (init, analyze, ...) into a
# single PR so they are always bumped together. Bumping them independently
# leaves the workflow with mismatched versions across steps, which
# github/codeql-action rejects at runtime.
groups:
codeql-action:
patterns:
- "github/codeql-action/*"

View file

@ -8,9 +8,6 @@ on:
schedule:
- cron: '31 9 * * 4'
# Declare default permissions as read only.
permissions: read-all
jobs:
analyze:
name: Analyze (${{ matrix.language }})
@ -36,13 +33,13 @@ jobs:
build-mode: autobuild
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
category: "/language:${{matrix.language}}"

View file

@ -13,62 +13,44 @@ concurrency:
permissions:
contents: read
env:
CONFORMANCE_VERSION: "0.2.0-alpha.11"
jobs:
server-conformance:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version: "^1.26"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
go-version: "^1.25"
- name: Start everything-server
run: |
go run ./conformance/everything-server/main.go -http=":3001" &
# Wait for the server to be ready.
timeout 30 bash -c 'until curl -s http://localhost:3001/mcp; do sleep 0.5; done'
- name: "Run conformance tests"
uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15
with:
mode: server
url: http://localhost:3001/mcp
suite: active
expected-failures: ./conformance/baseline.yml
node-version: 22
- name: Build everything-server
run: go build -o everything-server ./conformance/everything-server
- name: Run conformance tests (2025-11-25, stateful)
run: |
./everything-server -http=":3001" -stateless=false &
SERVER_PID=$!
trap "kill -9 $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true" EXIT
timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done'
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \
--url http://localhost:3001/mcp \
--requirements 2025-11-25 \
--expected-failures ./conformance/baseline.yml
- name: Run conformance tests (2026-07-28, stateless)
run: |
./everything-server -http=":3001" -stateless &
SERVER_PID=$!
trap "kill -9 $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true" EXIT
timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done'
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \
--url http://localhost:3001/mcp \
--requirements 2026-07-28 \
--expected-failures ./conformance/baseline.yml
client-conformance:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version: "^1.26"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
go-version: "^1.25"
- name: "Run conformance tests"
uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15
with:
mode: client
command: go run -tags mcp_go_client_oauth ./conformance/everything-client
suite: core
expected-failures: ./conformance/baseline.yml
node-version: 22
- name: Run conformance tests (full suite)
run: |
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \
--command "go run ./conformance/everything-client" \
--suite all \
--expected-failures ./conformance/baseline.yml

View file

@ -16,11 +16,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "^1.26"
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check docs are up-to-date
run: |
go generate ./...

View file

@ -11,29 +11,25 @@ permissions:
jobs:
vulncheck:
# Don't run scheduled builds on forks.
if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk'
runs-on: ubuntu-latest
steps:
- name: Run govulncheck
uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0
uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4
with:
go-version-file: go.mod
go-package: ./...
conformance:
# Don't run scheduled builds on forks.
if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk'
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version: "^1.26"
go-version: "^1.25"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: "22"
- name: Run server conformance tests

View file

@ -1,4 +1,4 @@
name: Publish Docs
name: Publish Docs to GitHub Pages
on:
release:
@ -13,20 +13,12 @@ jobs:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4
with:
python-version: 3.x
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
- run: pip install mkdocs-material
- run: mkdocs gh-deploy --force
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs
publish_branch: gh-pages
enable_jekyll: true

View file

@ -34,12 +34,12 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
with:
results_file: results.sarif
results_format: sarif
@ -64,7 +64,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: SARIF file
path: results.sarif
@ -73,6 +73,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif

View file

@ -14,11 +14,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version: "^1.26"
go-version: "^1.25"
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
@ -31,7 +31,7 @@ jobs:
- name: Run Go vet
run: go vet ./...
- name: Run staticcheck
uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # v1.4.1
uses: dominikh/staticcheck-action@024238d2898c874f26d723e7d0ff4308c35589a2 # v1
with:
install-go: false
version: "v0.6.1"
@ -40,12 +40,12 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
go: ["1.25", "1.26"]
go: ["1.24", "1.25"]
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version: ${{ matrix.go }}
- name: Test
@ -55,10 +55,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version: "1.26"
go-version: "1.24"
- name: Test with -race
run: go test -v -race ./...

View file

@ -1,4 +1,3 @@
<!-- Autogenerated by weave; DO NOT EDIT -->
# Contributing to the Go MCP SDK
Thank you for your interest in contributing! The Go SDK needs active
@ -7,31 +6,11 @@ new and emerging use-cases. We welcome all forms of contribution, from filing
and reviewing issues, to contributing fixes, to proposing and implementing new
features.
As described in the [design document](https://github.com/modelcontextprotocol/go-sdk/blob/main/design/design.md), it is important for
As described in the [design document](./design/design.md), it is important for
the MCP SDK to remain idiomatic, future-proof, and extensible. The process
described here is intended to ensure that the SDK evolves safely and
transparently, while adhering to these goals.
1. [Development setup](#development-setup)
1. [Conformance tests](#conformance-tests)
1. [Filing issues](#filing-issues)
1. [Bugs](#bugs)
1. [Proposals](#proposals)
1. [Design discussion](#design-discussion)
1. [Contributing code](#contributing-code)
1. [Adding and updating dependencies](#adding-and-updating-dependencies)
1. [Updating the README](#updating-the-readme)
1. [Versioning](#versioning)
1. [What counts as a breaking change](#what-counts-as-a-breaking-change)
1. [Deprecation](#deprecation)
1. [Timeouts](#timeouts)
1. [Code of conduct](#code-of-conduct)
1. [Governance](#governance)
1. [Working Group meetings](#working-group-meetings)
1. [Discord](#discord)
1. [Antitrust considerations](#antitrust-considerations)
## Development setup
This module can be built and tested using the standard Go toolchain. Run `go
@ -191,57 +170,6 @@ The CI system will automatically check that the README is up-to-date by running
`go generate ./internal/readme` and verifying no changes result. If you see a CI failure about the
README being out of sync, follow the steps above to regenerate it.
## Versioning
The SDK follows [semantic versioning](https://semver.org), as the Go module
system requires: the major version is part of the module path, so a breaking
release would import as `github.com/modelcontextprotocol/go-sdk/v2`. A breaking
change therefore cannot reach existing users by accident; they have to change
their import path to receive one.
This policy covers the exported API of the SDK's importable packages — `mcp`,
`jsonrpc`, `auth`, `auth/extauth` and `oauthex`. Everything under `internal/`
is not importable outside the module and may change in any release.
Which MCP spec revisions each SDK version speaks is documented in the
[README](README.md#version-compatibility). Support for a new revision arrives
in a minor release; dropping a revision the SDK previously negotiated is a
breaking change.
### What counts as a breaking change
- Removing or renaming an exported identifier.
- Changing the signature of an exported function or method.
- Removing or renaming a field of an exported struct, or changing its type.
- Adding a method to an exported interface that users are meant to implement.
- Changing documented behavior that callers could reasonably rely on.
- Dropping support for an MCP spec revision.
The following are not breaking, and may land in a minor or patch release:
- Adding an exported identifier, or a field to an exported struct.
- Fixing behavior that contradicted its documentation.
- Refactoring that leaves the exported API unchanged.
- Adding support for a new MCP spec revision.
- Raising the minimum Go version to one still supported upstream, per the
[README](README.md#version-compatibility).
### Deprecation
An API on its way out is marked with a `// Deprecated:` comment naming its
replacement, which `gopls` and `staticcheck` surface at call sites. It keeps
working until the next major version, since removing it would itself be a
breaking change.
Protocol features that the *specification* deprecates are a separate matter and
follow the spec's timeline rather than this policy. The features deprecated by
[SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging)
— roots, sampling and logging — remain supported for at least twelve months, as
noted in the [README](README.md#version-compatibility).
Any change to the exported API, breaking or not, goes through the
[proposal process](#proposals).
## Timeouts
If a contributor hasn't responded to issue questions or PR comments in two weeks,

View file

@ -34,24 +34,16 @@ contains feature documentation, mapping the MCP spec to the packages above.
The following table shows which versions of the Go SDK support which versions of the MCP specification:
| SDK Version | Latest MCP Spec | All Supported MCP Specs |
|-----------------|-------------------|------------------------------------------------------------------|
| v1.7.0+ | 2026-07-28 | 2026-07-28, 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 |
| v1.4.0 - v1.6.1 | 2025-11-25\* | 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 |
| v1.2.0 - v1.3.1 | 2025-11-25\*\* | 2025-11-25\*\*, 2025-06-18, 2025-03-26, 2024-11-05 |
| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 |
| SDK Version | Latest MCP Spec | All Supported MCP Specs |
|-----------------|-------------------|----------------------------------------------------|
| v1.4.0+ | 2025-11-25\* | 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 |
| v1.2.0 - v1.3.1 | 2025-11-25\*\* | 2025-11-25\*\*, 2025-06-18, 2025-03-26, 2024-11-05 |
| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 |
\* Client side OAuth has experimental support.
\*\* Partial support for 2025-11-25 (client side OAuth and Sampling with tools not available).
The roots, sampling, and logging features are deprecated as of protocol version
2026-07-28 by
[SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging).
The SDK continues to support them for compatibility during the deprecation
window (at least twelve months). See the individual feature documentation for
migration guidance.
New releases of the SDK target only supported versions of Go. See
https://go.dev/doc/devel/release#policy for more information.

View file

@ -8,7 +8,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"strings"
@ -47,34 +46,6 @@ type RequireBearerTokenOptions struct {
ResourceMetadataURL string
// The required scopes.
Scopes []string
// AllowMissingExpiration opts the middleware out of the
// `tokenInfo.Expiration.IsZero()` reject. Default false preserves the
// existing strict behaviour (every TokenInfo must carry an Expiration).
//
// Some IdPs emit session-bound bearer tokens that do not carry a standalone
// `exp` claim — the token's lifetime is bounded by an external session and
// is not advertised in-band. Resource servers integrating with such IdPs
// need to opt in to validating the rest of the token (scopes, signature
// via the verifier callback, etc.) without requiring the expiration field
// to be present.
//
// When enabled, the verifier is still responsible for any session-level
// validity check it can perform; this option only relaxes the middleware's
// own expiration enforcement.
AllowMissingExpiration bool
// ClockSkew bounds the tolerance applied to a token's Expiration when
// deciding whether it has elapsed. A token is rejected only if
// Expiration + ClockSkew is before the current time. Zero (the default)
// preserves strict comparison: any expired token is rejected immediately.
//
// Resource servers running behind a CDN, in distributed deployments, or
// communicating with an authorization server whose clock drifts a few
// seconds (common with cloud-managed IdPs) need a small positive value
// here to avoid rejecting tokens that are valid by the issuer's clock
// but momentarily appear expired by the verifier's. The same tolerance
// guards against an issuer's clock running slightly fast at /token
// issuance time.
ClockSkew time.Duration
}
type tokenInfoKey struct{}
@ -102,17 +73,8 @@ func RequireBearerToken(verifier TokenVerifier, opts *RequireBearerTokenOptions)
tokenInfo, errmsg, code := verify(r, verifier, opts)
if code != 0 {
if code == http.StatusUnauthorized || code == http.StatusForbidden {
if opts != nil {
var params []string
if opts.ResourceMetadataURL != "" {
params = append(params, fmt.Sprintf("resource_metadata=%q", opts.ResourceMetadataURL))
}
if len(opts.Scopes) > 0 {
params = append(params, fmt.Sprintf("scope=%q", strings.Join(opts.Scopes, " ")))
}
if len(params) > 0 {
w.Header().Add("WWW-Authenticate", "Bearer "+strings.Join(params, ", "))
}
if opts != nil && opts.ResourceMetadataURL != "" {
w.Header().Add("WWW-Authenticate", "Bearer resource_metadata="+opts.ResourceMetadataURL)
}
}
http.Error(w, errmsg, code)
@ -157,17 +119,11 @@ func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenO
}
}
if opts == nil {
opts = &RequireBearerTokenOptions{}
}
// Check expiration, with optional clock-skew tolerance. Skew only applies
// when an expiration is present; a missing expiration is governed solely by
// AllowMissingExpiration.
// Check expiration.
if tokenInfo.Expiration.IsZero() {
if !opts.AllowMissingExpiration {
return nil, "token missing expiration", http.StatusUnauthorized
}
} else if tokenInfo.Expiration.Add(opts.ClockSkew).Before(time.Now()) {
return nil, "token missing expiration", http.StatusUnauthorized
}
if tokenInfo.Expiration.Before(time.Now()) {
return nil, "token expired", http.StatusUnauthorized
}
return tokenInfo, "", 0

View file

@ -1,222 +0,0 @@
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by the license
// that can be found in the LICENSE file.
package auth_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
)
// savingTokenSource is an oauth2.TokenSource that passes the oauth2 config and
// token to the given saver function each time the access token value changes.
type savingTokenSource struct {
mu sync.Mutex
src oauth2.TokenSource
saver func(*oauth2.Config, *oauth2.Token) error
config *oauth2.Config
accessToken string
}
func (s *savingTokenSource) Token() (*oauth2.Token, error) {
s.mu.Lock()
defer s.mu.Unlock()
tok, err := s.src.Token()
if err != nil {
return nil, err
}
if s.accessToken != tok.AccessToken {
s.accessToken = tok.AccessToken
// This saver implementation always returns nil.
_ = s.saver(s.config, tok)
}
return tok, nil
}
// NewSavingTokenSource persists OAuth 2.0 sessions by intercepting token
// changes from the wrapped oauth2.TokenSource. When this wrapper detects an
// access token change, it calls the provided session saver with the
// oauth2.Config and the new oauth2.Token.
//
// initial is an optional access token the caller may already hold (such as a
// token loaded from storage). It initialises the wrapper's state so that if
// the first wrapped.Token() call returns the same token, does not trigger a
// redundant call to saver(). Pass nil when there is no existing token, in
// which case the first token produced by wrapped.Token() is saved.
func NewSavingTokenSource(wrapped oauth2.TokenSource, config *oauth2.Config, initial *oauth2.Token, saver func(*oauth2.Config, *oauth2.Token) error) oauth2.TokenSource {
if wrapped == nil {
return nil
}
if saver == nil {
return wrapped
}
var accessToken string
if initial != nil {
accessToken = initial.AccessToken
}
return &savingTokenSource{
src: wrapped,
saver: saver,
config: config,
accessToken: accessToken,
}
}
// sessionStore is an in-memory OAuth 2.0 session store used to demonstrate
// persistence. A production implementation would persist the session to disk or
// a secret store.
type sessionStore struct {
config *oauth2.Config
token *oauth2.Token
}
// save persists the given OAuth 2.0 config and token.
func (s *sessionStore) save(config *oauth2.Config, token *oauth2.Token) error {
fmt.Printf("Saving token: %s\n", token.AccessToken)
s.config = config
s.token = token
return nil
}
// restore loads a previously persisted OAuth 2.0 session, if one exists.
func (s *sessionStore) restore() (*oauth2.Config, *oauth2.Token, error) {
if s.config != nil && s.token != nil {
fmt.Println("Restoring session.")
} else {
fmt.Println("No session found to restore.")
}
return s.config, s.token, nil
}
// newMockAuthServer returns an httptest.Server that simulates both an MCP
// resource server requiring authorization and its OAuth authorization server.
func newMockAuthServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-authorization-server":
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"issuer": "http://%s", "authorization_endpoint": "http://%s/auth", "token_endpoint": "http://%s/token", "code_challenge_methods_supported": ["S256"]}`, r.Host, r.Host, r.Host)
case "/token":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token": "mock-token", "token_type": "bearer"}`))
default:
// The mock MCP endpoint returns 401 until the client presents a valid
// bearer token.
if r.Header.Get("Authorization") == "Bearer mock-token" {
// A real server would return a valid MCP message here. The empty
// response causes Connect to return an error after authorization,
// which is ignored for this example which demonstrates token
// persistence only.
w.WriteHeader(http.StatusOK)
return
}
w.Header().Set("WWW-Authenticate", "Bearer")
w.WriteHeader(http.StatusUnauthorized)
}
}))
}
// This example shows how OAuth2 session persistence might be implemented. It
// connects twice using a shared in-memory session store: the first connection
// authorizes and saves the session, and the second restores and reuses it.
func Example_persistence() {
// Simulate an MCP server that requires authorization and an OAuth server.
mockServer := newMockAuthServer()
defer mockServer.Close()
// store persists the OAuth2 session across both connections.
store := &sessionStore{}
// connect performs a single client connection, restoring any saved session
// beforehand and saving any new session acquired during authorization.
connect := func() {
// Load the OAuth2 session if available, and use it.
var initialTS oauth2.TokenSource
cfg, tok, err := store.restore()
if err == nil && cfg != nil && tok != nil {
initialTS = NewSavingTokenSource(
cfg.TokenSource(context.Background(), tok),
cfg, tok, store.save,
)
}
// Configure and initialize the AuthorizationCodeHandler with a
// NewTokenSource that saves the session when the access token changes,
// and the InitialTokenSource set to the session loaded via restore().
config := &auth.AuthorizationCodeHandlerConfig{
RedirectURL: "http://localhost/callback",
PreregisteredClient: &oauthex.ClientCredentials{
ClientID: "example",
},
Client: mockServer.Client(),
AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
fmt.Println("No token source found. Transport is calling Authorize()...")
// Extract the generated state from the authorization URL
u, _ := url.Parse(args.URL)
state := u.Query().Get("state")
return &auth.AuthorizationResult{Code: "mock-code", State: state}, nil
},
NewTokenSource: func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) {
// This save implementation always returns nil.
_ = store.save(cfg, token)
return NewSavingTokenSource(
cfg.TokenSource(ctx, token), cfg, token, store.save,
), nil
},
InitialTokenSource: initialTS,
}
handler, err := auth.NewAuthorizationCodeHandler(config)
if err != nil {
fmt.Printf("Error creating handler: %v\n", err)
return
}
// Set the constructed handler on a transport.
transport := &mcp.StreamableClientTransport{
Endpoint: mockServer.URL + "/sse",
OAuthHandler: handler,
}
// Create a client and attempt to connect using the configured
// transport. The transport will automatically:
// 1. Call TokenSource() to check for an existing session. This will
// return InitialTokenSource, if set.
// 2. Try the MCP endpoint, and encounter a 401 response from the mock
// server.
// 3. Call Authorize() to perform the OAuth flow, which calls
// NewTokenSource. In this example NewTokenSource saves the newly
// acquired session.
// 4. Retry the MCP endpoint with a valid bearer token and get a 200.
client := mcp.NewClient(
&mcp.Implementation{Name: "example", Version: "1.0.0"}, nil,
)
// Response ignored: this example asserts authorization only.
_, _ = client.Connect(context.Background(), transport, nil)
}
// The first connection has no saved session, so it authorizes and saves.
fmt.Println("--- First connect ---")
connect()
// The second connection restores the saved session and reuses it, so no
// further authorization or save occurs.
fmt.Println("--- Second connect ---")
connect()
// Output:
// --- First connect ---
// No session found to restore.
// No token source found. Transport is calling Authorize()...
// Saving token: mock-token
// --- Second connect ---
// Restoring session.
}

View file

@ -62,11 +62,6 @@ func TestVerify(t *testing.T) {
"no expiration", nil, "Bearer noexp",
"token missing expiration", 401,
},
{
"no expiration with AllowMissingExpiration accepts",
&RequireBearerTokenOptions{AllowMissingExpiration: true}, "Bearer noexp",
"", 0,
},
{
"expired", nil, "Bearer expired",
"token expired", 401,
@ -193,159 +188,3 @@ func TestProtectedResourceMetadataHandler(t *testing.T) {
})
}
}
func TestRequireBearerToken(t *testing.T) {
verifier := func(_ context.Context, token string, _ *http.Request) (*TokenInfo, error) {
if token == "valid" {
return &TokenInfo{Expiration: time.Now().Add(time.Hour), Scopes: []string{"read"}}, nil
}
return nil, ErrInvalidToken
}
tests := []struct {
name string
opts *RequireBearerTokenOptions
authHeader string
wantHeader string
wantStatus int
}{
{
name: "no middleware options",
opts: nil,
authHeader: "Bearer invalid",
wantHeader: "",
wantStatus: http.StatusUnauthorized,
},
{
name: "metadata only",
opts: &RequireBearerTokenOptions{
ResourceMetadataURL: "https://example.com/resource-metadata",
},
authHeader: "Bearer invalid",
wantHeader: "Bearer resource_metadata=\"https://example.com/resource-metadata\"",
wantStatus: http.StatusUnauthorized,
},
{
name: "scopes only",
opts: &RequireBearerTokenOptions{
Scopes: []string{"read", "write"},
},
authHeader: "Bearer invalid",
wantHeader: "Bearer scope=\"read write\"",
wantStatus: http.StatusUnauthorized,
},
{
name: "metadata and scopes",
opts: &RequireBearerTokenOptions{
ResourceMetadataURL: "https://example.com/resource-metadata",
Scopes: []string{"read", "write"},
},
authHeader: "Bearer invalid",
wantHeader: "Bearer resource_metadata=\"https://example.com/resource-metadata\", scope=\"read write\"",
wantStatus: http.StatusUnauthorized,
},
{
name: "insufficient scope",
opts: &RequireBearerTokenOptions{
Scopes: []string{"admin"},
},
authHeader: "Bearer valid", // Has "read", needs "admin" -> 403
wantHeader: "Bearer scope=\"admin\"",
wantStatus: http.StatusForbidden,
},
{
name: "success",
opts: &RequireBearerTokenOptions{
Scopes: []string{"read"},
},
authHeader: "Bearer valid",
wantHeader: "",
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := RequireBearerToken(verifier, tt.opts)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/", nil)
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
got := rec.Header().Get("WWW-Authenticate")
if got != tt.wantHeader {
t.Errorf("WWW-Authenticate = %q, want %q", got, tt.wantHeader)
}
})
}
}
// TestRequireBearerToken_ClockSkew verifies that the ClockSkew option
// extends the expiration check tolerance: a token whose Expiration is in the
// recent past is accepted iff the elapsed interval is within ClockSkew.
func TestRequireBearerToken_ClockSkew(t *testing.T) {
tests := []struct {
name string
clockSkew time.Duration
expiredAgo time.Duration
wantStatus int
}{
{
name: "no skew, fresh token accepted",
clockSkew: 0,
expiredAgo: -time.Minute, // expires in 1 minute
wantStatus: http.StatusOK,
},
{
name: "no skew, expired token rejected",
clockSkew: 0,
expiredAgo: 5 * time.Second, // expired 5s ago
wantStatus: http.StatusUnauthorized,
},
{
name: "with skew, recently-expired token accepted",
clockSkew: 30 * time.Second,
expiredAgo: 5 * time.Second,
wantStatus: http.StatusOK,
},
{
name: "with skew, token expired beyond tolerance rejected",
clockSkew: 10 * time.Second,
expiredAgo: 30 * time.Second,
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
verifier := func(_ context.Context, _ string, _ *http.Request) (*TokenInfo, error) {
return &TokenInfo{Expiration: time.Now().Add(-tt.expiredAgo)}, nil
}
handler := RequireBearerToken(verifier, &RequireBearerTokenOptions{
ClockSkew: tt.clockSkew,
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Authorization", "Bearer anything")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
})
}
}

View file

@ -2,6 +2,8 @@
// Use of this source code is governed by the license
// that can be found in the LICENSE file.
//go:build mcp_go_client_oauth
package auth
import (
@ -9,19 +11,27 @@ import (
"crypto/rand"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
"github.com/modelcontextprotocol/go-sdk/internal/util"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
)
// ClientSecretAuthConfig is used to configure client authentication using client_secret.
// Authentication method will be selected based on the authorization server's supported methods,
// according to the following preference order:
// 1. client_secret_post
// 2. client_secret_basic
type ClientSecretAuthConfig struct {
// ClientID is the client ID to be used for client authentication.
ClientID string
// ClientSecret is the client secret to be used for client authentication.
ClientSecret string
}
// ClientIDMetadataDocumentConfig is used to configure the Client ID Metadata Document
// based client registration per
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents.
@ -32,15 +42,19 @@ type ClientIDMetadataDocumentConfig struct {
URL string
}
// PreregisteredClientConfig is used to configure a pre-registered client per
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration.
// Currently only "client_secret_basic" and "client_secret_post" authentication methods are supported.
type PreregisteredClientConfig struct {
// ClientSecretAuthConfig is the client_secret based configuration to be used for client authentication.
ClientSecretAuthConfig *ClientSecretAuthConfig
}
// DynamicClientRegistrationConfig is used to configure dynamic client registration per
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration.
type DynamicClientRegistrationConfig struct {
// Metadata to be used in dynamic client registration request as per
// https://datatracker.ietf.org/doc/html/rfc7591#section-2.
//
// If Metadata.ApplicationType is empty, it will be inferred from
// Metadata.RedirectURIs. When set, it will be validated against the inferred type
// and an error will be returned if they conflict.
Metadata *oauthex.ClientRegistrationMetadata
}
@ -51,26 +65,14 @@ type AuthorizationResult struct {
Code string
// State string returned by the authorization server.
State string
// Iss is the issuer identifier returned by the authorization server in the
// authorization response per [RFC 9207]. The AuthorizationCodeFetcher should
// populate this from the "iss" query parameter in the redirect URI if present.
//
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
Iss string
}
// AuthorizationArgs is the input to [AuthorizationCodeFetcher].
// AuthorizationArgs is the input to [AuthorizationCodeHandlerConfig].AuthorizationCodeFetcher.
type AuthorizationArgs struct {
// Authorization URL to be opened in a browser for the user to start the authorization process.
URL string
}
// AuthorizationCodeFetcher is called to initiate the OAuth authorization flow.
// It is responsible for directing the user to the authorization URL (e.g., opening
// in a browser) and returning the authorization code and state once the Authorization
// Server redirects back to the configured RedirectURL.
type AuthorizationCodeFetcher func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error)
// AuthorizationCodeHandlerConfig is the configuration for [AuthorizationCodeHandler].
type AuthorizationCodeHandlerConfig struct {
// Client registration configuration.
@ -80,7 +82,7 @@ type AuthorizationCodeHandlerConfig struct {
// 3. Dynamic Client Registration
// At least one method must be configured.
ClientIDMetadataDocumentConfig *ClientIDMetadataDocumentConfig
PreregisteredClient *oauthex.ClientCredentials
PreregisteredClientConfig *PreregisteredClientConfig
DynamicClientRegistrationConfig *DynamicClientRegistrationConfig
// RedirectURL is a required URL to redirect to after authorization.
@ -95,70 +97,10 @@ type AuthorizationCodeHandlerConfig struct {
RedirectURL string
// AuthorizationCodeFetcher is a required function called to initiate the authorization flow.
// See [AuthorizationCodeFetcher] for details.
AuthorizationCodeFetcher AuthorizationCodeFetcher
// ScopeFilter, if non-nil, is called with the scopes discovered from the
// protected resource metadata (or WWW-Authenticate challenge) and returns
// the scopes to request during authorization. It gives the client full
// control to narrow, extend, or reorder that set — e.g. dropping Gmail's
// gmail.metadata, which the Gmail API refuses to combine with the search "q"
// parameter even alongside gmail.readonly. It runs before offline_access
// (see RequestRefreshToken) and the step-up union, so neither is affected.
ScopeFilter func(discovered []string) []string
// RequestRefreshToken indicates that the client intends to use refresh
// tokens and is capable of storing them securely.
//
// When true and the Authorization Server metadata contains "offline_access"
// in its scopes_supported, the client adds "offline_access" to the
// requested scopes.
//
// When using Dynamic Client Registration, callers should include
// "refresh_token" in [DynamicClientRegistrationConfig].Metadata.GrantTypes
// directly to advertise refresh token support to the Authorization Server.
//
// When using Client ID Metadata Document, the document hosted at the
// Client ID URL should include "refresh_token" in its grant_types.
//
// See https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance.
RequestRefreshToken bool
// AcceptUnadvertisedIss accepts a matching RFC 9207 iss even when the
// authorization server metadata omits authorization_response_iss_parameter_supported.
// The zero value (false) keeps the historical reject-unadvertised-iss behavior.
AcceptUnadvertisedIss bool
// Client is an optional HTTP client to use for HTTP requests.
// It is used for the following requests:
// - Fetching Protected Resource Metadata
// - Fetching Authorization Server Metadata
// - Registering a client dynamically
// - Exchanging an authorization code for an access token
// - Refreshing an access token
// Custom clients can include additional security configurations,
// such as SSRF protections, see
// https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#server-side-request-forgery-ssrf
// If not provided, http.DefaultClient will be used.
Client *http.Client
// NewTokenSource is an optional function that can be set to construct the
// token source that will be used by the [AuthorizationCodeHandler]. If
// non-nil, it is called after the authorization code is successfully
// exchanged for a token in [AuthorizationCodeHandler.Authorize]
// to obtain the [oauth2.TokenSource] returned by
// [AuthorizationCodeHandler.TokenSource]. Implementations must use the
// provided context, which is properly configured for constructing a
// TokenSource. The default is to call [oauth2.Config.TokenSource].
NewTokenSource func(context.Context, *oauth2.Config, *oauth2.Token) (oauth2.TokenSource, error)
// InitialTokenSource is an optional field that can be set to inject the
// token source that will be used by the [AuthorizationCodeHandler]. If
// non-nil, it is set as the token source that will be returned by
// [AuthorizationCodeHandler.TokenSource] during handler initialization.
// The default is nil, which means no token source has been set initially,
// and will trigger a call to [AuthorizationCodeHandler.Authorize].
InitialTokenSource oauth2.TokenSource
// It is responsible for opening the URL in a browser for the user to start the authorization process.
// It should return the authorization code and state once the Authorization Server
// redirects back to the RedirectURL.
AuthorizationCodeFetcher func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error)
}
// AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses
@ -166,21 +108,15 @@ type AuthorizationCodeHandlerConfig struct {
type AuthorizationCodeHandler struct {
config *AuthorizationCodeHandlerConfig
// mu protects concurrent access to tokenSource and grantedScopes.
mu sync.RWMutex
// tokenSource is the token source to use for authorization.
tokenSource oauth2.TokenSource
// grantedScopes maps authorization server issuer to the list of scopes granted by that issuer.
grantedScopes map[string][]string
}
var _ OAuthHandler = (*AuthorizationCodeHandler)(nil)
func (h *AuthorizationCodeHandler) isOAuthHandler() {}
func (h *AuthorizationCodeHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
h.mu.RLock()
defer h.mu.RUnlock()
return h.tokenSource, nil
}
@ -192,7 +128,7 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
return nil, errors.New("config must be provided")
}
if config.ClientIDMetadataDocumentConfig == nil &&
config.PreregisteredClient == nil &&
config.PreregisteredClientConfig == nil &&
config.DynamicClientRegistrationConfig == nil {
return nil, errors.New("at least one client registration configuration must be provided")
}
@ -202,15 +138,19 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
if config.ClientIDMetadataDocumentConfig != nil && !isNonRootHTTPSURL(config.ClientIDMetadataDocumentConfig.URL) {
return nil, fmt.Errorf("client ID metadata document URL must be a non-root HTTPS URL")
}
if config.PreregisteredClient != nil {
if err := config.PreregisteredClient.Validate(); err != nil {
return nil, fmt.Errorf("invalid PreregisteredClient configuration: %w", err)
preCfg := config.PreregisteredClientConfig
if preCfg != nil {
if preCfg.ClientSecretAuthConfig == nil {
return nil, errors.New("ClientSecretAuthConfig is required for pre-registered client")
}
if preCfg.ClientSecretAuthConfig.ClientID == "" || preCfg.ClientSecretAuthConfig.ClientSecret == "" {
return nil, fmt.Errorf("pre-registered client ID or secret is empty")
}
}
dCfg := config.DynamicClientRegistrationConfig
if dCfg != nil {
if dCfg.Metadata == nil {
return nil, errors.New("dynamic client registration requires non-nil Metadata")
return nil, errors.New("Metadata is required for dynamic client registration")
}
if len(dCfg.Metadata.RedirectURIs) == 0 {
return nil, errors.New("Metadata.RedirectURIs is required for dynamic client registration")
@ -220,26 +160,13 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
} else if !slices.Contains(dCfg.Metadata.RedirectURIs, config.RedirectURL) {
return nil, fmt.Errorf("RedirectURL %q is not in the list of allowed redirect URIs for dynamic client registration", config.RedirectURL)
}
applicationType := inferApplicationType(dCfg.Metadata.RedirectURIs)
if dCfg.Metadata.ApplicationType == "" {
dCfg.Metadata.ApplicationType = applicationType
} else if dCfg.Metadata.ApplicationType != applicationType {
return nil, fmt.Errorf("application type %q conflicts with the application type inferred from redirect URIs", dCfg.Metadata.ApplicationType)
}
}
if config.RedirectURL == "" {
// If the RedirectURL was supposed to be set by the dynamic client registration,
// it should have been set by now. Otherwise, it is required.
return nil, errors.New("RedirectURL is required")
}
if config.Client == nil {
config.Client = http.DefaultClient
}
return &AuthorizationCodeHandler{
config: config,
tokenSource: config.InitialTokenSource,
grantedScopes: make(map[string][]string),
}, nil
return &AuthorizationCodeHandler{config: config}, nil
}
func isNonRootHTTPSURL(u string) bool {
@ -250,42 +177,11 @@ func isNonRootHTTPSURL(u string) bool {
return pu.Scheme == "https" && pu.Path != ""
}
// inferApplicationType returns an application type based on the redirect URIs.
func inferApplicationType(redirectURIs []string) string {
hasNative := false
hasWeb := false
for _, uri := range redirectURIs {
u, err := url.Parse(uri)
if err != nil {
return ""
}
switch u.Scheme {
case "http", "https":
if util.IsLoopback(u.Hostname()) {
hasNative = true
} else {
hasWeb = true
}
default:
hasNative = true
}
}
if hasNative && hasWeb {
return ""
}
if hasNative {
return "native"
}
return "web"
}
// Authorize performs the authorization flow.
// It is designed to perform the whole Authorization Code Grant flow.
// On success, [AuthorizationCodeHandler.TokenSource] will return a token source with the fetched token.
func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error {
defer resp.Body.Close()
defer io.Copy(io.Discard, resp.Body)
wwwChallenges, err := oauthex.ParseWWWAuthenticate(resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")])
if err != nil {
@ -306,20 +202,9 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
return err
}
asm, err := GetAuthServerMetadata(ctx, prm.AuthorizationServers[0], h.config.Client)
asm, err := h.getAuthServerMetadata(ctx, prm)
if err != nil {
return fmt.Errorf("failed to get authorization server metadata: %w", err)
}
if asm == nil {
// Fallback to 2025-03-26 spec: predefined endpoints.
// https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
authServerURL := prm.AuthorizationServers[0]
asm = &oauthex.AuthServerMeta{
Issuer: authServerURL,
AuthorizationEndpoint: authServerURL + "/authorize",
TokenEndpoint: authServerURL + "/token",
RegistrationEndpoint: authServerURL + "/register",
}
return err
}
resolvedClientConfig, err := h.handleRegistration(ctx, asm)
@ -327,33 +212,11 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
return err
}
requestedScopes := scopesFromChallenges(wwwChallenges)
if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 {
requestedScopes = prm.ScopesSupported
scps := scopesFromChallenges(wwwChallenges)
if len(scps) == 0 && len(prm.ScopesSupported) > 0 {
scps = prm.ScopesSupported
}
// Let the client adjust the discovered scopes before offline_access and the
// step-up union below so neither is affected.
if h.config.ScopeFilter != nil {
requestedScopes = h.config.ScopeFilter(requestedScopes)
}
// SEP-2207: when the client desires refresh tokens and the Authorization
// Server advertises offline_access support, add it to the requested scopes.
if h.config.RequestRefreshToken &&
slices.Contains(asm.ScopesSupported, "offline_access") &&
!slices.Contains(requestedScopes, "offline_access") {
requestedScopes = append(requestedScopes, "offline_access")
}
// Accumulate scopes: union previously granted scopes with the newly
// challenged scopes so that step-up authorization does not lose
// permissions granted in earlier rounds (SEP-2350).
h.mu.RLock()
granted := h.grantedScopes[asm.Issuer]
h.mu.RUnlock()
requestedScopes = authutil.UnionScopes(granted, requestedScopes)
cfg := &oauth2.Config{
ClientID: resolvedClientConfig.clientID,
ClientSecret: resolvedClientConfig.clientSecret,
@ -364,24 +227,16 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
AuthStyle: resolvedClientConfig.authStyle,
},
RedirectURL: h.config.RedirectURL,
Scopes: requestedScopes,
Scopes: scps,
}
authRes, err := h.getAuthorizationCode(ctx, cfg, prm.Resource)
authRes, err := h.getAuthorizationCode(ctx, cfg, req.URL.String())
if err != nil {
// Purposefully leaving the error unwrappable so it can be handled by the caller.
return err
}
if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported, h.config.AcceptUnadvertisedIss); err != nil {
return err
}
err = h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource)
if err != nil {
return err
}
return h.updateGrantedScopes(asm.Issuer, requestedScopes)
return h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource)
}
// resourceMetadataURLFromChallenges returns a resource metadata URL from the given "WWW-Authenticate" header challenges,
@ -421,34 +276,22 @@ func errorFromChallenges(cs []oauthex.Challenge) string {
// If no metadata was found or the fetched metadata fails security checks,
// it returns an error.
func (h *AuthorizationCodeHandler) getProtectedResourceMetadata(ctx context.Context, wwwChallenges []oauthex.Challenge, mcpServerURL string) (*oauthex.ProtectedResourceMetadata, error) {
var errs []error
// Use MCP server URL as the resource URI per
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#canonical-server-uri.
for _, url := range protectedResourceMetadataURLs(resourceMetadataURLFromChallenges(wwwChallenges), mcpServerURL) {
prm, err := oauthex.GetProtectedResourceMetadata(ctx, url.URL, url.Resource, h.config.Client)
prm, err := oauthex.GetProtectedResourceMetadata(ctx, url.URL, url.Resource, http.DefaultClient)
if err != nil {
errs = append(errs, err)
continue
}
if prm == nil {
errs = append(errs, fmt.Errorf("protected resource metadata is nil"))
continue
}
if len(prm.AuthorizationServers) == 0 {
// If we found PRM, we enforce the 2025-11-25 spec and not search further.
return nil, fmt.Errorf("protected resource metadata has no authorization servers specified")
}
return prm, nil
}
// Fallback to 2025-03-26 spec MCP server root is the Authorization Server:
// https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery
u, err := url.Parse(mcpServerURL)
if err != nil {
return nil, fmt.Errorf("failed to parse MCP server URL: %v", err)
}
u.Path = ""
prm := &oauthex.ProtectedResourceMetadata{
AuthorizationServers: []string{u.String()},
Resource: mcpServerURL,
}
return prm, nil
return nil, fmt.Errorf("failed to get protected resource metadata: %v", errors.Join(errs...))
}
type prmURL struct {
@ -491,6 +334,82 @@ func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL {
return urls
}
// getAuthServerMetadata returns the authorization server metadata.
// The provided Protected Resource Metadata must not be nil.
// It returns an error if the metadata request fails with non-4xx HTTP status code
// or the fetched metadata fails security checks.
// If no metadata was found, it returns a minimal set of endpoints
// as a fallback to 2025-03-26 spec.
func (h *AuthorizationCodeHandler) getAuthServerMetadata(ctx context.Context, prm *oauthex.ProtectedResourceMetadata) (*oauthex.AuthServerMeta, error) {
var authServerURL string
if len(prm.AuthorizationServers) > 0 {
// Use the first authorization server, similarly to other SDKs.
authServerURL = prm.AuthorizationServers[0]
} else {
// Fallback to 2025-03-26 spec: MCP server base URL acts as Authorization Server.
authURL, err := url.Parse(prm.Resource)
if err != nil {
return nil, fmt.Errorf("failed to parse resource URL: %v", err)
}
authURL.Path = ""
authServerURL = authURL.String()
}
for _, u := range authorizationServerMetadataURLs(authServerURL) {
asm, err := oauthex.GetAuthServerMeta(ctx, u, authServerURL, http.DefaultClient)
if err != nil {
return nil, fmt.Errorf("failed to get authorization server metadata: %w", err)
}
if asm != nil {
return asm, nil
}
}
// Fallback to 2025-03-26 spec: predefined endpoints.
// https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
asm := &oauthex.AuthServerMeta{
Issuer: authServerURL,
AuthorizationEndpoint: authServerURL + "/authorize",
TokenEndpoint: authServerURL + "/token",
RegistrationEndpoint: authServerURL + "/register",
}
return asm, nil
}
// authorizationServerMetadataURLs returns a list of URLs to try when looking for
// authorization server metadata as mandated by the MCP specification:
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery.
func authorizationServerMetadataURLs(issuerURL string) []string {
var urls []string
baseURL, err := url.Parse(issuerURL)
if err != nil {
return nil
}
if baseURL.Path == "" {
// "OAuth 2.0 Authorization Server Metadata".
baseURL.Path = "/.well-known/oauth-authorization-server"
urls = append(urls, baseURL.String())
// "OpenID Connect Discovery 1.0".
baseURL.Path = "/.well-known/openid-configuration"
urls = append(urls, baseURL.String())
return urls
}
originalPath := baseURL.Path
// "OAuth 2.0 Authorization Server Metadata with path insertion".
baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/")
urls = append(urls, baseURL.String())
// "OpenID Connect Discovery 1.0 with path insertion".
baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/")
urls = append(urls, baseURL.String())
// "OpenID Connect Discovery 1.0 with path appending".
baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration"
urls = append(urls, baseURL.String())
return urls
}
type registrationType int
const (
@ -553,27 +472,20 @@ func (h *AuthorizationCodeHandler) handleRegistration(ctx context.Context, asm *
}, nil
}
// 2. Attempt to use pre-registered client configuration.
preCfg := h.config.PreregisteredClient
if preCfg != nil {
if preCfg.Issuer != "" && !authutil.IssuersEqual(preCfg.Issuer, asm.Issuer) {
return nil, fmt.Errorf("authorization server issuer %q does not match pre-registered credentials issuer %q", asm.Issuer, preCfg.Issuer)
}
pCfg := h.config.PreregisteredClientConfig
if pCfg != nil {
authStyle := selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported)
clientSecret := ""
if preCfg.ClientSecretAuth != nil {
clientSecret = preCfg.ClientSecretAuth.ClientSecret
}
return &resolvedClientConfig{
registrationType: registrationTypePreregistered,
clientID: preCfg.ClientID,
clientSecret: clientSecret,
clientID: pCfg.ClientSecretAuthConfig.ClientID,
clientSecret: pCfg.ClientSecretAuthConfig.ClientSecret,
authStyle: authStyle,
}, nil
}
// 3. Attempt to use dynamic client registration.
dcrCfg := h.config.DynamicClientRegistrationConfig
if dcrCfg != nil && asm.RegistrationEndpoint != "" {
regResp, err := oauthex.RegisterClient(ctx, asm.RegistrationEndpoint, dcrCfg.Metadata, h.config.Client)
regResp, err := oauthex.RegisterClient(ctx, asm.RegistrationEndpoint, dcrCfg.Metadata, http.DefaultClient)
if err != nil {
return nil, fmt.Errorf("failed to register client: %w", err)
}
@ -620,36 +532,6 @@ func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg
}, nil
}
// validateIssuerResponse validates the "iss" parameter in an authorization response
// per [RFC 9207]. When the server advertises authorization_response_iss_parameter_supported,
// iss is required and must match expectedIssuer. When it does not advertise support,
// an empty iss is accepted; a present iss is compared to expectedIssuer (RFC 9207 §2.4)
// and a mismatch is always rejected. A matching unadvertised iss is accepted only when
// acceptUnadvertisedIss is true (local policy); the default is the historical reject.
//
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported, acceptUnadvertisedIss bool) error {
if issParameterSupported {
if iss == "" {
return fmt.Errorf("authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response")
}
if iss != expectedIssuer {
return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer)
}
return nil
}
if iss == "" {
return nil
}
if iss != expectedIssuer {
return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer)
}
if !acceptUnadvertisedIss {
return fmt.Errorf("authorization server does not advertise RFC 9207 iss parameter support but iss was received in the authorization response")
}
return nil
}
// exchangeAuthorizationCode exchanges the authorization code for a token
// and stores it in a token source.
func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context, cfg *oauth2.Config, authResult *authResult, resourceURL string) error {
@ -657,57 +539,10 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context
oauth2.VerifierOption(authResult.usedCodeVerifier),
oauth2.SetAuthURLParam("resource", resourceURL),
}
clientCtx := context.WithValue(ctx, oauth2.HTTPClient, h.config.Client)
token, err := cfg.Exchange(clientCtx, authResult.Code, opts...)
token, err := cfg.Exchange(ctx, authResult.Code, opts...)
if err != nil {
return fmt.Errorf("token exchange failed: %w", err)
}
// The token source outlives this authorization request: it is stored on the
// handler and used by the transport for the lifetime of the connection. The
// oauth2 library captures the context passed to TokenSource and reuses it for
// every subsequent token refresh (see golang.org/x/oauth2: tokenRefresher
// retains the context and passes it to each refresh round-trip). Binding it to
// the per-request ctx makes all later refreshes fail with "context canceled"
// once that request (or the connect operation that triggered authorization)
// completes. Use a background context that still carries the configured HTTP
// client so refreshes keep working for the life of the token source.
refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client)
var ts oauth2.TokenSource
if h.config.NewTokenSource == nil {
ts = cfg.TokenSource(refreshCtx, token)
} else {
var err error
ts, err = h.config.NewTokenSource(refreshCtx, cfg, token)
if err != nil {
return fmt.Errorf("constructing token source failed: %w", err)
}
}
h.mu.Lock()
h.tokenSource = ts
h.mu.Unlock()
return nil
}
// updateGrantedScopes updates the granted scopes based on the token source and requested scopes.
func (h *AuthorizationCodeHandler) updateGrantedScopes(issuer string, requestedScopes []string) error {
h.mu.RLock()
ts := h.tokenSource
h.mu.RUnlock()
if ts == nil {
return nil
}
tok, err := ts.Token()
if err != nil {
return err
}
h.mu.Lock()
if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil {
h.grantedScopes[issuer] = requestedScopes
} else {
h.grantedScopes[issuer] = tokenScopes
}
h.mu.Unlock()
h.tokenSource = cfg.TokenSource(ctx, token)
return nil
}

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,8 @@ import (
// [github.com/modelcontextprotocol/go-sdk/mcp.StreamableClientTransport]
// for an example.
type OAuthHandler interface {
isOAuthHandler()
// TokenSource returns a token source to be used for outgoing requests.
// Returned token source might be nil. In that case, the transport will not
// add any authorization headers to the request.

135
auth/client_private.go Normal file
View file

@ -0,0 +1,135 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
//go:build mcp_go_client_oauth
package auth
import (
"bytes"
"errors"
"io"
"net/http"
"sync"
"golang.org/x/oauth2"
)
// An OAuthHandlerLegacy conducts an OAuth flow and returns a [oauth2.TokenSource] if the authorization
// is approved, or an error if not.
// The handler receives the HTTP request and response that triggered the authentication flow.
// To obtain the protected resource metadata, call [oauthex.GetProtectedResourceMetadataFromHeader].
//
// Deprecated: Please use the new [OAuthHandler] abstraction that is built
// into the streamable transport. This struct will be removed in v1.5.0.
type OAuthHandlerLegacy func(req *http.Request, res *http.Response) (oauth2.TokenSource, error)
// HTTPTransport is an [http.RoundTripper] that follows the MCP
// OAuth protocol when it encounters a 401 Unauthorized response.
//
// Deprecated: Please use the new [OAuthHandler] abstraction that is built
// into the streamable transport. This struct will be removed in v1.5.0.
type HTTPTransport struct {
handler OAuthHandlerLegacy
mu sync.Mutex // protects opts.Base
opts HTTPTransportOptions
}
// NewHTTPTransport returns a new [*HTTPTransport].
// The handler is invoked when an HTTP request results in a 401 Unauthorized status.
// It is called only once per transport. Once a TokenSource is obtained, it is used
// for the lifetime of the transport; subsequent 401s are not processed.
//
// Deprecated: Please use the new [OAuthHandler] abstraction that is built
// into the streamable transport. This struct will be removed in v1.5.0.
func NewHTTPTransport(handler OAuthHandlerLegacy, opts *HTTPTransportOptions) (*HTTPTransport, error) {
if handler == nil {
return nil, errors.New("handler cannot be nil")
}
t := &HTTPTransport{
handler: handler,
}
if opts != nil {
t.opts = *opts
}
if t.opts.Base == nil {
t.opts.Base = http.DefaultTransport
}
return t, nil
}
// HTTPTransportOptions are options to [NewHTTPTransport].
//
// Deprecated: Please use the new [OAuthHandler] abstraction that is built
// into the streamable transport. This struct will be removed in v1.5.0.
type HTTPTransportOptions struct {
// Base is the [http.RoundTripper] to use.
// If nil, [http.DefaultTransport] is used.
Base http.RoundTripper
}
func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
t.mu.Lock()
base := t.opts.Base
t.mu.Unlock()
var (
// If haveBody is set, the request has a nontrivial body, and we need avoid
// reading (or closing) it multiple times. In that case, bodyBytes is its
// content.
haveBody bool
bodyBytes []byte
)
if req.Body != nil && req.Body != http.NoBody {
// if we're setting Body, we must mutate first.
req = req.Clone(req.Context())
haveBody = true
var err error
bodyBytes, err = io.ReadAll(req.Body)
if err != nil {
return nil, err
}
// Now that we've read the request body, http.RoundTripper requires that we
// close it.
req.Body.Close() // ignore error
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
resp, err := base.RoundTrip(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusUnauthorized {
return resp, nil
}
if _, ok := base.(*oauth2.Transport); ok {
// We failed to authorize even with a token source; give up.
return resp, nil
}
resp.Body.Close()
// Try to authorize.
t.mu.Lock()
defer t.mu.Unlock()
// If we don't have a token source, get one by following the OAuth flow.
// (We may have obtained one while t.mu was not held above.)
// TODO: We hold the lock for the entire OAuth flow. This could be a long
// time. Is there a better way?
if _, ok := t.opts.Base.(*oauth2.Transport); !ok {
ts, err := t.handler(req, resp)
if err != nil {
return nil, err
}
t.opts.Base = &oauth2.Transport{Base: t.opts.Base, Source: ts}
}
// If we don't have a body, the request is reusable, though it will be cloned
// by the base. However, if we've had to read the body, we must clone.
if haveBody {
req = req.Clone(req.Context())
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
return t.opts.Base.RoundTrip(req)
}

153
auth/client_test.go Normal file
View file

@ -0,0 +1,153 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
//go:build mcp_go_client_oauth
package auth
import (
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"golang.org/x/oauth2"
)
// A basicReader is an io.Reader to be used as a non-rereadable request body.
//
// net/http has special handling for strings.Reader that we want to avoid.
type basicReader struct {
r *strings.Reader
}
func (r *basicReader) Read(p []byte) (n int, err error) { return r.r.Read(p) }
// TestHTTPTransport validates the OAuth HTTPTransport.
func TestHTTPTransport(t *testing.T) {
const testToken = "test-token-123"
fakeTokenSource := oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: testToken,
TokenType: "Bearer",
})
// authServer simulates a resource that requires OAuth.
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
// Ensure that the body was properly cloned, by reading it completely.
// If the body is not cloned, reading it the second time may yield no
// bytes.
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(body) == 0 {
http.Error(w, "empty body", http.StatusBadRequest)
return
}
}
authHeader := r.Header.Get("Authorization")
if authHeader == fmt.Sprintf("Bearer %s", testToken) {
w.WriteHeader(http.StatusOK)
return
}
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="http://metadata.example.com"`)
w.WriteHeader(http.StatusUnauthorized)
}))
defer authServer.Close()
t.Run("successful auth flow", func(t *testing.T) {
var handlerCalls int
handler := func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) {
handlerCalls++
// Verify that the response has the expected WWW-Authenticate header
if res.Header.Get("WWW-Authenticate") != `Bearer resource_metadata="http://metadata.example.com"` {
t.Errorf("handler got WWW-Authenticate header %q", res.Header.Get("WWW-Authenticate"))
}
return fakeTokenSource, nil
}
transport, err := NewHTTPTransport(handler, nil)
if err != nil {
t.Fatalf("NewHTTPTransport() failed: %v", err)
}
client := &http.Client{Transport: transport}
resp, err := client.Get(authServer.URL)
if err != nil {
t.Fatalf("client.Get() failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("got status %d, want %d", resp.StatusCode, http.StatusOK)
}
if handlerCalls != 1 {
t.Errorf("handler was called %d times, want 1", handlerCalls)
}
// Second request should reuse the token and not call the handler again.
resp2, err := client.Get(authServer.URL)
if err != nil {
t.Fatalf("second client.Get() failed: %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
t.Errorf("second request got status %d, want %d", resp2.StatusCode, http.StatusOK)
}
if handlerCalls != 1 {
t.Errorf("handler should still be called only once, but was %d", handlerCalls)
}
})
t.Run("request body is cloned", func(t *testing.T) {
handler := func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) {
// Verify that the response has the expected WWW-Authenticate header
if res.Header.Get("WWW-Authenticate") != `Bearer resource_metadata="http://metadata.example.com"` {
t.Errorf("handler got WWW-Authenticate header %q", res.Header.Get("WWW-Authenticate"))
}
return fakeTokenSource, nil
}
transport, err := NewHTTPTransport(handler, nil)
if err != nil {
t.Fatalf("NewHTTPTransport() failed: %v", err)
}
client := &http.Client{Transport: transport}
resp, err := client.Post(authServer.URL, "application/json", &basicReader{strings.NewReader("{}")})
if err != nil {
t.Fatalf("client.Post() failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("got status %d, want %d", resp.StatusCode, http.StatusOK)
}
})
t.Run("handler returns error", func(t *testing.T) {
handlerErr := errors.New("user rejected auth")
handler := func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) {
return nil, handlerErr
}
transport, err := NewHTTPTransport(handler, nil)
if err != nil {
t.Fatalf("NewHTTPTransport() failed: %v", err)
}
client := &http.Client{Transport: transport}
_, err = client.Get(authServer.URL)
if !errors.Is(err, handlerErr) {
t.Errorf("client.Get() returned error %v, want %v", err, handlerErr)
}
})
}

View file

@ -1,279 +0,0 @@
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by the license
// that can be found in the LICENSE file.
package extauth
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strings"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
)
// ClientCredentialsHandlerConfig is the configuration for [ClientCredentialsHandler].
type ClientCredentialsHandlerConfig struct {
// Credentials contains the pre-registered client ID and secret.
// REQUIRED. Both ClientID and ClientSecretAuth must be set, since the
// client credentials grant requires a confidential client.
Credentials *oauthex.ClientCredentials
// HTTPClient is an optional HTTP client for customization.
// If nil, http.DefaultClient is used.
// OPTIONAL.
HTTPClient *http.Client
}
// ClientCredentialsHandler is an implementation of [auth.OAuthHandler] that
// uses the OAuth 2.0 Client Credentials grant (RFC 6749 Section 4.4) to
// obtain access tokens.
//
// This handler is intended for service-to-service authentication where the
// client has pre-registered credentials (client ID and secret) and does not
// require user interaction. It bypasses both dynamic client registration and
// the authorization code flow.
//
// The token endpoint and scopes are discovered automatically via Protected
// Resource Metadata (RFC 9728) and Authorization Server Metadata (RFC 8414),
// following the ext-auth specification SEP-1046.
type ClientCredentialsHandler struct {
config *ClientCredentialsHandlerConfig
tokenSource oauth2.TokenSource
// grantedScopes maps authorization server issuer to the list of scopes granted by that issuer.
grantedScopes map[string][]string
}
// Compile-time check that ClientCredentialsHandler implements auth.OAuthHandler.
var _ auth.OAuthHandler = (*ClientCredentialsHandler)(nil)
// NewClientCredentialsHandler creates a new ClientCredentialsHandler.
// It validates the configuration and returns an error if invalid.
func NewClientCredentialsHandler(config *ClientCredentialsHandlerConfig) (*ClientCredentialsHandler, error) {
if config == nil {
return nil, fmt.Errorf("config must be provided")
}
if config.Credentials == nil {
return nil, fmt.Errorf("credentials are required")
}
if err := config.Credentials.Validate(); err != nil {
return nil, fmt.Errorf("invalid credentials: %w", err)
}
if config.Credentials.ClientSecretAuth == nil {
return nil, fmt.Errorf("clientSecretAuth is required for client credentials grant")
}
return &ClientCredentialsHandler{
config: config,
grantedScopes: make(map[string][]string),
}, nil
}
// TokenSource returns the token source for outgoing requests.
// Returns nil if authorization has not been performed yet.
func (h *ClientCredentialsHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
return h.tokenSource, nil
}
// Authorize performs the Client Credentials grant to obtain an access token.
// It is called when a request fails with 401 or 403.
//
// The flow follows the ext-auth specification SEP-1046:
// 1. Discover Protected Resource Metadata from the request URL
// 2. Discover Authorization Server Metadata from PRM
// 3. Exchange client credentials for an access token at the token endpoint
func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error {
defer resp.Body.Close()
defer io.Copy(io.Discard, resp.Body)
httpClient := h.config.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
// Step 1: Discover Protected Resource Metadata.
wwwChallenges, err := oauthex.ParseWWWAuthenticate(resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")])
if err != nil {
return fmt.Errorf("failed to parse WWW-Authenticate header: %v", err)
}
prm, err := getProtectedResourceMetadata(ctx, wwwChallenges, req.URL.String(), httpClient)
if err != nil {
return err
}
if len(prm.AuthorizationServers) == 0 {
return fmt.Errorf("protected resource metadata has no authorization servers specified")
}
// Step 2: Discover Authorization Server Metadata.
asm, err := auth.GetAuthServerMetadata(ctx, prm.AuthorizationServers[0], httpClient)
if err != nil {
return fmt.Errorf("failed to get authorization server metadata: %w", err)
}
if asm == nil {
// Fallback to 2025-03-26 spec: predefined endpoints.
authServerURL := prm.AuthorizationServers[0]
asm = &oauthex.AuthServerMeta{
Issuer: authServerURL,
TokenEndpoint: authServerURL + "/token",
}
}
creds := h.config.Credentials
if creds.Issuer != "" && !authutil.IssuersEqual(creds.Issuer, asm.Issuer) {
return fmt.Errorf("authorization server issuer %q does not match pre-registered credentials issuer %q", asm.Issuer, creds.Issuer)
}
// Determine requestedScopes: use PRM's scopes_supported if available.
requestedScopes := scopesFromChallenges(wwwChallenges)
if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 {
requestedScopes = prm.ScopesSupported
}
// Accumulate scopes: union previously granted scopes with the newly
// challenged scopes so that step-up authorization does not lose
// permissions granted in earlier rounds (SEP-2350).
requestedScopes = authutil.UnionScopes(h.grantedScopes[asm.Issuer], requestedScopes)
// Step 3: Exchange client credentials for an access token.
cfg := &clientcredentials.Config{
ClientID: creds.ClientID,
ClientSecret: creds.ClientSecretAuth.ClientSecret,
TokenURL: asm.TokenEndpoint,
Scopes: requestedScopes,
AuthStyle: selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported),
}
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
h.tokenSource = cfg.TokenSource(ctxWithClient)
return h.updateGrantedScopes(asm.Issuer, requestedScopes)
}
func (h *ClientCredentialsHandler) updateGrantedScopes(issuer string, requestedScopes []string) error {
if h.tokenSource == nil {
return nil
}
tok, err := h.tokenSource.Token()
if err != nil {
h.tokenSource = nil
return fmt.Errorf("client credentials token request failed: %w", err)
}
if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil {
h.grantedScopes[issuer] = requestedScopes
} else {
h.grantedScopes[issuer] = tokenScopes
}
return nil
}
// getProtectedResourceMetadata discovers Protected Resource Metadata (RFC 9728)
// from the request URL. This mirrors the logic in AuthorizationCodeHandler.
func getProtectedResourceMetadata(ctx context.Context, wwwChallenges []oauthex.Challenge, mcpServerURL string, httpClient *http.Client) (*oauthex.ProtectedResourceMetadata, error) {
// Use MCP server URL as the resource URI per
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#canonical-server-uri.
for _, u := range protectedResourceMetadataURLs(resourceMetadataURLFromChallenges(wwwChallenges), mcpServerURL) {
prm, err := oauthex.GetProtectedResourceMetadata(ctx, u.url, u.resource, httpClient)
if err != nil {
continue
}
if prm == nil {
continue
}
if len(prm.AuthorizationServers) == 0 {
// If we found PRM, we enforce the 2025-11-25 spec and not search further.
return nil, fmt.Errorf("protected resource metadata has no authorization servers specified")
}
return prm, nil
}
// Fallback to 2025-03-26 spec: MCP server root is the Authorization Server.
// https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery
u, err := url.Parse(mcpServerURL)
if err != nil {
return nil, fmt.Errorf("failed to parse MCP server URL: %v", err)
}
u.Path = ""
return &oauthex.ProtectedResourceMetadata{
AuthorizationServers: []string{u.String()},
Resource: mcpServerURL,
}, nil
}
type prmURL struct {
url string
resource string
}
// protectedResourceMetadataURLs returns URLs to try for PRM discovery.
// This mirrors the logic in AuthorizationCodeHandler.
func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL {
var urls []prmURL
if metadataURL != "" {
urls = append(urls, prmURL{url: metadataURL, resource: resourceURL})
}
ru, err := url.Parse(resourceURL)
if err != nil {
return urls
}
mu := *ru
// At the path of the server's MCP endpoint.
mu.Path = "/.well-known/oauth-protected-resource/" + strings.TrimLeft(ru.Path, "/")
urls = append(urls, prmURL{url: mu.String(), resource: resourceURL})
// At the root.
mu.Path = "/.well-known/oauth-protected-resource"
ru.Path = ""
urls = append(urls, prmURL{url: mu.String(), resource: ru.String()})
return urls
}
// resourceMetadataURLFromChallenges returns a resource metadata URL from
// WWW-Authenticate challenges, or the empty string if there is none.
func resourceMetadataURLFromChallenges(cs []oauthex.Challenge) string {
for _, c := range cs {
if u := c.Params["resource_metadata"]; u != "" {
return u
}
}
return ""
}
// scopesFromChallenges returns scopes from WWW-Authenticate challenges.
// It only looks at challenges with the "Bearer" scheme.
func scopesFromChallenges(cs []oauthex.Challenge) []string {
for _, c := range cs {
if c.Scheme == "bearer" && c.Params["scope"] != "" {
return strings.Fields(c.Params["scope"])
}
}
return nil
}
// selectTokenAuthMethod selects the preferred token endpoint auth method based on
// the authorization server's supported methods. Prefers client_secret_post over
// client_secret_basic per the OAuth 2.1 draft.
func selectTokenAuthMethod(supported []string) oauth2.AuthStyle {
prefOrder := []string{
"client_secret_post",
"client_secret_basic",
}
for _, method := range prefOrder {
if slices.Contains(supported, method) {
switch method {
case "client_secret_post":
return oauth2.AuthStyleInParams
case "client_secret_basic":
return oauth2.AuthStyleInHeader
}
}
}
return oauth2.AuthStyleAutoDetect
}

View file

@ -1,420 +0,0 @@
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by the license
// that can be found in the LICENSE file.
package extauth
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
)
func validClientCredentialsConfig() *ClientCredentialsHandlerConfig {
return &ClientCredentialsHandlerConfig{
Credentials: &oauthex.ClientCredentials{
ClientID: "test-client",
ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test-secret"},
},
}
}
func TestNewClientCredentialsHandler_Validation(t *testing.T) {
tests := []struct {
name string
config *ClientCredentialsHandlerConfig
wantError string
}{
{
name: "nil config",
config: nil,
wantError: "config must be provided",
},
{
name: "nil credentials",
config: func() *ClientCredentialsHandlerConfig {
c := validClientCredentialsConfig()
c.Credentials = nil
return c
}(),
wantError: "credentials are required",
},
{
name: "missing client ID",
config: func() *ClientCredentialsHandlerConfig {
c := validClientCredentialsConfig()
c.Credentials.ClientID = ""
return c
}(),
wantError: "ClientID is required",
},
{
name: "missing client secret auth",
config: func() *ClientCredentialsHandlerConfig {
c := validClientCredentialsConfig()
c.Credentials.ClientSecretAuth = nil
return c
}(),
wantError: "clientSecretAuth is required",
},
{
name: "empty client secret",
config: func() *ClientCredentialsHandlerConfig {
c := validClientCredentialsConfig()
c.Credentials.ClientSecretAuth.ClientSecret = ""
return c
}(),
wantError: "ClientSecret is required",
},
{
name: "valid config",
config: validClientCredentialsConfig(),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := NewClientCredentialsHandler(tc.config)
if tc.wantError != "" {
if err == nil {
t.Errorf("expected error containing %q, got nil", tc.wantError)
} else if !strings.Contains(err.Error(), tc.wantError) {
t.Errorf("error %q does not contain %q", err.Error(), tc.wantError)
}
} else if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestClientCredentialsHandler_Authorize(t *testing.T) {
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{
ServeOAuthInsertedEndpoint: true,
},
RegistrationConfig: &oauthtest.RegistrationConfig{
PreregisteredClients: map[string]oauthtest.ClientInfo{
"test-client": {Secret: "test-secret"},
},
},
ClientCredentialsConfig: &oauthtest.ClientCredentialsConfig{
Enabled: true,
},
})
authServer.Start(t)
resourceMux := http.NewServeMux()
resourceServer := httptest.NewServer(resourceMux)
t.Cleanup(resourceServer.Close)
resourceURL := resourceServer.URL + "/resource"
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
Resource: resourceURL,
AuthorizationServers: []string{authServer.URL()},
ScopesSupported: []string{"mcp:read", "mcp:write"},
}))
t.Run("successful authorization", func(t *testing.T) {
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
if err != nil {
t.Fatal(err)
}
// TokenSource should be nil before Authorize.
ts, err := handler.TokenSource(t.Context())
if err != nil {
t.Fatal(err)
}
if ts != nil {
t.Fatal("expected nil TokenSource before Authorize")
}
// Simulate a 401 response from the MCP server.
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{},
Body: http.NoBody,
}
req := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req, resp); err != nil {
t.Fatalf("Authorize failed: %v", err)
}
// TokenSource should now return a valid token.
ts, err = handler.TokenSource(t.Context())
if err != nil {
t.Fatal(err)
}
if ts == nil {
t.Fatal("expected non-nil TokenSource after Authorize")
}
tok, err := ts.Token()
if err != nil {
t.Fatalf("Token() failed: %v", err)
}
if tok.AccessToken != "test_access_token" {
t.Errorf("got access token %q, want %q", tok.AccessToken, "test_access_token")
}
})
t.Run("bad credentials", func(t *testing.T) {
config := validClientCredentialsConfig()
config.Credentials.ClientSecretAuth.ClientSecret = "wrong-secret"
handler, err := NewClientCredentialsHandler(config)
if err != nil {
t.Fatal(err)
}
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{},
Body: http.NoBody,
}
req := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req, resp); err == nil {
t.Error("expected Authorize to fail with bad credentials")
}
// TokenSource should still be nil after failed Authorize.
ts, err := handler.TokenSource(t.Context())
if err != nil {
t.Fatal(err)
}
if ts != nil {
t.Error("expected nil TokenSource after failed Authorize")
}
})
t.Run("scopes from WWW-Authenticate challenge", func(t *testing.T) {
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
if err != nil {
t.Fatal(err)
}
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{
"WWW-Authenticate": []string{`Bearer scope="read write"`},
},
Body: http.NoBody,
}
req := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req, resp); err != nil {
t.Fatalf("Authorize failed: %v", err)
}
ts, err := handler.TokenSource(t.Context())
if err != nil {
t.Fatal(err)
}
if ts == nil {
t.Fatal("expected non-nil TokenSource")
}
})
t.Run("issuer mismatch", func(t *testing.T) {
config := validClientCredentialsConfig()
config.Credentials.Issuer = "https://other-issuer.example.com"
handler, err := NewClientCredentialsHandler(config)
if err != nil {
t.Fatal(err)
}
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{},
Body: http.NoBody,
}
req := httptest.NewRequest("GET", resourceURL, nil)
err = handler.Authorize(t.Context(), req, resp)
if err == nil {
t.Fatal("expected Authorize to fail with issuer mismatch")
}
if !strings.Contains(err.Error(), "does not match") {
t.Errorf("error %q does not mention issuer mismatch", err.Error())
}
})
t.Run("issuer match ignoring trailing slash", func(t *testing.T) {
config := validClientCredentialsConfig()
// authServer.URL() has no trailing slash; configure with one to
// verify the comparison tolerates the difference (per RFC 8414 §3.3
// normalization applied in oauthex.IssuersEqual).
config.Credentials.Issuer = authServer.URL() + "/"
handler, err := NewClientCredentialsHandler(config)
if err != nil {
t.Fatal(err)
}
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{},
Body: http.NoBody,
}
req := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req, resp); err != nil {
t.Fatalf("Authorize() unexpected error = %v", err)
}
})
t.Run("PRM via resource_metadata in challenge", func(t *testing.T) {
prmMux := http.NewServeMux()
prmMux.Handle("/custom-prm", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
Resource: resourceURL,
AuthorizationServers: []string{authServer.URL()},
}))
prmServer := httptest.NewServer(prmMux)
t.Cleanup(prmServer.Close)
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
if err != nil {
t.Fatal(err)
}
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{
"WWW-Authenticate": []string{`Bearer resource_metadata="` + prmServer.URL + `/custom-prm"`},
},
Body: http.NoBody,
}
req := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req, resp); err != nil {
t.Fatalf("Authorize with resource_metadata challenge failed: %v", err)
}
ts, err := handler.TokenSource(t.Context())
if err != nil {
t.Fatal(err)
}
if ts == nil {
t.Fatal("expected non-nil TokenSource")
}
})
}
func TestClientCredentialsHandler_ScopeAccumulation(t *testing.T) {
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{
ServeOAuthInsertedEndpoint: true,
},
RegistrationConfig: &oauthtest.RegistrationConfig{
PreregisteredClients: map[string]oauthtest.ClientInfo{
"test-client": {Secret: "test-secret"},
},
},
ClientCredentialsConfig: &oauthtest.ClientCredentialsConfig{
Enabled: true,
},
TokenScopeFunc: func(requestedScope string) string {
// Simulate a server that never grants "write".
var granted []string
for _, s := range strings.Fields(requestedScope) {
if s != "write" {
granted = append(granted, s)
}
}
return strings.Join(granted, " ")
},
})
authServer.Start(t)
resourceMux := http.NewServeMux()
resourceServer := httptest.NewServer(resourceMux)
t.Cleanup(resourceServer.Close)
resourceURL := resourceServer.URL + "/resource"
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
Resource: resourceURL,
AuthorizationServers: []string{authServer.URL()},
}))
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
if err != nil {
t.Fatal(err)
}
// First authorization: 401 with scope="read write".
// The token response will only grant "read" (TokenScopeFunc strips "write").
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: make(http.Header),
Body: http.NoBody,
}
resp.Header.Set("WWW-Authenticate", `Bearer scope="read write"`)
req := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req, resp); err != nil {
t.Fatalf("First Authorize failed: %v", err)
}
// Verify only "read" was granted (the token omitted "write").
issuer := authServer.URL()
if diff := cmp.Diff([]string{"read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
t.Errorf("After first Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
}
// Second authorization: 401 with scope="admin" (simulating step-up).
// Accumulated scopes should be "read" (previously granted) + "admin" (new),
// but NOT "write" (requested but never granted).
resp2 := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: make(http.Header),
Body: http.NoBody,
}
resp2.Header.Set("WWW-Authenticate", `Bearer scope="admin"`)
req2 := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req2, resp2); err != nil {
t.Fatalf("Second Authorize failed: %v", err)
}
// Verify accumulated scopes: "read" (granted) + "admin" (new), no "write".
if diff := cmp.Diff([]string{"admin", "read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
t.Errorf("After second Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
}
}
func TestSelectTokenAuthMethod(t *testing.T) {
tests := []struct {
name string
supported []string
want oauth2.AuthStyle
}{
{
name: "prefers client_secret_post",
supported: []string{"client_secret_basic", "client_secret_post"},
want: oauth2.AuthStyleInParams,
},
{
name: "falls back to client_secret_basic",
supported: []string{"client_secret_basic"},
want: oauth2.AuthStyleInHeader,
},
{
name: "auto detect when none supported",
supported: []string{"private_key_jwt"},
want: oauth2.AuthStyleAutoDetect,
},
{
name: "auto detect when empty",
supported: nil,
want: oauth2.AuthStyleAutoDetect,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := selectTokenAuthMethod(tc.supported)
if got != tc.want {
t.Errorf("selectTokenAuthMethod(%v) = %v, want %v", tc.supported, got, tc.want)
}
})
}
}

Some files were not shown because too many files have changed in this diff Show more