go-sdk/conformance/everything-client/main.go
Maciej Kisiel c9317fb5b7
all: client side OAuth support (#785)
This PR introduces client-side OAuth support.

# Overall design philosophy

The main new interface introduced to support client-side OAuth is the
`OAuthHandler`. Contrary to its counter-parts in some other SDKs, it is
intentionally small and generic so that it can support most of the OAuth
authorization flow variants. The main `mcp` package purposefully
delegates almost all auth handling to `OAuthHandler` implementations.
This way, the `mcp` package continues to be largely OAuth agnostic.

The `auth` package comes with a default `OAuthHandler` implementation –
`AuthorizationCodeHandler`, which implements the authorization flow
for the `authorization_code` grant type, as defined in the [MCP
specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
It is expected that the `OAuthHandler` implementation granularity will
be roughly per grant type, as this parameter influences the
authorization flow the most.

# Detailed design

## `OAuthHandler` (auth/client.go)

The main interface for the integration consists of two methods:

```go
type OAuthHandler interface {
	// 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.
	TokenSource(context.Context) (oauth2.TokenSource, error)

	// Authorize is called when an HTTP request results in an error that may
	// be addressed by the authorization flow (currently 401 Unauthorized and 403 Forbidden).
	// It is responsible for performing the OAuth flow to obtain an access token.
	// The arguments are the request that failed and the response that was received for it.
	// The headers of the request are available, but the body will have already been consumed
	// when Authorize is called.
	// If the returned error is nil, TokenSource is expected to return a non-nil token source.
	// After a successful call to Authorize, the HTTP request will be retried by the transport.
	// The function is responsible for closing the response body.
	Authorize(context.Context, *http.Request, *http.Response) error
}
```

`StreamableClientTransport` accepts this interface as a field and uses
the `TokenSource` method to add an `Authorization: Bearer` header to
outgoing requests to the MCP server. If the request fails with `401:
Unauthorized` or `403: Forbidden`, the transport calls `Authorize` to
perform the authorization flow. If `Authorize` returns a non-nil error,
the request is retried once. `Authorize` errors do not result in
termination of the client session (unless they happen during
connection initialization), as some OAuth flows are multi-legged and it should be
acceptable to retry for example when the authorization grant was
received.

*Note:* There was already an `OAuthHandler` type defined as part of a
previous iteration of OAuth support. It was renamed to
`OAuthHandlerLegacy`. This is the only backwards incompatible API change
in the experimental client `auth` part (protected by the
`mcp_go_client_oauth` Go build tag). The cost of replacing previous
usages is deemed worthwhile to maintain clear naming, under the
assumption that the experimental client `auth` part was not widely used.

## `AuthorizationCodeHandler` (auth/authorization_code.go)

This is the `OAuthHandler` implementation that fulfills the [MCP
specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
In particular, in supports:

- Protected Resource Metadata discovery and fetching
- Authorization Server Metadata discovery and fetching
- Client registration
  - OpenID Connect metadata document
  - Pre-registration
  - Dynamic Client Registration
- PKCE
- `client_secret_post`, `client_secret_basic` token endpoint
auth methods
- Automatic refresh of tokens

### Authorization Server redirect

As part of the flow for the `authorization_code` grant type, the client
needs to point the user to the authorization URL, so that they confirm
the access request. This design proposes that it will be supported via a
pluggable dependency that will initiate this process in an
application-specific way:

```go
type AuthorizationCodeHandler struct {
	...

	// AuthorizationCodeFetcher is a required function called to initiate the authorization flow.
	// 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)

	...
}
```

The function is supposed perform any application specific logic to handle the redirect
from the Authorization Server and return the resulting code and state.

## `oauthex` changes

Some additional/adjusted building blocks that may be useful when
creating `OAuthHandler` implementations are present in the `oauthex`
package:

- `auth_meta.go`: Authorization Server Metadata utilities:
  - `GetAuthServerMeta`: get Authorization Server Metadata from a URL
- `dcr.go`: Dynamic Client Registration utilities (unchanged)
- `resource_meta.go`: Protected Resource Metadata utilities:
- `GetProtectedResourceMetadata`: get Protected Resource Metadata from a
URL
  - `ParseWWWAuthenticate` – as per name (unchanged)
- [deprecated] `GetProtectedResourceMetadataFromID` and
`GetProtectedResourceMetadataFromHeader` in favor of more generic
`GetProtectedResourceMetadata`. They will be kept under the Go build tag
and removed one version of the SDK in the future.
2026-02-27 15:30:13 +01:00

264 lines
7 KiB
Go

// 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.
// The conformance client implements features required for MCP conformance testing.
// It mirrors the functionality of the TypeScript conformance client at
// https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/conformance/everything-client.ts
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"slices"
"sort"
"strings"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// scenarioHandler is the function signature for all conformance test scenarios.
// It takes a context and the server URL to connect to.
type scenarioHandler func(ctx context.Context, serverURL string, configCtx map[string]any) error
var (
// registry stores all registered scenario handlers.
registry = make(map[string]scenarioHandler)
)
// registerScenario registers a new scenario handler with the given name.
// This function should be called during init() by scenario implementations.
func registerScenario(name string, handler scenarioHandler) {
if _, exists := registry[name]; exists {
log.Fatalf("Scenario %q is already registered", name)
}
registry[name] = handler
}
func init() {
registerScenario("initialize", runBasicClient)
registerScenario("tools_call", runToolsCallClient)
registerScenario("elicitation-sep1034-client-defaults", runElicitationDefaultsClient)
registerScenario("sse-retry", runSSERetryClient)
}
// ============================================================================
// Basic scenarios
// ============================================================================
func runBasicClient(ctx context.Context, serverURL string, _ map[string]any) error {
session, err := connectToServer(ctx, serverURL)
if err != nil {
return err
}
defer session.Close()
_, err = session.ListTools(ctx, nil)
if err != nil {
return fmt.Errorf("session.ListTools(): %v", err)
}
return nil
}
func runToolsCallClient(ctx context.Context, serverURL string, _ map[string]any) error {
session, err := connectToServer(ctx, serverURL)
if err != nil {
return err
}
defer session.Close()
tools, err := session.ListTools(ctx, nil)
if err != nil {
return fmt.Errorf("session.ListTools(): %v", err)
}
idx := slices.IndexFunc(tools.Tools, func(t *mcp.Tool) bool {
return t.Name == "add_numbers"
})
if idx == -1 {
return fmt.Errorf("tool %q not found", "add_numbers")
}
_, err = session.CallTool(ctx, &mcp.CallToolParams{
Name: "add_numbers",
Arguments: map[string]any{"a": 5, "b": 3},
})
if err != nil {
return fmt.Errorf("session.CallTool('add_numbers'): %v", err)
}
return nil
}
// ============================================================================
// Elicitation scenarios
// ============================================================================
func runElicitationDefaultsClient(ctx context.Context, serverURL string, _ map[string]any) error {
elicitationHandler := func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
return &mcp.ElicitResult{
Action: "accept",
Content: map[string]any{},
}, nil
}
session, err := connectToServer(ctx, serverURL, withClientOptions(&mcp.ClientOptions{
ElicitationHandler: elicitationHandler,
}))
if err != nil {
return err
}
defer session.Close()
tools, err := session.ListTools(ctx, nil)
if err != nil {
return fmt.Errorf("session.ListTools(): %v", err)
}
var testToolName = "test_client_elicitation_defaults"
idx := slices.IndexFunc(tools.Tools, func(t *mcp.Tool) bool {
return t.Name == testToolName
})
if idx == -1 {
return fmt.Errorf("tool %q not found", testToolName)
}
_, err = session.CallTool(ctx, &mcp.CallToolParams{
Name: testToolName,
Arguments: map[string]any{},
})
if err != nil {
return fmt.Errorf("session.CallTool(%q): %v", testToolName, err)
}
return nil
}
// ============================================================================
// SSE retry scenario
// ============================================================================
func runSSERetryClient(ctx context.Context, serverURL string, _ map[string]any) error {
session, err := connectToServer(ctx, serverURL)
if err != nil {
return err
}
defer session.Close()
log.Printf("Connected to server %q", serverURL)
tools, err := session.ListTools(ctx, nil)
if err != nil {
return fmt.Errorf("session.ListTools(): %v", err)
}
var testToolName = "test_reconnection"
idx := slices.IndexFunc(tools.Tools, func(t *mcp.Tool) bool {
return t.Name == testToolName
})
if idx == -1 {
return fmt.Errorf("tool %q not found", testToolName)
}
_, err = session.CallTool(ctx, &mcp.CallToolParams{
Name: testToolName,
Arguments: map[string]any{},
})
if err != nil {
return fmt.Errorf("session.CallTool(%q): %v", testToolName, err)
}
return nil
}
// ============================================================================
// Main entry point
// ============================================================================
func main() {
if len(os.Args) != 2 {
printUsageAndExit("Usage: %s <server-url>", os.Args[0])
}
serverURL := os.Args[1]
scenarioName := os.Getenv("MCP_CONFORMANCE_SCENARIO")
configCtx := getConformanceContext()
if scenarioName == "" {
printUsageAndExit("MCP_CONFORMANCE_SCENARIO not set")
}
handler, ok := registry[scenarioName]
if !ok {
printUsageAndExit("Unknown scenario: %q", scenarioName)
}
ctx := context.Background()
if err := handler(ctx, serverURL, configCtx); err != nil {
log.Fatalf("Scenario %q failed: %v", scenarioName, err)
}
}
func getConformanceContext() map[string]any {
ctxStr := os.Getenv("MCP_CONFORMANCE_CONTEXT")
if ctxStr == "" {
return nil
}
var ctx map[string]any
_ = json.Unmarshal([]byte(ctxStr), &ctx)
return ctx
}
func printUsageAndExit(format string, args ...any) {
var scenarios []string
for name := range registry {
scenarios = append(scenarios, name)
}
sort.Strings(scenarios)
msg := fmt.Sprintf(format, args...)
log.Fatalf("%s\nAvailable scenarios:\n - %s", msg, strings.Join(scenarios, "\n - "))
}
type connectConfig struct {
clientOptions *mcp.ClientOptions
oauthHandler auth.OAuthHandler
}
type connectOption func(*connectConfig)
func withClientOptions(opts *mcp.ClientOptions) connectOption {
return func(c *connectConfig) {
c.clientOptions = opts
}
}
// connectToServer connects to the MCP server and returns a client session.
// The caller is responsible for closing the session.
func connectToServer(ctx context.Context, serverURL string, opts ...connectOption) (*mcp.ClientSession, error) {
config := &connectConfig{}
for _, opt := range opts {
opt(config)
}
client := mcp.NewClient(&mcp.Implementation{
Name: "test-client",
Version: "1.0.0",
}, config.clientOptions)
transport := &mcp.StreamableClientTransport{
Endpoint: serverURL,
OAuthHandler: config.oauthHandler,
}
session, err := client.Connect(ctx, transport, nil)
if err != nil {
return nil, fmt.Errorf("client.Connect(): %w", err)
}
return session, nil
}