Repository: modelcontextprotocol/go-sdk

Issue:
server-side logging
Add logging to the server to enhance debuggability.

Thread a slog.Logger through server-side structures and log important actions and errors.

Hints from issue comments:
@KamdynS: Can I open a PR for this issue? I have code locally passing tests but wanted to ask first since there is no help-wanted tag@findleyr: I defer to @jba, but that seems reasonable to me.@findleyr: @jba what's left on this? Can we merge the associated PR?@jba: The author never addressed my comments, so it remains in limbo.@findleyr: Does this need to be a release blocker?@jba: I think I was wondering if we needed to make a breaking change to add this. I don't think we do. Removing the label.

Solution patch:
diff --git a/README.md b/README.md
index a12a026..81af0b0 100644
--- a/README.md
+++ b/README.md
@@ -1,25 +1,13 @@
 <!-- Autogenerated by weave; DO NOT EDIT -->
-# MCP Go SDK v0.6.0
+# MCP Go SDK
 
 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/modelcontextprotocol/go-sdk)
 
-***BREAKING CHANGES***
-
-This version contains minor breaking changes.
-See the [release notes](
-https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.6.0) for details.
-
 [![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk)
 
 This repository contains an implementation of the official Go software
 development kit (SDK) for the Model Context Protocol (MCP).
 
-> [!IMPORTANT]
-> The SDK is in release-candidate state, and is going to be tagged v1.0.0
-> soon (see https://github.com/modelcontextprotocol/go-sdk/issues/328).
-> We do not anticipate significant API changes or instability. Please use it
-> and [file issues](https://github.com/modelcontextprotocol/go-sdk/issues/new/choose).
-
 ## Package / Feature documentation
 
 The SDK consists of several importable packages:
@@ -33,7 +21,7 @@ The SDK consists of several importable packages:
   their own transports.
 - The
   [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth)
-  package provides some primitives for supporting oauth.
+  package provides some primitives for supporting OAuth.
 - The
   [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex)
   package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata.
@@ -78,7 +66,7 @@ func main() {
 	// Create a server with a single tool.
 	server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v1.0.0"}, nil)
 	mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi)
-	// Run the server over stdin/stdout, until the client disconnects
+	// Run the server over stdin/stdout, until the client disconnects.
 	if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
 		log.Fatal(err)
 	}
@@ -106,7 +94,7 @@ func main() {
 	// Create a new client, with no features.
 	client := mcp.NewClient(&mcp.Implementation{Name: "mcp-client", Version: "v1.0.0"}, nil)
 
-	// Connect to a server over stdin/stdout
+	// Connect to a server over stdin/stdout.
 	transport := &mcp.CommandTransport{Command: exec.Command("myserver")}
 	session, err := client.Connect(ctx, transport, nil)
 	if err != nil {
diff --git a/auth/auth.go b/auth/auth.go
index 7cc0074..0eea1d8 100644
--- a/auth/auth.go
+++ b/auth/auth.go
@@ -56,8 +56,6 @@ func TokenInfoFromContext(ctx context.Context) *TokenInfo {
 // If verification succeeds, the [TokenInfo] is added to the request's context and the request proceeds.
 // If verification fails, the request fails with a 401 Unauthenticated, and the WWW-Authenticate header
 // is populated to enable [protected resource metadata].
-//
-
 //
 // [protected resource metadata]: https://datatracker.ietf.org/doc/rfc9728
 func RequireBearerToken(verifier TokenVerifier, opts *RequireBearerTokenOptions) func(http.Handler) http.Handler {
diff --git a/auth/client.go b/auth/client.go
new file mode 100644
index 0000000..2dda135
--- /dev/null
+++ b/auth/client.go
@@ -0,0 +1,109 @@
+// 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 (
+	"context"
+	"errors"
+	"net/http"
+	"sync"
+
+	"github.com/modelcontextprotocol/go-sdk/internal/oauthex"
+	"golang.org/x/oauth2"
+)
+
+// An OAuthHandler conducts an OAuth flow and returns a [oauth2.TokenSource] if the authorization
+// is approved, or an error if not.
+type OAuthHandler func(context.Context, OAuthHandlerArgs) (oauth2.TokenSource, error)
+
+// OAuthHandlerArgs are arguments to an [OAuthHandler].
+type OAuthHandlerArgs struct {
+	// The URL to fetch protected resource metadata, extracted from the WWW-Authenticate header.
+	// Empty if not present or there was an error obtaining it.
+	ResourceMetadataURL string
+}
+
+// HTTPTransport is an [http.RoundTripper] that follows the MCP
+// OAuth protocol when it encounters a 401 Unauthorized response.
+type HTTPTransport struct {
+	handler OAuthHandler
+	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.
+func NewHTTPTransport(handler OAuthHandler, 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].
+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()
+
+	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 {
+		authHeaders := resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")]
+		ts, err := t.handler(req.Context(), OAuthHandlerArgs{
+			ResourceMetadataURL: extractResourceMetadataURL(authHeaders),
+		})
+		if err != nil {
+			return nil, err
+		}
+		t.opts.Base = &oauth2.Transport{Base: t.opts.Base, Source: ts}
+	}
+	return t.opts.Base.RoundTrip(req.Clone(req.Context()))
+}
+
+func extractResourceMetadataURL(authHeaders []string) string {
+	cs, err := oauthex.ParseWWWAuthenticate(authHeaders)
+	if err != nil {
+		return ""
+	}
+	return oauthex.ResourceMetadataURL(cs)
+}
diff --git a/docs/README.md b/docs/README.md
index b4268c8..9f32507 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -15,21 +15,21 @@ protocol.
 1. [Authorization](protocol.md#authorization)
 1. [Security](protocol.md#security)
 1. [Utilities](protocol.md#utilities)
-    1. [Cancellation](utilities.md#cancellation)
-    1. [Ping](utilities.md#ping)
-    1. [Progress](utilities.md#progress)
+    1. [Cancellation](protocol.md#cancellation)
+    1. [Ping](protocol.md#ping)
+    1. [Progress](protocol.md#progress)
 
 ## Client Features
 
 1. [Roots](client.md#roots)
 1. [Sampling](client.md#sampling)
-1. [Elicitation](clients.md#elicitation)
+1. [Elicitation](client.md#elicitation)
 
 ## Server Features
 
 1. [Prompts](server.md#prompts)
 1. [Resources](server.md#resources)
-1. [Tools](tools.md)
+1. [Tools](server.md#tools)
 1. [Utilities](server.md#utilities)
     1. [Completion](server.md#completion)
     1. [Logging](server.md#logging)
diff --git a/docs/client.md b/docs/client.md
index 2cbe082..8fc7544 100644
--- a/docs/client.md
+++ b/docs/client.md
@@ -145,7 +145,7 @@ func Example_elicitation() {
 	}
 	defer ss.Close()
 
-	c := mcp.NewClient(testImpl, &mcp.ClientOptions{
+	c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{
 		ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
 			return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil
 		},
diff --git a/docs/protocol.md b/docs/protocol.md
index c87e522..3a031af 100644
--- a/docs/protocol.md
+++ b/docs/protocol.md
@@ -128,8 +128,8 @@ which starts the a `exec.Cmd` as a subprocess and communicates over its
 stdin/stdout.
 
 **Server-side**: the server side of the `stdio` transport is implemented by
-`StdioTransport`, which connects over the current processes `os.Stdin` and
-`os.Stdout`.
+[`StdioTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#StdioTransport),
+which connects over the current processes `os.Stdin` and `os.Stdout`.
 
 ### Streamable Transport
 
@@ -201,7 +201,7 @@ to see the logical session
 > Stateless mode is not directly discussed in the spec, and is still being
 > defined. See modelcontextprotocol/modelcontextprotocol#1364,
 > modelcontextprotocol/modelcontextprotocol#1372, or
-> modelcontextprotocol/modelcontextprotocol#11442 for potential refinements.
+> modelcontextprotocol/modelcontextprotocol#1442 for potential refinements.
 
 _See [examples/server/distributed](../examples/server/distributed/main.go) for
 an example using statless mode to implement a server distributed across
@@ -267,7 +267,7 @@ The  [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/
 
 Client-side OAuth is implemented by setting  
 [`StreamableClientTransport.HTTPClient`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk@v0.5.0/mcp#StreamableClientTransport.HTTPClient) to a custom [`http.Client`](https://pkg.go.dev/net/http#Client)
-Additional support is forthcoming; see #493.
+Additional support is forthcoming; see modelcontextprotocol/go-sdk#493.
 
 ## Security
 
diff --git a/docs/server.md b/docs/server.md
index d6656ee..f986c68 100644
--- a/docs/server.md
+++ b/docs/server.md
@@ -364,9 +364,9 @@ the `Tool.InputSchema` explicitly:
 
 ```go
 // Distinguished Go types allow custom schemas to be reused during inference.
-customSchemas := map[any]*jsonschema.Schema{
-	Probability(0):  {Type: "number", Minimum: jsonschema.Ptr(0.0), Maximum: jsonschema.Ptr(1.0)},
-	WeatherType(""): {Type: "string", Enum: []any{Sunny, PartlyCloudy, Cloudy, Rainy, Snowy}},
+customSchemas := map[reflect.Type]*jsonschema.Schema{
+	reflect.TypeFor[Probability](): {Type: "number", Minimum: jsonschema.Ptr(0.0), Maximum: jsonschema.Ptr(1.0)},
+	reflect.TypeFor[WeatherType](): {Type: "string", Enum: []any{Sunny, PartlyCloudy, Cloudy, Rainy, Snowy}},
 }
 opts := &jsonschema.ForOptions{TypeSchemas: customSchemas}
 in, err := jsonschema.For[WeatherInput](opts)
diff --git a/examples/client/listfeatures/main.go b/examples/client/listfeatures/main.go
index 9d473f0..044b5e9 100644
--- a/examples/client/listfeatures/main.go
+++ b/examples/client/listfeatures/main.go
@@ -27,30 +27,51 @@ import (
 	"github.com/modelcontextprotocol/go-sdk/mcp"
 )
 
+var (
+	endpoint = flag.String("http", "", "if set, connect to this streamable endpoint rather than running a stdio server")
+)
+
 func main() {
 	flag.Parse()
 	args := flag.Args()
-	if len(args) == 0 {
+	if len(args) == 0 && *endpoint == "" {
 		fmt.Fprintln(os.Stderr, "Usage: listfeatures <command> [<args>]")
+		fmt.Fprintln(os.Stderr, "Usage: listfeatures --http=\"https://example.com/server/mcp\"")
 		fmt.Fprintln(os.Stderr, "List all features for a stdio MCP server")
 		fmt.Fprintln(os.Stderr)
 		fmt.Fprintln(os.Stderr, "Example:\n\tlistfeatures npx @modelcontextprotocol/server-everything")
 		os.Exit(2)
 	}
 
-	ctx := context.Background()
-	cmd := exec.Command(args[0], args[1:]...)
+	var (
+		ctx       = context.Background()
+		transport mcp.Transport
+	)
+	if *endpoint != "" {
+		transport = &mcp.StreamableClientTransport{
+			Endpoint: *endpoint,
+		}
+	} else {
+		cmd := exec.Command(args[0], args[1:]...)
+		transport = &mcp.CommandTransport{Command: cmd}
+	}
 	client := mcp.NewClient(&mcp.Implementation{Name: "mcp-client", Version: "v1.0.0"}, nil)
-	cs, err := client.Connect(ctx, &mcp.CommandTransport{Command: cmd}, nil)
+	cs, err := client.Connect(ctx, transport, nil)
 	if err != nil {
 		log.Fatal(err)
 	}
 	defer cs.Close()
 
-	printSection("tools", cs.Tools(ctx, nil), func(t *mcp.Tool) string { return t.Name })
-	printSection("resources", cs.Resources(ctx, nil), func(r *mcp.Resource) string { return r.Name })
-	printSection("resource templates", cs.ResourceTemplates(ctx, nil), func(r *mcp.ResourceTemplate) string { return r.Name })
-	printSection("prompts", cs.Prompts(ctx, nil), func(p *mcp.Prompt) string { return p.Name })
+	if cs.InitializeResult().Capabilities.Tools != nil {
+		printSection("tools", cs.Tools(ctx, nil), func(t *mcp.Tool) string { return t.Name })
+	}
+	if cs.InitializeResult().Capabilities.Resources != nil {
+		printSection("resources", cs.Resources(ctx, nil), func(r *mcp.Resource) string { return r.Name })
+		printSection("resource templates", cs.ResourceTemplates(ctx, nil), func(r *mcp.ResourceTemplate) string { return r.Name })
+	}
+	if cs.InitializeResult().Capabilities.Prompts != nil {
+		printSection("prompts", cs.Prompts(ctx, nil), func(p *mcp.Prompt) string { return p.Name })
+	}
 }
 
 func printSection[T any](name string, features iter.Seq2[T, error], featName func(T) string) {
diff --git a/examples/server/auth-middleware/go.mod b/examples/server/auth-middleware/go.mod
index f1ca77f..cbc89c7 100644
--- a/examples/server/auth-middleware/go.mod
+++ b/examples/server/auth-middleware/go.mod
@@ -8,7 +8,7 @@ require (
 )
 
 require (
-	github.com/google/jsonschema-go v0.2.3 // indirect
+	github.com/google/jsonschema-go v0.3.0 // indirect
 	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
 )
 
diff --git a/examples/server/auth-middleware/go.sum b/examples/server/auth-middleware/go.sum
index 6a39263..7b7a8e5 100644
--- a/examples/server/auth-middleware/go.sum
+++ b/examples/server/auth-middleware/go.sum
@@ -2,8 +2,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD
 github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
-github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM=
-github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
+github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
+github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
 golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
diff --git a/examples/server/rate-limiting/go.mod b/examples/server/rate-limiting/go.mod
index 91d3d26..adf535b 100644
--- a/examples/server/rate-limiting/go.mod
+++ b/examples/server/rate-limiting/go.mod
@@ -8,7 +8,7 @@ require (
 )
 
 require (
-	github.com/google/jsonschema-go v0.2.3 // indirect
+	github.com/google/jsonschema-go v0.3.0 // indirect
 	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
 )
 
diff --git a/examples/server/rate-limiting/go.sum b/examples/server/rate-limiting/go.sum
index da49fd1..92c2739 100644
--- a/examples/server/rate-limiting/go.sum
+++ b/examples/server/rate-limiting/go.sum
@@ -1,7 +1,7 @@
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
-github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM=
-github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
+github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
+github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
diff --git a/examples/server/toolschemas/main.go b/examples/server/toolschemas/main.go
index 36ee049..abc1e3f 100644
--- a/examples/server/toolschemas/main.go
+++ b/examples/server/toolschemas/main.go
@@ -54,63 +54,86 @@ func (t *manualGreeter) greet(_ context.Context, req *mcp.CallToolRequest) (*mcp
 	// Handle the parsing and validation of input and output.
 	//
 	// Note that errors here are treated as tool errors, not protocol errors.
+
+	// First, unmarshal to a map[string]any and validate.
+	if err := unmarshalAndValidate(req.Params.Arguments, t.inputSchema); err != nil {
+		return errf("invalid input: %v", err), nil
+	}
+
+	// Now unmarshal again to input.
 	var input Input
 	if err := json.Unmarshal(req.Params.Arguments, &input); err != nil {
 		return errf("failed to unmarshal arguments: %v", err), nil
 	}
-	if err := t.inputSchema.Validate(input); err != nil {
-		return errf("invalid input: %v", err), nil
-	}
 	output := Output{Greeting: "Hi " + input.Name}
-	if err := t.outputSchema.Validate(output); err != nil {
-		return errf("tool produced invalid output: %v", err), nil
-	}
 	outputJSON, err := json.Marshal(output)
 	if err != nil {
 		return errf("output failed to marshal: %v", err), nil
 	}
+	//
+	if err := unmarshalAndValidate(outputJSON, t.outputSchema); err != nil {
+		return errf("invalid output: %v", err), nil
+	}
+
 	return &mcp.CallToolResult{
 		Content:           []mcp.Content{&mcp.TextContent{Text: string(outputJSON)}},
 		StructuredContent: output,
 	}, nil
 }
 
-func main() {
-	server := mcp.NewServer(&mcp.Implementation{Name: "greeter"}, nil)
-
-	// Add the 'greeting' tool in a few different ways.
-
-	// First, we can just use [mcp.AddTool], and get the out-of-the-box handling
-	// it provides:
-	mcp.AddTool(server, &mcp.Tool{Name: "simple greeting"}, simpleGreeting)
+// unmarshalAndValidate unmarshals data to a map[string]any, then validates that against res.
+func unmarshalAndValidate(data []byte, res *jsonschema.Resolved) error {
+	var m map[string]any
+	if err := json.Unmarshal(data, &m); err != nil {
+		return err
+	}
+	return res.Validate(m)
+}
 
-	// Next, we can create our schemas entirely manually, and add them using
-	// [mcp.Server.AddTool]. Since we're working manually, we can add some
-	// constraints on the length of the name.
-	//
-	// We don't need to do all this work: below, we use jsonschema.For to start
-	// from the default schema.
-	var (
-		manual manualGreeter
-		err    error
-	)
-	inputSchema := &jsonschema.Schema{
+var (
+	inputSchema = &jsonschema.Schema{
 		Type: "object",
 		Properties: map[string]*jsonschema.Schema{
 			"name": {Type: "string", MaxLength: jsonschema.Ptr(10)},
 		},
 	}
-	manual.inputSchema, err = inputSchema.Resolve(nil)
-	if err != nil {
-		log.Fatal(err)
-	}
-	outputSchema := &jsonschema.Schema{
+	outputSchema = &jsonschema.Schema{
 		Type: "object",
 		Properties: map[string]*jsonschema.Schema{
 			"greeting": {Type: "string"},
 		},
 	}
-	manual.outputSchema, err = outputSchema.Resolve(nil)
+)
+
+func newManualGreeter() (*manualGreeter, error) {
+	resIn, err := inputSchema.Resolve(nil)
+	if err != nil {
+		return nil, err
+	}
+	resOut, err := outputSchema.Resolve(nil)
+	if err != nil {
+		return nil, err
+	}
+	return &manualGreeter{
+		inputSchema:  resIn,
+		outputSchema: resOut,
+	}, nil
+}
+
+func main() {
+	server := mcp.NewServer(&mcp.Implementation{Name: "greeter"}, nil)
+
+	// Add the 'greeting' tool in a few different ways.
+
+	// First, we can just use [mcp.AddTool], and get the out-of-the-box handling
+	// it provides for schema inference, validation, parsing, and packing the
+	// result.
+	mcp.AddTool(server, &mcp.Tool{Name: "simple greeting"}, simpleGreeting)
+
+	// Alternatively, we can create our schemas entirely manually, and add them
+	// using [mcp.Server.AddTool]. Since we're using the 'raw' API, we have to do
+	// the parsing and validation ourselves
+	manual, err := newManualGreeter()
 	if err != nil {
 		log.Fatal(err)
 	}
@@ -120,6 +143,22 @@ func main() {
 		OutputSchema: outputSchema,
 	}, manual.greet)
 
+	// We can even use raw schema values. In this case, note that we're not
+	// validating the input at all.
+	server.AddTool(&mcp.Tool{
+		Name:        "unvalidated greeting",
+		InputSchema: json.RawMessage(`{"type":"object","properties":{"user":{"type":"string"}}}`),
+	}, func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+		// Note: no validation!
+		var args struct{ User string }
+		if err := json.Unmarshal(req.Params.Arguments, &args); err != nil {
+			return nil, err
+		}
+		return &mcp.CallToolResult{
+			Content: []mcp.Content{&mcp.TextContent{Text: "Hi " + args.User}},
+		}, nil
+	})
+
 	// Finally, note that we can also use custom schemas with a ToolHandlerFor.
 	// We can do this in two ways: by using one of the schema values constructed
 	// above, or by using jsonschema.For and adjusting the resulting schema.
diff --git a/go.mod b/go.mod
index f5c578c..d5917a1 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,8 @@ go 1.23.0
 require (
 	github.com/golang-jwt/jwt/v5 v5.2.2
 	github.com/google/go-cmp v0.7.0
-	github.com/google/jsonschema-go v0.2.3
+	github.com/google/jsonschema-go v0.3.0
 	github.com/yosida95/uritemplate/v3 v3.0.2
+	golang.org/x/oauth2 v0.30.0
 	golang.org/x/tools v0.34.0
 )
diff --git a/go.sum b/go.sum
index 6a39263..32ceedf 100644
--- a/go.sum
+++ b/go.sum
@@ -2,9 +2,11 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD
 github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
-github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM=
-github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
+github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
+github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
+golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
+golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
 golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
 golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
diff --git a/internal/docs/README.src.md b/internal/docs/README.src.md
index b252f94..7f7b224 100644
--- a/internal/docs/README.src.md
+++ b/internal/docs/README.src.md
@@ -14,21 +14,21 @@ protocol.
 1. [Authorization](protocol.md#authorization)
 1. [Security](protocol.md#security)
 1. [Utilities](protocol.md#utilities)
-    1. [Cancellation](utilities.md#cancellation)
-    1. [Ping](utilities.md#ping)
-    1. [Progress](utilities.md#progress)
+    1. [Cancellation](protocol.md#cancellation)
+    1. [Ping](protocol.md#ping)
+    1. [Progress](protocol.md#progress)
 
 ## Client Features
 
 1. [Roots](client.md#roots)
 1. [Sampling](client.md#sampling)
-1. [Elicitation](clients.md#elicitation)
+1. [Elicitation](client.md#elicitation)
 
 ## Server Features
 
 1. [Prompts](server.md#prompts)
 1. [Resources](server.md#resources)
-1. [Tools](tools.md)
+1. [Tools](server.md#tools)
 1. [Utilities](server.md#utilities)
     1. [Completion](server.md#completion)
     1. [Logging](server.md#logging)
diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md
index 88c023c..e85374a 100644
--- a/internal/docs/protocol.src.md
+++ b/internal/docs/protocol.src.md
@@ -74,8 +74,8 @@ which starts the a `exec.Cmd` as a subprocess and communicates over its
 stdin/stdout.
 
 **Server-side**: the server side of the `stdio` transport is implemented by
-`StdioTransport`, which connects over the current processes `os.Stdin` and
-`os.Stdout`.
+[`StdioTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#StdioTransport),
+which connects over the current processes `os.Stdin` and `os.Stdout`.
 
 ### Streamable Transport
 
@@ -128,7 +128,7 @@ to see the logical session
 > Stateless mode is not directly discussed in the spec, and is still being
 > defined. See modelcontextprotocol/modelcontextprotocol#1364,
 > modelcontextprotocol/modelcontextprotocol#1372, or
-> modelcontextprotocol/modelcontextprotocol#11442 for potential refinements.
+> modelcontextprotocol/modelcontextprotocol#1442 for potential refinements.
 
 _See [examples/server/distributed](../examples/server/distributed/main.go) for
 an example using statless mode to implement a server distributed across
@@ -194,7 +194,7 @@ The  [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/
 
 Client-side OAuth is implemented by setting  
 [`StreamableClientTransport.HTTPClient`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk@v0.5.0/mcp#StreamableClientTransport.HTTPClient) to a custom [`http.Client`](https://pkg.go.dev/net/http#Client)
-Additional support is forthcoming; see #493.
+Additional support is forthcoming; see modelcontextprotocol/go-sdk#493.
 
 ## Security
 
diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go
index 9c0d5d6..791e698 100644
--- a/internal/jsonrpc2/messages.go
+++ b/internal/jsonrpc2/messages.go
@@ -16,8 +16,8 @@ type ID struct {
 	value any
 }
 
-// MakeID coerces the given Go value to an ID. The value is assumed to be the
-// default JSON marshaling of a Request identifier -- nil, float64, or string.
+// MakeID coerces the given Go value to an ID. The value should be the
+// default JSON marshaling of a Request identifier: nil, float64, or string.
 //
 // Returns an error if the value type was not a valid Request ID type.
 //
diff --git a/internal/oauthex/auth_meta.go b/internal/oauthex/auth_meta.go
index 1f075f8..5bbbb41 100644
--- a/internal/oauthex/auth_meta.go
+++ b/internal/oauthex/auth_meta.go
@@ -8,10 +8,14 @@
 package oauthex
 
 import (
+	"bytes"
 	"context"
+	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"net/http"
+	"time"
 )
 
 // AuthServerMeta represents the metadata for an OAuth 2.0 authorization server,
@@ -109,6 +113,153 @@ type AuthServerMeta struct {
 	CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"`
 }
 
+// ClientRegistrationMetadata represents the client metadata fields for the DCR POST request (RFC 7591).
+type ClientRegistrationMetadata struct {
+	// RedirectURIs is a REQUIRED JSON array of redirection URI strings for use in
+	// redirect-based flows (such as the authorization code grant).
+	RedirectURIs []string `json:"redirect_uris"`
+
+	// TokenEndpointAuthMethod is an OPTIONAL string indicator of the requested
+	// authentication method for the token endpoint.
+	// If omitted, the default is "client_secret_basic".
+	TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
+
+	// GrantTypes is an OPTIONAL JSON array of OAuth 2.0 grant type strings
+	// that the client will restrict itself to using.
+	// If omitted, the default is ["authorization_code"].
+	GrantTypes []string `json:"grant_types,omitempty"`
+
+	// ResponseTypes is an OPTIONAL JSON array of OAuth 2.0 response type strings
+	// that the client will restrict itself to using.
+	// If omitted, the default is ["code"].
+	ResponseTypes []string `json:"response_types,omitempty"`
+
+	// ClientName is a RECOMMENDED human-readable name of the client to be presented
+	// to the end-user.
+	ClientName string `json:"client_name,omitempty"`
+
+	// ClientURI is a RECOMMENDED URL of a web page providing information about the client.
+	ClientURI string `json:"client_uri,omitempty"`
+
+	// LogoURI is an OPTIONAL URL of a logo for the client, which may be displayed
+	// to the end-user.
+	LogoURI string `json:"logo_uri,omitempty"`
+
+	// Scope is an OPTIONAL string containing a space-separated list of scope values
+	// that the client will restrict itself to using.
+	Scope string `json:"scope,omitempty"`
+
+	// Contacts is an OPTIONAL JSON array of strings representing ways to contact
+	// people responsible for this client (e.g., email addresses).
+	Contacts []string `json:"contacts,omitempty"`
+
+	// TOSURI is an OPTIONAL URL that the client provides to the end-user
+	// to read about the client's terms of service.
+	TOSURI string `json:"tos_uri,omitempty"`
+
+	// PolicyURI is an OPTIONAL URL that the client provides to the end-user
+	// to read about the client's privacy policy.
+	PolicyURI string `json:"policy_uri,omitempty"`
+
+	// JWKSURI is an OPTIONAL URL for the client's JSON Web Key Set [JWK] document.
+	// This is preferred over the 'jwks' parameter.
+	JWKSURI string `json:"jwks_uri,omitempty"`
+
+	// JWKS is an OPTIONAL client's JSON Web Key Set [JWK] document, passed by value.
+	// This is an alternative to providing a JWKSURI.
+	JWKS string `json:"jwks,omitempty"`
+
+	// SoftwareID is an OPTIONAL unique identifier string for the client software,
+	// constant across all instances and versions.
+	SoftwareID string `json:"software_id,omitempty"`
+
+	// SoftwareVersion is an OPTIONAL version identifier string for the client software.
+	SoftwareVersion string `json:"software_version,omitempty"`
+
+	// SoftwareStatement is an OPTIONAL JWT that asserts client metadata values.
+	// Values in the software statement take precedence over other metadata values.
+	SoftwareStatement string `json:"software_statement,omitempty"`
+}
+
+// ClientRegistrationResponse represents the fields returned by the Authorization Server
+// (RFC 7591, Section 3.2.1 and 3.2.2).
+type ClientRegistrationResponse struct {
+	// ClientRegistrationMetadata contains all registered client metadata, returned by the
+	// server on success, potentially with modified or defaulted values.
+	ClientRegistrationMetadata
+
+	// ClientID is the REQUIRED newly issued OAuth 2.0 client identifier.
+	ClientID string `json:"client_id"`
+
+	// ClientSecret is an OPTIONAL client secret string.
+	ClientSecret string `json:"client_secret,omitempty"`
+
+	// ClientIDIssuedAt is an OPTIONAL Unix timestamp when the ClientID was issued.
+	ClientIDIssuedAt time.Time `json:"client_id_issued_at,omitempty"`
+
+	// ClientSecretExpiresAt is the REQUIRED (if client_secret is issued) Unix
+	// timestamp when the secret expires, or 0 if it never expires.
+	ClientSecretExpiresAt time.Time `json:"client_secret_expires_at,omitempty"`
+}
+
+func (r *ClientRegistrationResponse) MarshalJSON() ([]byte, error) {
+	type alias ClientRegistrationResponse
+	var clientIDIssuedAt int64
+	var clientSecretExpiresAt int64
+
+	if !r.ClientIDIssuedAt.IsZero() {
+		clientIDIssuedAt = r.ClientIDIssuedAt.Unix()
+	}
+	if !r.ClientSecretExpiresAt.IsZero() {
+		clientSecretExpiresAt = r.ClientSecretExpiresAt.Unix()
+	}
+
+	return json.Marshal(&struct {
+		ClientIDIssuedAt      int64 `json:"client_id_issued_at,omitempty"`
+		ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
+		*alias
+	}{
+		ClientIDIssuedAt:      clientIDIssuedAt,
+		ClientSecretExpiresAt: clientSecretExpiresAt,
+		alias:                 (*alias)(r),
+	})
+}
+
+func (r *ClientRegistrationResponse) UnmarshalJSON(data []byte) error {
+	type alias ClientRegistrationResponse
+	aux := &struct {
+		ClientIDIssuedAt      int64 `json:"client_id_issued_at,omitempty"`
+		ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
+		*alias
+	}{
+		alias: (*alias)(r),
+	}
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+	if aux.ClientIDIssuedAt != 0 {
+		r.ClientIDIssuedAt = time.Unix(aux.ClientIDIssuedAt, 0)
+	}
+	if aux.ClientSecretExpiresAt != 0 {
+		r.ClientSecretExpiresAt = time.Unix(aux.ClientSecretExpiresAt, 0)
+	}
+	return nil
+}
+
+// ClientRegistrationError is the error response from the Authorization Server
+// for a failed registration attempt (RFC 7591, Section 3.2.2).
+type ClientRegistrationError struct {
+	// ErrorCode is the REQUIRED error code if registration failed (RFC 7591, 3.2.2).
+	ErrorCode string `json:"error"`
+
+	// ErrorDescription is an OPTIONAL human-readable error message.
+	ErrorDescription string `json:"error_description,omitempty"`
+}
+
+func (e *ClientRegistrationError) Error() string {
+	return fmt.Sprintf("registration failed: %s (%s)", e.ErrorCode, e.ErrorDescription)
+}
+
 var wellKnownPaths = []string{
 	"/.well-known/oauth-authorization-server",
 	"/.well-known/openid-configuration",
@@ -143,3 +294,59 @@ func GetAuthServerMeta(ctx context.Context, issuerURL string, c *http.Client) (*
 	}
 	return nil, fmt.Errorf("failed to get auth server metadata from %q: %w", issuerURL, errors.Join(errs...))
 }
+
+// RegisterClient performs Dynamic Client Registration according to RFC 7591.
+func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta *ClientRegistrationMetadata, c *http.Client) (*ClientRegistrationResponse, error) {
+	if registrationEndpoint == "" {
+		return nil, fmt.Errorf("registration_endpoint is required")
+	}
+
+	if c == nil {
+		c = http.DefaultClient
+	}
+
+	payload, err := json.Marshal(clientMeta)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal client metadata: %w", err)
+	}
+
+	req, err := http.NewRequestWithContext(ctx, "POST", registrationEndpoint, bytes.NewBuffer(payload))
+	if err != nil {
+		return nil, fmt.Errorf("failed to create registration request: %w", err)
+	}
+
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Accept", "application/json")
+
+	resp, err := c.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("registration request failed: %w", err)
+	}
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read registration response body: %w", err)
+	}
+
+	if resp.StatusCode == http.StatusCreated {
+		var regResponse ClientRegistrationResponse
+		if err := json.Unmarshal(body, &regResponse); err != nil {
+			return nil, fmt.Errorf("failed to decode successful registration response: %w (%s)", err, string(body))
+		}
+		if regResponse.ClientID == "" {
+			return nil, fmt.Errorf("registration response is missing required 'client_id' field")
+		}
+		return &regResponse, nil
+	}
+
+	if resp.StatusCode == http.StatusBadRequest {
+		var regError ClientRegistrationError
+		if err := json.Unmarshal(body, &regError); err != nil {
+			return nil, fmt.Errorf("failed to decode registration error response: %w (%s)", err, string(body))
+		}
+		return nil, &regError
+	}
+
+	return nil, fmt.Errorf("registration failed with status %s: %s", resp.Status, string(body))
+}
diff --git a/internal/oauthex/oauth2.go b/internal/oauthex/oauth2.go
index de16449..fc08c6b 100644
--- a/internal/oauthex/oauth2.go
+++ b/internal/oauthex/oauth2.go
@@ -65,3 +65,21 @@ func getJSON[T any](ctx context.Context, c *http.Client, url string, limit int64
 	}
 	return &t, nil
 }
+
+// checkURLScheme ensures that its argument is a valid URL with a scheme
+// that prevents XSS attacks.
+// See #526.
+func checkURLScheme(u string) error {
+	if u == "" {
+		return nil
+	}
+	uu, err := url.Parse(u)
+	if err != nil {
+		return err
+	}
+	scheme := strings.ToLower(uu.Scheme)
+	if scheme == "javascript" || scheme == "data" || scheme == "vbscript" {
+		return fmt.Errorf("URL has disallowed scheme %q", scheme)
+	}
+	return nil
+}
diff --git a/internal/oauthex/resource_meta.go b/internal/oauthex/resource_meta.go
index 71d52cd..5787fb1 100644
--- a/internal/oauthex/resource_meta.go
+++ b/internal/oauthex/resource_meta.go
@@ -146,11 +146,11 @@ func GetProtectedResourceMetadataFromHeader(ctx context.Context, header http.Hea
 	if len(headers) == 0 {
 		return nil, nil
 	}
-	cs, err := parseWWWAuthenticate(headers)
+	cs, err := ParseWWWAuthenticate(headers)
 	if err != nil {
 		return nil, err
 	}
-	url := resourceMetadataURL(cs)
+	url := ResourceMetadataURL(cs)
 	if url == "" {
 		return nil, nil
 	}
@@ -159,11 +159,11 @@ func GetProtectedResourceMetadataFromHeader(ctx context.Context, header http.Hea
 
 // getPRM makes a GET request to the given URL, and validates the response.
 // As part of the validation, it compares the returned resource field to wantResource.
-func getPRM(ctx context.Context, url string, c *http.Client, wantResource string) (*ProtectedResourceMetadata, error) {
-	if !strings.HasPrefix(strings.ToUpper(url), "HTTPS://") {
-		return nil, fmt.Errorf("resource URL %q does not use HTTPS", url)
+func getPRM(ctx context.Context, purl string, c *http.Client, wantResource string) (*ProtectedResourceMetadata, error) {
+	if !strings.HasPrefix(strings.ToUpper(purl), "HTTPS://") {
+		return nil, fmt.Errorf("resource URL %q does not use HTTPS", purl)
 	}
-	prm, err := getJSON[ProtectedResourceMetadata](ctx, c, url, 1<<20)
+	prm, err := getJSON[ProtectedResourceMetadata](ctx, c, purl, 1<<20)
 	if err != nil {
 		return nil, err
 	}
@@ -171,6 +171,12 @@ func getPRM(ctx context.Context, url string, c *http.Client, wantResource string
 	if prm.Resource != wantResource {
 		return nil, fmt.Errorf("got metadata resource %q, want %q", prm.Resource, wantResource)
 	}
+	// Validate the authorization server URLs to prevent XSS attacks (see #526).
+	for _, u := range prm.AuthorizationServers {
+		if err := checkURLScheme(u); err != nil {
+			return nil, err
+		}
+	}
 	return prm, nil
 }
 
@@ -187,9 +193,9 @@ type challenge struct {
 	Params map[string]string
 }
 
-// resourceMetadataURL returns a resource metadata URL from the given challenges,
+// ResourceMetadataURL returns a resource metadata URL from the given challenges,
 // or the empty string if there is none.
-func resourceMetadataURL(cs []challenge) string {
+func ResourceMetadataURL(cs []challenge) string {
 	for _, c := range cs {
 		if u := c.Params["resource_metadata"]; u != "" {
 			return u
@@ -198,11 +204,11 @@ func resourceMetadataURL(cs []challenge) string {
 	return ""
 }
 
-// parseWWWAuthenticate parses a WWW-Authenticate header string.
+// ParseWWWAuthenticate parses a WWW-Authenticate header string.
 // The header format is defined in RFC 9110, Section 11.6.1, and can contain
 // one or more challenges, separated by commas.
 // It returns a slice of challenges or an error if one of the headers is malformed.
-func parseWWWAuthenticate(headers []string) ([]challenge, error) {
+func ParseWWWAuthenticate(headers []string) ([]challenge, error) {
 	// GENERATED BY GEMINI 2.5 (human-tweaked)
 	var challenges []challenge
 	for _, h := range headers {
diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md
index 10ce9b6..e83c5fc 100644
--- a/internal/readme/README.src.md
+++ b/internal/readme/README.src.md
@@ -1,24 +1,12 @@
-# MCP Go SDK v0.6.0
+# MCP Go SDK
 
 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/modelcontextprotocol/go-sdk)
 
-***BREAKING CHANGES***
-
-This version contains minor breaking changes.
-See the [release notes](
-https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.6.0) for details.
-
 [![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk)
 
 This repository contains an implementation of the official Go software
 development kit (SDK) for the Model Context Protocol (MCP).
 
-> [!IMPORTANT]
-> The SDK is in release-candidate state, and is going to be tagged v1.0.0
-> soon (see https://github.com/modelcontextprotocol/go-sdk/issues/328).
-> We do not anticipate significant API changes or instability. Please use it
-> and [file issues](https://github.com/modelcontextprotocol/go-sdk/issues/new/choose).
-
 ## Package / Feature documentation
 
 The SDK consists of several importable packages:
@@ -32,7 +20,7 @@ The SDK consists of several importable packages:
   their own transports.
 - The
   [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth)
-  package provides some primitives for supporting oauth.
+  package provides some primitives for supporting OAuth.
 - The
   [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex)
   package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata.
diff --git a/internal/readme/client/client.go b/internal/readme/client/client.go
index 9f35796..8fc9972 100644
--- a/internal/readme/client/client.go
+++ b/internal/readme/client/client.go
@@ -19,7 +19,7 @@ func main() {
 	// Create a new client, with no features.
 	client := mcp.NewClient(&mcp.Implementation{Name: "mcp-client", Version: "v1.0.0"}, nil)
 
-	// Connect to a server over stdin/stdout
+	// Connect to a server over stdin/stdout.
 	transport := &mcp.CommandTransport{Command: exec.Command("myserver")}
 	session, err := client.Connect(ctx, transport, nil)
 	if err != nil {
diff --git a/internal/readme/server/server.go b/internal/readme/server/server.go
index bfa0825..5d7656d 100644
--- a/internal/readme/server/server.go
+++ b/internal/readme/server/server.go
@@ -32,7 +32,7 @@ func main() {
 	// Create a server with a single tool.
 	server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v1.0.0"}, nil)
 	mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi)
-	// Run the server over stdin/stdout, until the client disconnects
+	// Run the server over stdin/stdout, until the client disconnects.
 	if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
 		log.Fatal(err)
 	}
diff --git a/jsonrpc/jsonrpc.go b/jsonrpc/jsonrpc.go
index 1cf1202..1633d4e 100644
--- a/jsonrpc/jsonrpc.go
+++ b/jsonrpc/jsonrpc.go
@@ -19,8 +19,8 @@ type (
 	Response = jsonrpc2.Response
 )
 
-// MakeID coerces the given Go value to an ID. The value is assumed to be the
-// default JSON marshaling of a Request identifier -- nil, float64, or string.
+// MakeID coerces the given Go value to an ID. The value should be the
+// default JSON marshaling of a Request identifier: nil, float64, or string.
 //
 // Returns an error if the value type was not a valid Request ID type.
 func MakeID(v any) (ID, error) {
diff --git a/mcp/client.go b/mcp/client.go
index dea3e85..fb77b3f 100644
--- a/mcp/client.go
+++ b/mcp/client.go
@@ -283,7 +283,7 @@ func (c *Client) listRoots(_ context.Context, req *ListRootsRequest) (*ListRoots
 func (c *Client) createMessage(ctx context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) {
 	if c.opts.CreateMessageHandler == nil {
 		// TODO: wrap or annotate this error? Pick a standard code?
-		return nil, jsonrpc2.NewError(CodeUnsupportedMethod, "client does not support CreateMessage")
+		return nil, jsonrpc2.NewError(codeUnsupportedMethod, "client does not support CreateMessage")
 	}
 	return c.opts.CreateMessageHandler(ctx, req)
 }
@@ -291,12 +291,13 @@ func (c *Client) createMessage(ctx context.Context, req *CreateMessageRequest) (
 func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) {
 	if c.opts.ElicitationHandler == nil {
 		// TODO: wrap or annotate this error? Pick a standard code?
-		return nil, jsonrpc2.NewError(CodeUnsupportedMethod, "client does not support elicitation")
+		return nil, jsonrpc2.NewError(codeUnsupportedMethod, "client does not support elicitation")
 	}
 
 	// Validate that the requested schema only contains top-level properties without nesting
-	if err := validateElicitSchema(req.Params.RequestedSchema); err != nil {
-		return nil, jsonrpc2.NewError(CodeInvalidParams, err.Error())
+	schema, err := validateElicitSchema(req.Params.RequestedSchema)
+	if err != nil {
+		return nil, jsonrpc2.NewError(codeInvalidParams, err.Error())
 	}
 
 	res, err := c.opts.ElicitationHandler(ctx, req)
@@ -305,17 +306,17 @@ func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult,
 	}
 
 	// Validate elicitation result content against requested schema
-	if req.Params.RequestedSchema != nil && res.Content != nil {
+	if schema != nil && res.Content != nil {
 		// TODO: is this the correct behavior if validation fails?
 		// It isn't the *server's* params that are invalid, so why would we return
 		// this code to the server?
-		resolved, err := req.Params.RequestedSchema.Resolve(nil)
+		resolved, err := schema.Resolve(nil)
 		if err != nil {
-			return nil, jsonrpc2.NewError(CodeInvalidParams, fmt.Sprintf("failed to resolve requested schema: %v", err))
+			return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("failed to resolve requested schema: %v", err))
 		}
 
 		if err := resolved.Validate(res.Content); err != nil {
-			return nil, jsonrpc2.NewError(CodeInvalidParams, fmt.Sprintf("elicitation result content does not match requested schema: %v", err))
+			return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("elicitation result content does not match requested schema: %v", err))
 		}
 	}
 
@@ -324,14 +325,19 @@ func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult,
 
 // validateElicitSchema validates that the schema conforms to MCP elicitation schema requirements.
 // Per the MCP specification, elicitation schemas are limited to flat objects with primitive properties only.
-func validateElicitSchema(schema *jsonschema.Schema) error {
-	if schema == nil {
-		return nil // nil schema is allowed
+func validateElicitSchema(wireSchema any) (*jsonschema.Schema, error) {
+	if wireSchema == nil {
+		return nil, nil // nil schema is allowed
+	}
+
+	var schema *jsonschema.Schema
+	if err := remarshal(wireSchema, &schema); err != nil {
+		return nil, err
 	}
 
 	// The root schema must be of type "object" if specified
 	if schema.Type != "" && schema.Type != "object" {
-		return fmt.Errorf("elicit schema must be of type 'object', got %q", schema.Type)
+		return nil, fmt.Errorf("elicit schema must be of type 'object', got %q", schema.Type)
 	}
 
 	// Check if the schema has properties
@@ -342,12 +348,12 @@ func validateElicitSchema(schema *jsonschema.Schema) error {
 			}
 
 			if err := validateElicitProperty(propName, propSchema); err != nil {
-				return err
+				return nil, err
 			}
 		}
 	}
 
-	return nil
+	return schema, nil
 }
 
 // validateElicitProperty validates a single property in an elicitation schema.
@@ -383,7 +389,7 @@ func validateElicitStringProperty(propName string, propSchema *jsonschema.Schema
 		if propSchema.Extra != nil {
 			if enumNamesRaw, exists := propSchema.Extra["enumNames"]; exists {
 				// Type check enumNames - should be a slice
-				if enumNamesSlice, ok := enumNamesRaw.([]interface{}); ok {
+				if enumNamesSlice, ok := enumNamesRaw.([]any); ok {
 					if len(enumNamesSlice) != len(propSchema.Enum) {
 						return fmt.Errorf("elicit schema property %q has %d enum values but %d enumNames, they must match", propName, len(propSchema.Enum), len(enumNamesSlice))
 					}
@@ -668,7 +674,7 @@ func (cs *ClientSession) callProgressNotificationHandler(ctx context.Context, pa
 // NotifyProgress sends a progress notification from the client to the server
 // associated with this session.
 // This can be used if the client is performing a long-running task that was
-// initiated by the server
+// initiated by the server.
 func (cs *ClientSession) NotifyProgress(ctx context.Context, params *ProgressNotificationParams) error {
 	return handleNotify(ctx, notificationProgress, newClientRequest(cs, orZero[Params](params)))
 }
@@ -701,7 +707,7 @@ func (cs *ClientSession) Resources(ctx context.Context, params *ListResourcesPar
 
 // ResourceTemplates provides an iterator for all resource templates available on the server,
 // automatically fetching pages and managing cursors.
-// The `params` argument can set the initial cursor.
+// The params argument can set the initial cursor.
 // Iteration stops at the first encountered error, which will be yielded.
 func (cs *ClientSession) ResourceTemplates(ctx context.Context, params *ListResourceTemplatesParams) iter.Seq2[*ResourceTemplate, error] {
 	if params == nil {
diff --git a/mcp/content.go b/mcp/content.go
index 108b027..e53cad1 100644
--- a/mcp/content.go
+++ b/mcp/content.go
@@ -189,7 +189,7 @@ type ResourceContents struct {
 	Meta     Meta   `json:"_meta,omitempty"`
 }
 
-func (r ResourceContents) MarshalJSON() ([]byte, error) {
+func (r *ResourceContents) MarshalJSON() ([]byte, error) {
 	// If we could assume Go 1.24, we could use omitzero for Blob and avoid this method.
 	if r.URI == "" {
 		return nil, errors.New("ResourceContents missing URI")
@@ -197,7 +197,7 @@ func (r ResourceContents) MarshalJSON() ([]byte, error) {
 	if r.Blob == nil {
 		// Text. Marshal normally.
 		type wireResourceContents ResourceContents // (lacks MarshalJSON method)
-		return json.Marshal((wireResourceContents)(r))
+		return json.Marshal((wireResourceContents)(*r))
 	}
 	// Blob.
 	if r.Text != "" {
diff --git a/mcp/event.go b/mcp/event.go
index bd78cde..1dd36f4 100644
--- a/mcp/event.go
+++ b/mcp/event.go
@@ -176,7 +176,6 @@ type EventStore interface {
 	// with all of its streams.
 	// A store cannot rely on this method being called for cleanup. It should institute
 	// additional mechanisms, such as timeouts, to reclaim storage.
-	//
 	SessionClosed(_ context.Context, sessionID string) error
 
 	// There is no StreamClosed method. A server doesn't know when a stream is finished, because
diff --git a/mcp/logging.go b/mcp/logging.go
index b3186a9..208427e 100644
--- a/mcp/logging.go
+++ b/mcp/logging.go
@@ -88,6 +88,23 @@ type LoggingHandler struct {
 	handler         slog.Handler
 }
 
+// discardHandler is a slog.Handler that drops all logs.
+// TODO: use slog.DiscardHandler when we require Go 1.24+.
+type discardHandler struct{}
+
+func (discardHandler) Enabled(context.Context, slog.Level) bool  { return false }
+func (discardHandler) Handle(context.Context, slog.Record) error { return nil }
+func (discardHandler) WithAttrs([]slog.Attr) slog.Handler        { return discardHandler{} }
+func (discardHandler) WithGroup(string) slog.Handler             { return discardHandler{} }
+
+// ensureLogger returns l if non-nil, otherwise a discard logger.
+func ensureLogger(l *slog.Logger) *slog.Logger {
+	if l != nil {
+		return l
+	}
+	return slog.New(discardHandler{})
+}
+
 // NewLoggingHandler creates a [LoggingHandler] that logs to the given [ServerSession] using a
 // [slog.JSONHandler].
 func NewLoggingHandler(ss *ServerSession, opts *LoggingHandlerOptions) *LoggingHandler {
diff --git a/mcp/mcp.go b/mcp/mcp.go
index 88321a1..56e950b 100644
--- a/mcp/mcp.go
+++ b/mcp/mcp.go
@@ -80,9 +80,9 @@
 // validated. As a special case, if the output type is 'any', no output schema
 // is generated.
 //
-//	func double(_ context.Context, _ *mcp.CallToolRequest, in In) (*mcp.CallToolResponse, Out, error) {
+//	func double(_ context.Context, _ *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) {
 //		return nil, Out{Answer: 2*in.Number}, nil
 //	}
 //	...
-//	mcp.AddTool(&mcp.Tool{Name: "double", Description: "double a number"}, double)
+//	mcp.AddTool(server, &mcp.Tool{Name: "double"}, double)
 package mcp
diff --git a/mcp/protocol.go b/mcp/protocol.go
index f3f23f5..1312dfb 100644
--- a/mcp/protocol.go
+++ b/mcp/protocol.go
@@ -13,12 +13,10 @@ package mcp
 import (
 	"encoding/json"
 	"fmt"
-
-	"github.com/google/jsonschema-go/jsonschema"
 )
 
 // Optional annotations for the client. The client can use annotations to inform
-// how objects are used or displayed
+// how objects are used or displayed.
 type Annotations struct {
 	// Describes who the intended customer of this object or data is.
 	//
@@ -913,14 +911,38 @@ type Tool struct {
 	// This can be used by clients to improve the LLM's understanding of available
 	// tools. It can be thought of like a "hint" to the model.
 	Description string `json:"description,omitempty"`
-	// A JSON Schema object defining the expected parameters for the tool.
-	InputSchema *jsonschema.Schema `json:"inputSchema"`
+	// InputSchema holds a JSON Schema object defining the expected parameters
+	// for the tool.
+	//
+	// From the server, this field may be set to any value that JSON-marshals to
+	// valid JSON schema (including json.RawMessage). However, for tools added
+	// using [AddTool], which automatically validates inputs and outputs, the
+	// schema must be in a draft the SDK understands. Currently, the SDK uses
+	// github.com/google/jsonschema-go for inference and validation, which only
+	// supports the 2020-12 draft of JSON schema. To do your own validation, use
+	// [Server.AddTool].
+	//
+	// From the client, this field will hold the default JSON marshaling of the
+	// server's input schema (a map[string]any).
+	InputSchema any `json:"inputSchema"`
 	// Intended for programmatic or logical use, but used as a display name in past
 	// specs or fallback (if title isn't present).
 	Name string `json:"name"`
-	// An optional JSON Schema object defining the structure of the tool's output
-	// returned in the structuredContent field of a CallToolResult.
-	OutputSchema *jsonschema.Schema `json:"outputSchema,omitempty"`
+	// OutputSchema holds an optional JSON Schema object defining the structure
+	// of the tool's output returned in the StructuredContent field of a
+	// CallToolResult.
+	//
+	// From the server, this field may be set to any value that JSON-marshals to
+	// valid JSON schema (including json.RawMessage). However, for tools added
+	// using [AddTool], which automatically validates inputs and outputs, the
+	// schema must be in a draft the SDK understands. Currently, the SDK uses
+	// github.com/google/jsonschema-go for inference and validation, which only
+	// supports the 2020-12 draft of JSON schema. To do your own validation, use
+	// [Server.AddTool].
+	//
+	// From the client, this field will hold the default JSON marshaling of the
+	// server's output schema (a map[string]any).
+	OutputSchema any `json:"outputSchema,omitempty"`
 	// Intended for UI and end-user contexts — optimized to be human-readable and
 	// easily understood, even by those unfamiliar with domain-specific terminology.
 	// If not provided, Annotations.Title should be used for display if present,
@@ -1022,9 +1044,18 @@ type ElicitParams struct {
 	Meta `json:"_meta,omitempty"`
 	// The message to present to the user.
 	Message string `json:"message"`
-	// A restricted subset of JSON Schema.
+	// A JSON schema object defining the requested elicitation schema.
+	//
+	// From the server, this field may be set to any value that can JSON-marshal
+	// to valid JSON schema (including json.RawMessage for raw schema values).
+	// Internally, the SDK uses github.com/google/jsonschema-go for validation,
+	// which only supports the 2020-12 draft of the JSON schema spec.
+	//
+	// From the client, this field will use the default JSON marshaling (a
+	// map[string]any).
+	//
 	// Only top-level properties are allowed, without nesting.
-	RequestedSchema *jsonschema.Schema `json:"requestedSchema"`
+	RequestedSchema any `json:"requestedSchema"`
 }
 
 func (x *ElicitParams) isParams() {}
diff --git a/mcp/resource.go b/mcp/resource.go
index 0658c66..8746eda 100644
--- a/mcp/resource.go
+++ b/mcp/resource.go
@@ -41,7 +41,7 @@ type ResourceHandler func(context.Context, *ReadResourceRequest) (*ReadResourceR
 // not be found.
 func ResourceNotFoundError(uri string) error {
 	return &jsonrpc2.WireError{
-		Code:    CodeResourceNotFound,
+		Code:    codeResourceNotFound,
 		Message: "Resource not found",
 		Data:    json.RawMessage(fmt.Sprintf(`{"uri":%q}`, uri)),
 	}
diff --git a/mcp/server.go b/mcp/server.go
index 27de09a..f5b99cf 100644
--- a/mcp/server.go
+++ b/mcp/server.go
@@ -12,6 +12,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"iter"
+	"log/slog"
 	"maps"
 	"net/url"
 	"path/filepath"
@@ -27,6 +28,7 @@ import (
 	"github.com/yosida95/uritemplate/v3"
 )
 
+// DefaultPageSize is the default for [ServerOptions.PageSize].
 const DefaultPageSize = 1000
 
 // A Server is an instance of an MCP server.
@@ -53,6 +55,8 @@ type Server struct {
 type ServerOptions struct {
 	// Optional instructions for connected clients.
 	Instructions string
+	// If non-nil, log server activity.
+	Logger *slog.Logger
 	// If non-nil, called when "notifications/initialized" is received.
 	InitializedHandler func(context.Context, *InitializedRequest)
 	// PageSize is the maximum number of items to return in a single page for
@@ -129,6 +133,10 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server {
 		opts.GetSessionID = randText
 	}
 
+	if opts.Logger == nil { // ensure we have a logger
+		opts.Logger = ensureLogger(nil)
+	}
+
 	return &Server{
 		impl:                    impl,
 		opts:                    opts,
@@ -164,9 +172,9 @@ func (s *Server) RemovePrompts(names ...string) {
 //
 // The tool's input schema must be non-nil and have the type "object". For a tool
 // that takes no input, or one where any input is valid, set [Tool.InputSchema] to
-// &jsonschema.Schema{Type: "object"}.
+// `{"type": "object"}`, using your preferred library or `json.RawMessage`.
 //
-// If present, the output schema must also have type "object".
+// If present, [Tool.OutputSchema] must also have type "object".
 //
 // When the handler is invoked as part of a CallTool request, req.Params.Arguments
 // will be a json.RawMessage.
@@ -189,11 +197,29 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) {
 		// discovered until runtime, when the LLM sent bad data.
 		panic(fmt.Errorf("AddTool %q: missing input schema", t.Name))
 	}
-	if t.InputSchema.Type != "object" {
+	if s, ok := t.InputSchema.(*jsonschema.Schema); ok && s.Type != "object" {
 		panic(fmt.Errorf(`AddTool %q: input schema must have type "object"`, t.Name))
+	} else {
+		var m map[string]any
+		if err := remarshal(t.InputSchema, &m); err != nil {
+			panic(fmt.Errorf("AddTool %q: can't marshal input schema to a JSON object: %v", t.Name, err))
+		}
+		if typ := m["type"]; typ != "object" {
+			panic(fmt.Errorf(`AddTool %q: input schema must have type "object" (got %v)`, t.Name, typ))
+		}
 	}
-	if t.OutputSchema != nil && t.OutputSchema.Type != "object" {
-		panic(fmt.Errorf(`AddTool %q: output schema must have type "object"`, t.Name))
+	if t.OutputSchema != nil {
+		if s, ok := t.OutputSchema.(*jsonschema.Schema); ok && s.Type != "object" {
+			panic(fmt.Errorf(`AddTool %q: output schema must have type "object"`, t.Name))
+		} else {
+			var m map[string]any
+			if err := remarshal(t.OutputSchema, &m); err != nil {
+				panic(fmt.Errorf("AddTool %q: can't marshal output schema to a JSON object: %v", t.Name, err))
+			}
+			if typ := m["type"]; typ != "object" {
+				panic(fmt.Errorf(`AddTool %q: output schema must have type "object" (got %v)`, t.Name, typ))
+			}
+		}
 	}
 	st := &serverTool{tool: t, handler: h}
 	// Assume there was a change, since add replaces existing tools.
@@ -331,7 +357,8 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan
 //
 // TODO(rfindley): we really shouldn't ever return 'null' results. Maybe we
 // should have a jsonschema.Zero(schema) helper?
-func setSchema[T any](sfield **jsonschema.Schema, rfield **jsonschema.Resolved) (zero any, err error) {
+func setSchema[T any](sfield *any, rfield **jsonschema.Resolved) (zero any, err error) {
+	var internalSchema *jsonschema.Schema
 	if *sfield == nil {
 		rt := reflect.TypeFor[T]()
 		if rt.Kind() == reflect.Pointer {
@@ -339,28 +366,41 @@ func setSchema[T any](sfield **jsonschema.Schema, rfield **jsonschema.Resolved)
 			zero = reflect.Zero(rt).Interface()
 		}
 		// TODO: we should be able to pass nil opts here.
-		*sfield, err = jsonschema.ForType(rt, &jsonschema.ForOptions{})
+		internalSchema, err = jsonschema.ForType(rt, &jsonschema.ForOptions{})
+		if err == nil {
+			*sfield = internalSchema
+		}
+	} else {
+		if err := remarshal(*sfield, &internalSchema); err != nil {
+			return zero, err
+		}
 	}
 	if err != nil {
 		return zero, err
 	}
-	*rfield, err = (*sfield).Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true})
+	*rfield, err = internalSchema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true})
 	return zero, err
 }
 
 // AddTool adds a tool and typed tool handler to the server.
 //
 // If the tool's input schema is nil, it is set to the schema inferred from the
-// In type parameter, using [jsonschema.For]. The In type argument must be a
-// map or a struct, so that its inferred JSON Schema has type "object".
+// In type parameter. Types are inferred from Go types, and property
+// descriptions are read from the 'jsonschema' struct tag. Internally, the SDK
+// uses the github.com/google/jsonschema-go package for inference and
+// validation. The In type argument must be a map or a struct, so that its
+// inferred JSON Schema has type "object", as required by the spec. As a
+// special case, if the In type is 'any', the tool's input schema is set to an
+// empty object schema value.
 //
 // If the tool's output schema is nil, and the Out type is not 'any', the
 // output schema is set to the schema inferred from the Out type argument,
-// which also must be a map or struct.
+// which must also be a map or struct. If the Out type is 'any', the output
+// schema is omitted.
 //
-// Unlike [Server.AddTool], AddTool does a lot automatically, and forces tools
-// to conform to the MCP spec. See [ToolHandlerFor] for a detailed description
-// of this automatic behavior.
+// Unlike [Server.AddTool], AddTool does a lot automatically, and forces
+// tools to conform to the MCP spec. See [ToolHandlerFor] for a detailed
+// description of this automatic behavior.
 func AddTool[In, Out any](s *Server, t *Tool, h ToolHandlerFor[In, Out]) {
 	tt, hh, err := toolForErr(t, h)
 	if err != nil {
@@ -465,6 +505,9 @@ func (s *Server) changeAndNotify(notification string, params Params, change func
 }
 
 // Sessions returns an iterator that yields the current set of server sessions.
+//
+// There is no guarantee that the iterator observes sessions that are added or
+// removed during iteration.
 func (s *Server) Sessions() iter.Seq[*ServerSession] {
 	s.mu.Lock()
 	clients := slices.Clone(s.sessions)
@@ -493,7 +536,7 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm
 	if !ok {
 		// Return a proper JSON-RPC error with the correct error code
 		return nil, &jsonrpc2.WireError{
-			Code:    CodeInvalidParams,
+			Code:    codeInvalidParams,
 			Message: fmt.Sprintf("unknown prompt %q", req.Params.Name),
 		}
 	}
@@ -520,7 +563,7 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR
 	s.mu.Unlock()
 	if !ok {
 		return nil, &jsonrpc2.WireError{
-			Code:    CodeInvalidParams,
+			Code:    codeInvalidParams,
 			Message: fmt.Sprintf("unknown tool %q", req.Params.Name),
 		}
 	}
@@ -659,6 +702,7 @@ func (s *Server) ResourceUpdated(ctx context.Context, params *ResourceUpdatedNot
 	sessions := slices.Collect(maps.Keys(subscribedSessions))
 	s.mu.Unlock()
 	notifySessions(sessions, notificationResourceUpdated, params)
+	s.opts.Logger.Info("resource updated notification sent", "uri", params.URI, "subscriber_count", len(sessions))
 	return nil
 }
 
@@ -676,6 +720,7 @@ func (s *Server) subscribe(ctx context.Context, req *SubscribeRequest) (*emptyRe
 		s.resourceSubscriptions[req.Params.URI] = make(map[*ServerSession]bool)
 	}
 	s.resourceSubscriptions[req.Params.URI][req.Session] = true
+	s.opts.Logger.Info("resource subscribed", "uri", req.Params.URI, "session_id", req.Session.ID())
 
 	return &emptyResult{}, nil
 }
@@ -697,6 +742,7 @@ func (s *Server) unsubscribe(ctx context.Context, req *UnsubscribeRequest) (*emp
 			delete(s.resourceSubscriptions, req.Params.URI)
 		}
 	}
+	s.opts.Logger.Info("resource unsubscribed", "uri", req.Params.URI, "session_id", req.Session.ID())
 
 	return &emptyResult{}, nil
 }
@@ -715,8 +761,10 @@ func (s *Server) unsubscribe(ctx context.Context, req *UnsubscribeRequest) (*emp
 // It need not be called on servers that are used for multiple concurrent connections,
 // as with [StreamableHTTPHandler].
 func (s *Server) Run(ctx context.Context, t Transport) error {
+	s.opts.Logger.Info("server run start")
 	ss, err := s.Connect(ctx, t, nil)
 	if err != nil {
+		s.opts.Logger.Error("server connect failed", "error", err)
 		return err
 	}
 
@@ -728,8 +776,14 @@ func (s *Server) Run(ctx context.Context, t Transport) error {
 	select {
 	case <-ctx.Done():
 		ss.Close()
+		s.opts.Logger.Error("server run cancelled", "error", ctx.Err())
 		return ctx.Err()
 	case err := <-ssClosed:
+		if err != nil {
+			s.opts.Logger.Error("server session ended with error", "error", err)
+		} else {
+			s.opts.Logger.Info("server session ended")
+		}
 		return err
 	}
 }
@@ -745,6 +799,7 @@ func (s *Server) bind(mcpConn Connection, conn *jsonrpc2.Connection, state *Serv
 	s.mu.Lock()
 	s.sessions = append(s.sessions, ss)
 	s.mu.Unlock()
+	s.opts.Logger.Info("server session connected", "session_id", ss.ID())
 	return ss
 }
 
@@ -760,6 +815,7 @@ func (s *Server) disconnect(cc *ServerSession) {
 	for _, subscribedSessions := range s.resourceSubscriptions {
 		delete(subscribedSessions, cc)
 	}
+	s.opts.Logger.Info("server session disconnected", "session_id", cc.ID())
 }
 
 // ServerSessionOptions configures the server session.
@@ -784,7 +840,14 @@ func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOp
 		state = opts.State
 		onClose = opts.onClose
 	}
-	return connect(ctx, t, s, state, onClose)
+
+	s.opts.Logger.Info("server connecting")
+	ss, err := connect(ctx, t, s, state, onClose)
+	if err != nil {
+		s.opts.Logger.Error("server connect error", "error", err)
+		return nil, err
+	}
+	return ss, nil
 }
 
 // TODO: (nit) move all ServerSession methods below the ServerSession declaration.
@@ -804,9 +867,11 @@ func (ss *ServerSession) initialized(ctx context.Context, params *InitializedPar
 	})
 
 	if !wasInit {
+		ss.server.opts.Logger.Error("initialized before initialize")
 		return nil, fmt.Errorf("%q before %q", notificationInitialized, methodInitialize)
 	}
 	if wasInitd {
+		ss.server.opts.Logger.Error("duplicate initialized notification")
 		return nil, fmt.Errorf("duplicate %q received", notificationInitialized)
 	}
 	if ss.server.opts.KeepAlive > 0 {
@@ -815,6 +880,7 @@ func (ss *ServerSession) initialized(ctx context.Context, params *InitializedPar
 	if h := ss.server.opts.InitializedHandler; h != nil {
 		h(ctx, serverRequestFor(ss, params))
 	}
+	ss.server.opts.Logger.Info("session initialized")
 	return nil, nil
 }
 
@@ -1052,6 +1118,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any,
 	case methodInitialize, methodPing, notificationInitialized:
 	default:
 		if !initialized {
+			ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method)
 			return nil, fmt.Errorf("method %q is invalid during session initialization", req.Method)
 		}
 	}
@@ -1108,6 +1175,7 @@ func (ss *ServerSession) setLevel(_ context.Context, params *SetLoggingLevelPara
 	ss.updateState(func(state *ServerSessionState) {
 		state.LogLevel = params.Level
 	})
+	ss.server.opts.Logger.Info("client log level set", "level", params.Level)
 	return &emptyResult{}, nil
 }
 
diff --git a/mcp/shared.go b/mcp/shared.go
index 69b8836..e90bcbd 100644
--- a/mcp/shared.go
+++ b/mcp/shared.go
@@ -331,13 +331,11 @@ func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Cont
 
 // Error codes
 const (
-	// TODO: should these be unexported?
-
-	CodeResourceNotFound = -32002
+	codeResourceNotFound = -32002
 	// The error code if the method exists and was called properly, but the peer does not support it.
-	CodeUnsupportedMethod = -31001
+	codeUnsupportedMethod = -31001
 	// The error code for invalid parameters
-	CodeInvalidParams = -32602
+	codeInvalidParams = -32602
 )
 
 // notifySessions calls Notify on all the sessions.
@@ -453,13 +451,13 @@ func clientRequestFor[P Params](s *ClientSession, p P) *ClientRequest[P] {
 
 // Params is a parameter (input) type for an MCP call or notification.
 type Params interface {
-	// isParams discourages implementation of Params outside of this package.
-	isParams()
-
 	// GetMeta returns metadata from a value.
 	GetMeta() map[string]any
 	// SetMeta sets the metadata on a value.
 	SetMeta(map[string]any)
+
+	// isParams discourages implementation of Params outside of this package.
+	isParams()
 }
 
 // RequestParams is a parameter (input) type for an MCP request.
diff --git a/mcp/streamable.go b/mcp/streamable.go
index 4ab343b..4886f80 100644
--- a/mcp/streamable.go
+++ b/mcp/streamable.go
@@ -12,6 +12,7 @@ import (
 	"fmt"
 	"io"
 	"iter"
+	"log/slog"
 	"math"
 	"math/rand/v2"
 	"net/http"
@@ -67,6 +68,10 @@ type StreamableHTTPOptions struct {
 	//
 	// [§2.1.5]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server
 	JSONResponse bool
+
+	// Logger specifies the logger to use.
+	// If nil, do not log.
+	Logger *slog.Logger
 }
 
 // NewStreamableHTTPHandler returns a new [StreamableHTTPHandler].
@@ -82,6 +87,11 @@ func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *Strea
 	if opts != nil {
 		h.opts = *opts
 	}
+
+	if h.opts.Logger == nil { // ensure we have a logger
+		h.opts.Logger = ensureLogger(nil)
+	}
+
 	return h
 }
 
@@ -367,6 +377,8 @@ type StreamableServerTransport struct {
 	// StreamableHTTPOptions.JSONResponse is exported.
 	jsonResponse bool
 
+	logger *slog.Logger
+
 	// connection is non-nil if and only if the transport has been connected.
 	connection *streamableServerConn
 }
@@ -381,6 +393,7 @@ func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, er
 		stateless:      t.Stateless,
 		eventStore:     t.EventStore,
 		jsonResponse:   t.jsonResponse,
+		logger:         t.logger,
 		incoming:       make(chan jsonrpc.Message, 10),
 		done:           make(chan struct{}),
 		streams:        make(map[string]*stream),
@@ -407,6 +420,8 @@ type streamableServerConn struct {
 	jsonResponse bool
 	eventStore   EventStore
 
+	logger *slog.Logger
+
 	incoming chan jsonrpc.Message // messages from the client to the server
 
 	mu sync.Mutex // guards all fields below
@@ -754,7 +769,7 @@ func (c *streamableServerConn) respondSSE(stream *stream, w http.ResponseWriter,
 		}
 		if _, err := writeEvent(w, e); err != nil {
 			// Connection closed or broken.
-			// TODO(#170): log when we add server-side logging.
+			c.logger.Warn("error writing event", "error", err)
 			return false
 		}
 		writes++
@@ -773,7 +788,13 @@ func (c *streamableServerConn) respondSSE(stream *stream, w http.ResponseWriter,
 				// simplify.
 				http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
 			} else {
-				// TODO(#170): log when we add server-side logging
+				if ctx.Err() != nil {
+					// Client disconnected or cancelled the request.
+					c.logger.Error("stream context done", "error", ctx.Err())
+				} else {
+					// Some other error.
+					c.logger.Error("error receiving message", "error", err)
+				}
 			}
 			return
 		}
@@ -981,6 +1002,14 @@ type StreamableClientTransport struct {
 	// MaxRetries is the maximum number of times to attempt a reconnect before giving up.
 	// It defaults to 5. To disable retries, use a negative number.
 	MaxRetries int
+
+	// TODO(rfindley): propose exporting these.
+	// If strict is set, the transport is in 'strict mode', where any violation
+	// of the MCP spec causes a failure.
+	strict bool
+	// If logger is set, it is used to log aspects of the transport, such as spec
+	// violations that were ignored.
+	logger *slog.Logger
 }
 
 // These settings are not (yet) exposed to the user in
@@ -1018,13 +1047,15 @@ func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, er
 	// Create a new cancellable context that will manage the connection's lifecycle.
 	// This is crucial for cleanly shutting down the background SSE listener by
 	// cancelling its blocking network operations, which prevents hangs on exit.
-	connCtx, cancel := context.WithCancel(context.Background())
+	connCtx, cancel := context.WithCancel(ctx)
 	conn := &streamableClientConn{
 		url:        t.Endpoint,
 		client:     client,
 		incoming:   make(chan jsonrpc.Message, 10),
 		done:       make(chan struct{}),
 		maxRetries: maxRetries,
+		strict:     t.strict,
+		logger:     t.logger,
 		ctx:        connCtx,
 		cancel:     cancel,
 		failed:     make(chan struct{}),
@@ -1039,6 +1070,8 @@ type streamableClientConn struct {
 	cancel     context.CancelFunc
 	incoming   chan jsonrpc.Message
 	maxRetries int
+	strict     bool         // from [StreamableClientTransport.strict]
+	logger     *slog.Logger // from [StreamableClientTransport.logger]
 
 	// Guard calls to Close, as it may be called multiple times.
 	closeOnce sync.Once
@@ -1152,9 +1185,11 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e
 	}
 
 	var requestSummary string
+	var isCall bool
 	switch msg := msg.(type) {
 	case *jsonrpc.Request:
 		requestSummary = fmt.Sprintf("sending %q", msg.Method)
+		isCall = msg.IsCall()
 	case *jsonrpc.Response:
 		requestSummary = fmt.Sprintf("sending jsonrpc response #%d", msg.ID)
 	default:
@@ -1209,11 +1244,24 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e
 		}
 	}
 	if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusAccepted {
+		// [§2.1.4]: "If the input is a JSON-RPC response or notification:
+		// If the server accepts the input, the server MUST return HTTP status code 202 Accepted with no body."
+		//
+		// [§2.1.4]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server
+		resp.Body.Close()
+		return nil
+	} else if !isCall && !c.strict {
+		// Some servers return 200, even with an empty json body.
+		// Ignore this response in non-strict mode.
+		if c.logger != nil {
+			c.logger.Warn(fmt.Sprintf("unexpected status code %d from non-call", resp.StatusCode))
+		}
 		resp.Body.Close()
 		return nil
 	}
 
-	switch ct := resp.Header.Get("Content-Type"); ct {
+	contentType := strings.TrimSpace(strings.SplitN(resp.Header.Get("Content-Type"), ";", 2)[0])
+	switch contentType {
 	case "application/json":
 		go c.handleJSON(requestSummary, resp)
 
@@ -1223,14 +1271,14 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e
 
 	default:
 		resp.Body.Close()
-		return fmt.Errorf("%s: unsupported content type %q", requestSummary, ct)
+		return fmt.Errorf("%s: unsupported content type %q", requestSummary, contentType)
 	}
 	return nil
 }
 
 // testAuth controls whether a fake Authorization header is added to outgoing requests.
 // TODO: replace with a better mechanism when client-side auth is in place.
-var testAuth = false
+var testAuth atomic.Bool
 
 func (c *streamableClientConn) setMCPHeaders(req *http.Request) {
 	c.mu.Lock()
@@ -1242,7 +1290,7 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request) {
 	if c.sessionID != "" {
 		req.Header.Set(sessionIDHeader, c.sessionID)
 	}
-	if testAuth {
+	if testAuth.Load() {
 		req.Header.Set("Authorization", "Bearer foo")
 	}
 }
@@ -1294,18 +1342,36 @@ func (c *streamableClientConn) handleSSE(requestSummary string, initialResp *htt
 		newResp, err := c.reconnect(lastEventID)
 		if err != nil {
 			// All reconnection attempts failed: fail the connection.
-			c.fail(fmt.Errorf("%s: failed to reconnect: %v", requestSummary, err))
+			c.fail(fmt.Errorf("%s: failed to reconnect (session ID: %v): %v", requestSummary, c.sessionID, err))
 			return
 		}
 		resp = newResp
 		if resp.StatusCode == http.StatusMethodNotAllowed && persistent {
+			// [§2.2.3]: "The server MUST either return Content-Type:
+			// text/event-stream in response to this HTTP GET, or else return HTTP
+			// 405 Method Not Allowed, indicating that the server does not offer an
+			// SSE stream at this endpoint."
+			//
+			// [§2.2.3]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server
+
 			// The server doesn't support the hanging GET.
 			resp.Body.Close()
 			return
 		}
+		if resp.StatusCode == http.StatusNotFound && persistent && !c.strict {
+			// modelcontextprotocol/gosdk#393: some servers return NotFound instead
+			// of MethodNotAllowed for the persistent GET.
+			//
+			// Treat this like MethodNotAllowed in non-strict mode.
+			if c.logger != nil {
+				c.logger.Warn("got 404 instead of 405 for hanging GET")
+			}
+			resp.Body.Close()
+			return
+		}
 		// (see equivalent handling in [streamableClientConn.Write]).
 		if resp.StatusCode == http.StatusNotFound {
-			c.fail(fmt.Errorf("%s: failed to reconnect: %w", requestSummary, errSessionMissing))
+			c.fail(fmt.Errorf("%s: failed to reconnect (session ID: %v): %w", requestSummary, c.sessionID, errSessionMissing))
 			return
 		}
 		if resp.StatusCode < 200 || resp.StatusCode >= 300 {
@@ -1394,14 +1460,10 @@ func (c *streamableClientConn) reconnect(lastEventID string) (*http.Response, er
 // Close implements the [Connection] interface.
 func (c *streamableClientConn) Close() error {
 	c.closeOnce.Do(func() {
-		// Cancel any hanging network requests.
-		c.cancel()
-		close(c.done)
-
 		if errors.Is(c.failure(), errSessionMissing) {
 			// If the session is missing, no need to delete it.
 		} else {
-			req, err := http.NewRequest(http.MethodDelete, c.url, nil)
+			req, err := http.NewRequestWithContext(c.ctx, http.MethodDelete, c.url, nil)
 			if err != nil {
 				c.closeErr = err
 			} else {
@@ -1411,6 +1473,10 @@ func (c *streamableClientConn) Close() error {
 				}
 			}
 		}
+
+		// Cancel any hanging network requests after cleanup.
+		c.cancel()
+		close(c.done)
 	})
 	return c.closeErr
 }
diff --git a/mcp/transport.go b/mcp/transport.go
index d2109e7..111887b 100644
--- a/mcp/transport.go
+++ b/mcp/transport.go
@@ -93,6 +93,18 @@ func (*StdioTransport) Connect(context.Context) (Connection, error) {
 	return newIOConn(rwc{os.Stdin, os.Stdout}), nil
 }
 
+// An IOTransport is a [Transport] that communicates over separate
+// io.ReadCloser and io.WriteCloser using newline-delimited JSON.
+type IOTransport struct {
+	Reader io.ReadCloser
+	Writer io.WriteCloser
+}
+
+// Connect implements the [Transport] interface.
+func (t *IOTransport) Connect(context.Context) (Connection, error) {
+	return newIOConn(rwc{t.Reader, t.Writer}), nil
+}
+
 // An InMemoryTransport is a [Transport] that communicates over an in-memory
 // network connection, using newline-delimited JSON.
 type InMemoryTransport struct {
@@ -104,8 +116,12 @@ func (t *InMemoryTransport) Connect(context.Context) (Connection, error) {
 	return newIOConn(t.rwc), nil
 }
 
-// NewInMemoryTransports returns two [InMemoryTransports] that connect to each
-// other.
+// NewInMemoryTransports returns two [InMemoryTransport] objects that connect
+// to each other.
+//
+// The resulting transports are symmetrical: use either to connect to a server,
+// and then the other to connect to a client. Servers must be connected before
+// clients, as the client initializes the MCP session during connection.
 func NewInMemoryTransports() (*InMemoryTransport, *InMemoryTransport) {
 	c1, c2 := net.Pipe()
 	return &InMemoryTransport{c1}, &InMemoryTransport{c2}
@@ -230,7 +246,7 @@ func (s *loggingConn) Read(ctx context.Context) (jsonrpc.Message, error) {
 
 	if err != nil {
 		s.mu.Lock()
-		fmt.Fprintf(s.w, "read error: %v", err)
+		fmt.Fprintf(s.w, "read error: %v\n", err)
 		s.mu.Unlock()
 	} else {
 		data, err := jsonrpc2.EncodeMessage(msg)
@@ -250,7 +266,7 @@ func (s *loggingConn) Write(ctx context.Context, msg jsonrpc.Message) error {
 	err := s.delegate.Write(ctx, msg)
 	if err != nil {
 		s.mu.Lock()
-		fmt.Fprintf(s.w, "write error: %v", err)
+		fmt.Fprintf(s.w, "write error: %v\n", err)
 		s.mu.Unlock()
 	} else {
 		data, err := jsonrpc2.EncodeMessage(msg)
diff --git a/mcp/util.go b/mcp/util.go
index 102c088..5ada466 100644
--- a/mcp/util.go
+++ b/mcp/util.go
@@ -6,6 +6,7 @@ package mcp
 
 import (
 	"crypto/rand"
+	"encoding/json"
 )
 
 func assert(cond bool, msg string) {
@@ -27,3 +28,16 @@ func randText() string {
 	}
 	return string(src)
 }
+
+// remarshal marshals from to JSON, and then unmarshals into to, which must be
+// a pointer type.
+func remarshal(from, to any) error {
+	data, err := json.Marshal(from)
+	if err != nil {
+		return err
+	}
+	if err := json.Unmarshal(data, to); err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/oauthex/oauthex.go b/oauthex/oauthex.go
index eb3a3e7..3c28dce 100644
--- a/oauthex/oauthex.go
+++ b/oauthex/oauthex.go
@@ -5,6 +5,10 @@
 // Package oauthex implements extensions to OAuth2.
 package oauthex
 
-import "github.com/modelcontextprotocol/go-sdk/internal/oauthex"
+import (
+	"github.com/modelcontextprotocol/go-sdk/internal/oauthex"
+)
 
+// ProtectedResourceMetadata is the metadata for an OAuth 2.0 protected resource,
+// as defined in section 2 of https://www.rfc-editor.org/rfc/rfc9728.html.
 type ProtectedResourceMetadata = oauthex.ProtectedResourceMetadata



Test patch:
diff --git a/auth/client_test.go b/auth/client_test.go
new file mode 100644
index 0000000..310fc56
--- /dev/null
+++ b/auth/client_test.go
@@ -0,0 +1,102 @@
+// 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 (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"golang.org/x/oauth2"
+)
+
+// 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) {
+		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(ctx context.Context, args OAuthHandlerArgs) (oauth2.TokenSource, error) {
+			handlerCalls++
+			if args.ResourceMetadataURL != "http://metadata.example.com" {
+				t.Errorf("handler got metadata URL %q, want %q", args.ResourceMetadataURL, "http://metadata.example.com")
+			}
+			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("handler returns error", func(t *testing.T) {
+		handlerErr := errors.New("user rejected auth")
+		handler := func(ctx context.Context, args OAuthHandlerArgs) (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)
+		}
+	})
+}
diff --git a/examples/server/toolschemas/main_test.go b/examples/server/toolschemas/main_test.go
new file mode 100644
index 0000000..9173881
--- /dev/null
+++ b/examples/server/toolschemas/main_test.go
@@ -0,0 +1,31 @@
+// 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.
+
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"testing"
+
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+func TestGreet(t *testing.T) {
+	manual, err := newManualGreeter()
+	if err != nil {
+		t.Fatal(err)
+	}
+	res, err := manual.greet(context.Background(), &mcp.CallToolRequest{
+		Params: &mcp.CallToolParamsRaw{
+			Arguments: json.RawMessage(`{"name": "Bob"}`),
+		},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if res.IsError {
+		t.Fatalf("tool error: %q", res.Content[0].(*mcp.TextContent).Text)
+	}
+}
diff --git a/internal/oauthex/auth_meta_test.go b/internal/oauthex/auth_meta_test.go
index b83402f..6ff9f3d 100644
--- a/internal/oauthex/auth_meta_test.go
+++ b/internal/oauthex/auth_meta_test.go
@@ -5,10 +5,18 @@
 package oauthex
 
 import (
+	"context"
 	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
 )
 
 func TestAuthMetaParse(t *testing.T) {
@@ -26,3 +34,198 @@ func TestAuthMetaParse(t *testing.T) {
 		t.Errorf("got %q, want %q", g, w)
 	}
 }
+
+func TestClientRegistrationMetadataParse(t *testing.T) {
+	// Verify that we can parse a typical client metadata JSON.
+	data, err := os.ReadFile(filepath.FromSlash("testdata/client-auth-meta.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	var a ClientRegistrationMetadata
+	if err := json.Unmarshal(data, &a); err != nil {
+		t.Fatal(err)
+	}
+	// Spot check
+	if g, w := a.ClientName, "My Test App"; g != w {
+		t.Errorf("got ClientName %q, want %q", g, w)
+	}
+	if g, w := len(a.RedirectURIs), 2; g != w {
+		t.Errorf("got %d RedirectURIs, want %d", g, w)
+	}
+}
+
+func TestRegisterClient(t *testing.T) {
+	testCases := []struct {
+		name         string
+		handler      http.HandlerFunc
+		clientMeta   *ClientRegistrationMetadata
+		wantClientID string
+		wantErr      string
+	}{
+		{
+			name: "Success",
+			handler: func(w http.ResponseWriter, r *http.Request) {
+				if r.Method != "POST" {
+					t.Errorf("Expected POST, got %s", r.Method)
+				}
+				body, err := io.ReadAll(r.Body)
+				if err != nil {
+					t.Fatal(err)
+				}
+				var receivedMeta ClientRegistrationMetadata
+				if err := json.Unmarshal(body, &receivedMeta); err != nil {
+					t.Fatalf("Failed to unmarshal request body: %v", err)
+				}
+				if receivedMeta.ClientName != "Test App" {
+					t.Errorf("Expected ClientName 'Test App', got '%s'", receivedMeta.ClientName)
+				}
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusCreated)
+				w.Write([]byte(`{"client_id":"test-client-id","client_secret":"test-client-secret","client_name":"Test App"}`))
+			},
+			clientMeta:   &ClientRegistrationMetadata{ClientName: "Test App", RedirectURIs: []string{"http://localhost/cb"}},
+			wantClientID: "test-client-id",
+		},
+		{
+			name: "Missing ClientID in Response",
+			handler: func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusCreated)
+				w.Write([]byte(`{"client_secret":"test-client-secret"}`)) // No client_id
+			},
+			clientMeta: &ClientRegistrationMetadata{RedirectURIs: []string{"http://localhost/cb"}},
+			wantErr:    "registration response is missing required 'client_id' field",
+		},
+		{
+			name: "Standard OAuth Error",
+			handler: func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusBadRequest)
+				w.Write([]byte(`{"error":"invalid_redirect_uri","error_description":"Redirect URI is not valid."}`))
+			},
+			clientMeta: &ClientRegistrationMetadata{RedirectURIs: []string{"http://invalid/cb"}},
+			wantErr:    "registration failed: invalid_redirect_uri (Redirect URI is not valid.)",
+		},
+		{
+			name: "Non-JSON Server Error",
+			handler: func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusInternalServerError)
+				w.Write([]byte("Internal Server Error"))
+			},
+			clientMeta: &ClientRegistrationMetadata{RedirectURIs: []string{"http://localhost/cb"}},
+			wantErr:    "registration failed with status 500 Internal Server Error",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			server := httptest.NewServer(tc.handler)
+			defer server.Close()
+
+			info, err := RegisterClient(context.Background(), server.URL, tc.clientMeta, server.Client())
+
+			if tc.wantErr != "" {
+				if err == nil {
+					t.Fatalf("Expected an error containing '%s', but got nil", tc.wantErr)
+				}
+				if !strings.Contains(err.Error(), tc.wantErr) {
+					t.Errorf("Expected error to contain '%s', got '%v'", tc.wantErr, err)
+				}
+				return
+			}
+
+			if err != nil {
+				t.Fatalf("Expected no error, but got: %v", err)
+			}
+			if info.ClientID != tc.wantClientID {
+				t.Errorf("Expected client_id '%s', got '%s'", tc.wantClientID, info.ClientID)
+			}
+		})
+	}
+
+	t.Run("No Endpoint", func(t *testing.T) {
+		_, err := RegisterClient(context.Background(), "", &ClientRegistrationMetadata{}, nil)
+		if err == nil {
+			t.Fatal("Expected an error for missing registration endpoint, got nil")
+		}
+		expectedErr := "registration_endpoint is required"
+		if err.Error() != expectedErr {
+			t.Errorf("Expected error '%s', got '%v'", expectedErr, err)
+		}
+	})
+}
+
+func TestClientRegistrationResponseJSON(t *testing.T) {
+	testCases := []struct {
+		name     string
+		in       ClientRegistrationResponse
+		wantJSON string
+	}{
+		{
+			name: "full response",
+			in: ClientRegistrationResponse{
+				ClientID:              "test-client-id",
+				ClientSecret:          "test-client-secret",
+				ClientIDIssuedAt:      time.Unix(1758840047, 0),
+				ClientSecretExpiresAt: time.Unix(1790376047, 0),
+			},
+			wantJSON: `{"client_id":"test-client-id","client_secret":"test-client-secret","client_id_issued_at":1758840047,"client_secret_expires_at":1790376047, "redirect_uris": null}`,
+		},
+		{
+			name: "minimal response with only required fields",
+			in: ClientRegistrationResponse{
+				ClientID: "test-client-id-minimal",
+			},
+			wantJSON: `{"client_id":"test-client-id-minimal", "redirect_uris":null}`,
+		},
+		{
+			name: "response with a secret that does not expire",
+			in: ClientRegistrationResponse{
+				ClientID:     "test-client-id-no-expiry",
+				ClientSecret: "test-secret-no-expiry",
+			},
+			wantJSON: `{"client_id":"test-client-id-no-expiry","client_secret":"test-secret-no-expiry", "redirect_uris":null}`,
+		},
+		{
+			name:     "unmarshal with zero timestamp",
+			in:       ClientRegistrationResponse{ClientID: "client-id-zero"},
+			wantJSON: `{"client_id":"client-id-zero", "redirect_uris":null}`,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			// Test MarshalJSON
+			t.Run("marshal", func(t *testing.T) {
+				b, err := json.Marshal(&tc.in)
+				if err != nil {
+					t.Fatalf("Marshal() error = %v", err)
+				}
+
+				var gotMap, wantMap map[string]any
+				if err := json.Unmarshal(b, &gotMap); err != nil {
+					t.Fatalf("failed to unmarshal actual result: %v", err)
+				}
+				if err := json.Unmarshal([]byte(tc.wantJSON), &wantMap); err != nil {
+					t.Fatalf("failed to unmarshal expected result: %v", err)
+				}
+
+				if diff := cmp.Diff(wantMap, gotMap); diff != "" {
+					t.Errorf("Marshal() mismatch (-want +got):\n%s", diff)
+				}
+			})
+
+			// Test UnmarshalJSON
+			t.Run("unmarshal", func(t *testing.T) {
+				var got ClientRegistrationResponse
+				if err := json.Unmarshal([]byte(tc.wantJSON), &got); err != nil {
+					t.Fatalf("Unmarshal() error = %v", err)
+				}
+
+				if diff := cmp.Diff(tc.in, got); diff != "" {
+					t.Errorf("Unmarshal() mismatch (-want +got):\n%s", diff)
+				}
+			})
+		})
+	}
+}
diff --git a/internal/oauthex/testdata/client-auth-meta.json b/internal/oauthex/testdata/client-auth-meta.json
new file mode 100644
index 0000000..c07f5be
--- /dev/null
+++ b/internal/oauthex/testdata/client-auth-meta.json
@@ -0,0 +1,8 @@
+{
+    "client_name": "My Test App",
+    "redirect_uris": [
+        "https://client.example.org/callback",
+        "https://client.example.org/callback2"
+    ],
+    "scope": "read write"
+}
diff --git a/mcp/client_example_test.go b/mcp/client_example_test.go
index bba3da4..225daba 100644
--- a/mcp/client_example_test.go
+++ b/mcp/client_example_test.go
@@ -112,7 +112,7 @@ func Example_elicitation() {
 	}
 	defer ss.Close()
 
-	c := mcp.NewClient(testImpl, &mcp.ClientOptions{
+	c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{
 		ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
 			return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil
 		},
diff --git a/mcp/client_list_test.go b/mcp/client_list_test.go
index 0183a73..6974ad5 100644
--- a/mcp/client_list_test.go
+++ b/mcp/client_list_test.go
@@ -6,6 +6,7 @@ package mcp_test
 
 import (
 	"context"
+	"encoding/json"
 	"iter"
 	"log"
 	"testing"
@@ -41,7 +42,13 @@ func TestList(t *testing.T) {
 			if err != nil {
 				t.Fatal(err)
 			}
-			tt.InputSchema = is
+			data, err := json.Marshal(is)
+			if err != nil {
+				t.Fatal(err)
+			}
+			if err := json.Unmarshal(data, &tt.InputSchema); err != nil {
+				t.Fatal(err)
+			}
 			wantTools = append(wantTools, tt)
 		}
 		t.Run("list", func(t *testing.T) {
diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go
index 9cc105c..0e1ee1f 100644
--- a/mcp/mcp_test.go
+++ b/mcp/mcp_test.go
@@ -322,7 +322,7 @@ func TestEndToEnd(t *testing.T) {
 		} {
 			rres, err := cs.ReadResource(ctx, &ReadResourceParams{URI: tt.uri})
 			if err != nil {
-				if code := errorCode(err); code == CodeResourceNotFound {
+				if code := errorCode(err); code == codeResourceNotFound {
 					if tt.mimeType != "" {
 						t.Errorf("%s: not found but expected it to be", tt.uri)
 					}
@@ -994,8 +994,8 @@ func TestElicitationUnsupportedMethod(t *testing.T) {
 	if err == nil {
 		t.Error("expected error when ElicitationHandler is not provided, got nil")
 	}
-	if code := errorCode(err); code != CodeUnsupportedMethod {
-		t.Errorf("got error code %d, want %d (CodeUnsupportedMethod)", code, CodeUnsupportedMethod)
+	if code := errorCode(err); code != codeUnsupportedMethod {
+		t.Errorf("got error code %d, want %d (CodeUnsupportedMethod)", code, codeUnsupportedMethod)
 	}
 	if !strings.Contains(err.Error(), "does not support elicitation") {
 		t.Errorf("error should mention unsupported elicitation, got: %v", err)
@@ -1342,8 +1342,8 @@ func TestElicitationSchemaValidation(t *testing.T) {
 				t.Errorf("expected error for invalid schema %q, got nil", tc.name)
 				return
 			}
-			if code := errorCode(err); code != CodeInvalidParams {
-				t.Errorf("got error code %d, want %d (CodeInvalidParams)", code, CodeInvalidParams)
+			if code := errorCode(err); code != codeInvalidParams {
+				t.Errorf("got error code %d, want %d (CodeInvalidParams)", code, codeInvalidParams)
 			}
 			if !strings.Contains(err.Error(), tc.expectedError) {
 				t.Errorf("error message %q does not contain expected text %q", err.Error(), tc.expectedError)
@@ -1462,8 +1462,8 @@ func TestElicitationCapabilityDeclaration(t *testing.T) {
 		if err == nil {
 			t.Error("expected UnsupportedMethod error when no capability declared")
 		}
-		if code := errorCode(err); code != CodeUnsupportedMethod {
-			t.Errorf("got error code %d, want %d (CodeUnsupportedMethod)", code, CodeUnsupportedMethod)
+		if code := errorCode(err); code != codeUnsupportedMethod {
+			t.Errorf("got error code %d, want %d (CodeUnsupportedMethod)", code, codeUnsupportedMethod)
 		}
 	})
 }
diff --git a/mcp/server_test.go b/mcp/server_test.go
index 249ef90..ec6114d 100644
--- a/mcp/server_test.go
+++ b/mcp/server_test.go
@@ -492,7 +492,7 @@ func TestAddTool(t *testing.T) {
 
 type schema = jsonschema.Schema
 
-func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out, wantIn, wantOut *schema, wantErrContaining string) {
+func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out, wantIn, wantOut any, wantErrContaining string) {
 	t.Helper()
 	th := func(context.Context, *CallToolRequest, In) (*CallToolResult, Out, error) {
 		return nil, out, nil
diff --git a/mcp/streamable_bench_test.go b/mcp/streamable_bench_test.go
index 07f81da..bf82ee5 100644
--- a/mcp/streamable_bench_test.go
+++ b/mcp/streamable_bench_test.go
@@ -8,6 +8,7 @@ import (
 	"context"
 	"net/http"
 	"net/http/httptest"
+	"reflect"
 	"testing"
 
 	"github.com/google/jsonschema-go/jsonschema"
@@ -17,9 +18,9 @@ import (
 func BenchmarkStreamableServing(b *testing.B) {
 	// This benchmark measures how fast we can handle a single tool on a
 	// streamable server, including tool validation and stream management.
-	customSchemas := map[any]*jsonschema.Schema{
-		Probability(0):  {Type: "number", Minimum: jsonschema.Ptr(0.0), Maximum: jsonschema.Ptr(1.0)},
-		WeatherType(""): {Type: "string", Enum: []any{Sunny, PartlyCloudy, Cloudy, Rainy, Snowy}},
+	customSchemas := map[reflect.Type]*jsonschema.Schema{
+		reflect.TypeFor[Probability](): {Type: "number", Minimum: jsonschema.Ptr(0.0), Maximum: jsonschema.Ptr(1.0)},
+		reflect.TypeFor[WeatherType](): {Type: "string", Enum: []any{Sunny, PartlyCloudy, Cloudy, Rainy, Snowy}},
 	}
 	opts := &jsonschema.ForOptions{TypeSchemas: customSchemas}
 	in, err := jsonschema.For[WeatherInput](opts)
diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go
index 001d3a6..9116677 100644
--- a/mcp/streamable_client_test.go
+++ b/mcp/streamable_client_test.go
@@ -256,7 +256,7 @@ func TestStreamableClientGETHandling(t *testing.T) {
 				responses: fakeResponses{
 					{"POST", "", methodInitialize}: {
 						header: header{
-							"Content-Type":  "application/json",
+							"Content-Type":  "application/json; charset=utf-8", // should ignore the charset
 							sessionIDHeader: "123",
 						},
 						body: jsonBody(t, initResp),
@@ -293,8 +293,8 @@ func TestStreamableClientGETHandling(t *testing.T) {
 				t.Fatalf("client.Connect() failed: %v", err)
 			}
 
-			// wait for all required requests to be handled, with exponential
-			// backoff.
+			// Since we need the client to observe the result of the hanging GET,
+			// wait for all requests to be handled.
 			start := time.Now()
 			delay := 1 * time.Millisecond
 			for range 10 {
@@ -310,7 +310,7 @@ func TestStreamableClientGETHandling(t *testing.T) {
 
 			_, err = session.ListTools(ctx, nil)
 			if (err != nil) != (test.wantErrorContaining != "") {
-				t.Errorf("After initialization, got error %v, want %v", err, test.wantErrorContaining)
+				t.Errorf("After initialization, got error %v, want containing %q", err, test.wantErrorContaining)
 			} else if err != nil {
 				if !strings.Contains(err.Error(), test.wantErrorContaining) {
 					t.Errorf("After initialization, got error %s, want containing %q", err, test.wantErrorContaining)
@@ -323,3 +323,91 @@ func TestStreamableClientGETHandling(t *testing.T) {
 		})
 	}
 }
+
+func TestStreamableClientStrictness(t *testing.T) {
+	ctx := context.Background()
+
+	tests := []struct {
+		label             string
+		strict            bool
+		initializedStatus int
+		getStatus         int
+		wantConnectError  bool
+		wantListError     bool
+	}{
+		{"conformant server", true, http.StatusAccepted, http.StatusMethodNotAllowed, false, false},
+		{"strict initialized", true, http.StatusOK, http.StatusMethodNotAllowed, true, false},
+		{"unstrict initialized", false, http.StatusOK, http.StatusMethodNotAllowed, false, false},
+		{"strict GET", true, http.StatusAccepted, http.StatusNotFound, false, true},
+		{"unstrict GET", false, http.StatusOK, http.StatusNotFound, false, false},
+	}
+	for _, test := range tests {
+		t.Run(test.label, func(t *testing.T) {
+			fake := &fakeStreamableServer{
+				t: t,
+				responses: fakeResponses{
+					{"POST", "", methodInitialize}: {
+						header: header{
+							"Content-Type":  "application/json",
+							sessionIDHeader: "123",
+						},
+						body: jsonBody(t, initResp),
+					},
+					{"POST", "123", notificationInitialized}: {
+						status:              test.initializedStatus,
+						wantProtocolVersion: latestProtocolVersion,
+					},
+					{"GET", "123", ""}: {
+						header: header{
+							"Content-Type": "text/event-stream",
+						},
+						status:              test.getStatus,
+						wantProtocolVersion: latestProtocolVersion,
+					},
+					{"POST", "123", methodListTools}: {
+						header: header{
+							"Content-Type":  "application/json",
+							sessionIDHeader: "123",
+						},
+						body:     jsonBody(t, resp(2, &ListToolsResult{Tools: []*Tool{}}, nil)),
+						optional: true,
+					},
+					{"DELETE", "123", ""}: {optional: true},
+				},
+			}
+			httpServer := httptest.NewServer(fake)
+			defer httpServer.Close()
+
+			transport := &StreamableClientTransport{Endpoint: httpServer.URL, strict: test.strict}
+			client := NewClient(testImpl, nil)
+			session, err := client.Connect(ctx, transport, nil)
+			if (err != nil) != test.wantConnectError {
+				t.Errorf("client.Connect() returned error %v; want error: %t", err, test.wantConnectError)
+			}
+			if err != nil {
+				return
+			}
+			// Since we need the client to observe the result of the hanging GET,
+			// wait for all requests to be handled.
+			start := time.Now()
+			delay := 1 * time.Millisecond
+			for range 10 {
+				if len(fake.missingRequests()) == 0 {
+					break
+				}
+				time.Sleep(delay)
+				delay *= 2
+			}
+			if missing := fake.missingRequests(); len(missing) > 0 {
+				t.Errorf("did not receive expected requests after %s: %v", time.Since(start), missing)
+			}
+			_, err = session.ListTools(ctx, nil)
+			if (err != nil) != test.wantListError {
+				t.Errorf("ListTools returned error %v; want error: %t", err, test.wantListError)
+			}
+			if err := session.Close(); err != nil {
+				t.Errorf("closing session: %v", err)
+			}
+		})
+	}
+}
diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go
index 3b967f8..f7cda72 100644
--- a/mcp/streamable_test.go
+++ b/mcp/streamable_test.go
@@ -675,13 +675,14 @@ func TestStreamableServerTransport(t *testing.T) {
 					headers: http.Header{"MCP-Protocol-Version": {"2025-03-26"}},
 					// Two messages => batch. Expect OK with two responses in order.
 					messages: []jsonrpc.Message{
+						// Note: only include one request here, because responses are not
+						// necessarily sorted.
 						req(201, "tools/call", &CallToolParams{Name: "tool"}),
-						req(202, "tools/call", &CallToolParams{Name: "tool"}),
+						req(0, "notifications/roots/list_changed", &RootsListChangedParams{}),
 					},
 					wantStatusCode: http.StatusOK,
 					wantMessages: []jsonrpc.Message{
 						resp(201, &CallToolResult{Content: []Content{}}, nil),
-						resp(202, &CallToolResult{Content: []Content{}}, nil),
 					},
 				},
 			},
@@ -1310,8 +1311,9 @@ func textContent(t *testing.T, res *CallToolResult) string {
 }
 
 func TestTokenInfo(t *testing.T) {
-	defer func(b bool) { testAuth = b }(testAuth)
-	testAuth = true
+	oldAuth := testAuth.Load()
+	defer testAuth.Store(oldAuth)
+	testAuth.Store(true)
 	ctx := context.Background()
 
 	// Create a server with a tool that returns TokenInfo.
@@ -1430,3 +1432,57 @@ func TestStreamableGET(t *testing.T) {
 		t.Errorf("GET with session ID: got status %d, want %d", got, want)
 	}
 }
+
+func TestStreamableClientContextPropagation(t *testing.T) {
+	type contextKey string
+	const testKey = contextKey("test-key")
+	const testValue = "test-value"
+
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	ctx2 := context.WithValue(ctx, testKey, testValue)
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+		switch req.Method {
+		case "POST":
+			w.Header().Set("Content-Type", "application/json")
+			w.Header().Set("Mcp-Session-Id", "test-session")
+			w.WriteHeader(http.StatusOK)
+			w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26","capabilities":{},"serverInfo":{"name":"test","version":"1.0"}}}`))
+		case "GET":
+			w.Header().Set("Content-Type", "text/event-stream")
+			w.WriteHeader(http.StatusOK)
+		case "DELETE":
+			w.WriteHeader(http.StatusNoContent)
+		}
+	}))
+	defer server.Close()
+
+	transport := &StreamableClientTransport{Endpoint: server.URL}
+	conn, err := transport.Connect(ctx2)
+	if err != nil {
+		t.Fatalf("Connect failed: %v", err)
+	}
+	defer conn.Close()
+
+	streamableConn, ok := conn.(*streamableClientConn)
+	if !ok {
+		t.Fatalf("Expected *streamableClientConn, got %T", conn)
+	}
+
+	if got := streamableConn.ctx.Value(testKey); got != testValue {
+		t.Errorf("Context value not propagated: got %v, want %v", got, testValue)
+	}
+
+	if streamableConn.ctx.Done() == nil {
+		t.Error("Connection context is not cancellable")
+	}
+
+	cancel()
+	select {
+	case <-streamableConn.ctx.Done():
+	case <-time.After(100 * time.Millisecond):
+		t.Error("Connection context was not cancelled when parent was cancelled")
+	}
+
+}
diff --git a/mcp/tool_example_test.go b/mcp/tool_example_test.go
index 8f3fbbe..8efc3ee 100644
--- a/mcp/tool_example_test.go
+++ b/mcp/tool_example_test.go
@@ -9,12 +9,56 @@ import (
 	"encoding/json"
 	"fmt"
 	"log"
+	"reflect"
 	"time"
 
 	"github.com/google/jsonschema-go/jsonschema"
 	"github.com/modelcontextprotocol/go-sdk/mcp"
 )
 
+func ExampleServer_AddTool_rawSchema() {
+	// In some scenarios, you may want your server to be a pass-through, with
+	// JSON schema coming from another source. Or perhaps you want to implement
+	// tool validation using a different JSON schema library.
+	//
+	// For these cases, you can use [mcp.Server.AddTool], which is the "raw" form
+	// of the API. Note that it is the caller's responsibility to validate inputs
+	// and outputs.
+	server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil)
+	server.AddTool(&mcp.Tool{
+		Name:        "greet",
+		InputSchema: json.RawMessage(`{"type":"object","properties":{"user":{"type":"string"}}}`),
+	}, func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+		// Note: no validation!
+		var args struct{ User string }
+		if err := json.Unmarshal(req.Params.Arguments, &args); err != nil {
+			// TODO: we should use a jsonrpc error here, to be consistent with other
+			// SDKs.
+			return nil, err
+		}
+		return &mcp.CallToolResult{
+			Content: []mcp.Content{&mcp.TextContent{Text: "Hi " + args.User}},
+		}, nil
+	})
+
+	ctx := context.Background()
+	session, err := connect(ctx, server)
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer session.Close()
+
+	res, err := session.CallTool(ctx, &mcp.CallToolParams{
+		Name:      "greet",
+		Arguments: map[string]any{"user": "you"},
+	})
+	if err != nil {
+		log.Fatal(err)
+	}
+	fmt.Println(res.Content[0].(*mcp.TextContent).Text)
+	// Output: Hi you
+}
+
 func ExampleAddTool_customMarshalling() {
 	// Sometimes when you want to customize the input or output schema for a
 	// tool, you need to customize the schema of a single helper type that's used
@@ -34,8 +78,8 @@ func ExampleAddTool_customMarshalling() {
 	// In this case, you can use jsonschema.For along with jsonschema.ForOptions
 	// to customize the schema inference for your custom type.
 	inputSchema, err := jsonschema.For[Input](&jsonschema.ForOptions{
-		TypeSchemas: map[any]*jsonschema.Schema{
-			MyDate{}: {Type: "string"},
+		TypeSchemas: map[reflect.Type]*jsonschema.Schema{
+			reflect.TypeFor[MyDate](): {Type: "string"},
 		},
 	})
 	if err != nil {
@@ -67,7 +111,7 @@ func ExampleAddTool_customMarshalling() {
 	}
 	// Output:
 	// my_tool {
-	// 	"type": "object",
+	// 	"additionalProperties": false,
 	// 	"properties": {
 	// 		"end": {
 	// 			"type": "string"
@@ -79,7 +123,7 @@ func ExampleAddTool_customMarshalling() {
 	// 			"type": "string"
 	// 		}
 	// 	},
-	// 	"additionalProperties": false
+	// 	"type": "object"
 	// }
 }
 
@@ -152,9 +196,9 @@ func ExampleAddTool_complexSchema() {
 	// !+customschemas
 
 	// Distinguished Go types allow custom schemas to be reused during inference.
-	customSchemas := map[any]*jsonschema.Schema{
-		Probability(0):  {Type: "number", Minimum: jsonschema.Ptr(0.0), Maximum: jsonschema.Ptr(1.0)},
-		WeatherType(""): {Type: "string", Enum: []any{Sunny, PartlyCloudy, Cloudy, Rainy, Snowy}},
+	customSchemas := map[reflect.Type]*jsonschema.Schema{
+		reflect.TypeFor[Probability](): {Type: "number", Minimum: jsonschema.Ptr(0.0), Maximum: jsonschema.Ptr(1.0)},
+		reflect.TypeFor[WeatherType](): {Type: "string", Enum: []any{Sunny, PartlyCloudy, Cloudy, Rainy, Snowy}},
 	}
 	opts := &jsonschema.ForOptions{TypeSchemas: customSchemas}
 	in, err := jsonschema.For[WeatherInput](opts)
@@ -199,9 +243,9 @@ func ExampleAddTool_complexSchema() {
 		}
 		// Formatting the entire schemas would be too much output.
 		// Just check that our customizations were effective.
-		fmt.Println("max days:", *t.InputSchema.Properties["days"].Maximum)
-		fmt.Println("max confidence:", *t.OutputSchema.Properties["confidence"].Maximum)
-		fmt.Println("weather types:", t.OutputSchema.Properties["dailyForecast"].Items.Properties["type"].Enum)
+		fmt.Println("max days:", jsonPath(t.InputSchema, "properties", "days", "maximum"))
+		fmt.Println("max confidence:", jsonPath(t.OutputSchema, "properties", "confidence", "maximum"))
+		fmt.Println("weather types:", jsonPath(t.OutputSchema, "properties", "dailyForecast", "items", "properties", "type", "enum"))
 	}
 	// Output:
 	// max days: 10
@@ -217,3 +261,10 @@ func connect(ctx context.Context, server *mcp.Server) (*mcp.ClientSession, error
 	client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil)
 	return client.Connect(ctx, t2, nil)
 }
+
+func jsonPath(s any, path ...string) any {
+	if len(path) == 0 {
+		return s
+	}
+	return jsonPath(s.(map[string]any)[path[0]], path[1:]...)
+}
diff --git a/mcp/tool_test.go b/mcp/tool_test.go
index ef26e9d..c440fcc 100644
--- a/mcp/tool_test.go
+++ b/mcp/tool_test.go
@@ -66,7 +66,7 @@ func TestToolErrorHandling(t *testing.T) {
 	// Create a tool that returns a structured error
 	structuredErrorHandler := func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, any, error) {
 		return nil, nil, &jsonrpc2.WireError{
-			Code:    CodeInvalidParams,
+			Code:    codeInvalidParams,
 			Message: "internal server error",
 		}
 	}
@@ -111,8 +111,8 @@ func TestToolErrorHandling(t *testing.T) {
 			t.Fatalf("expected WireError, got %[1]T: %[1]v", err)
 		}
 
-		if wireErr.Code != CodeInvalidParams {
-			t.Errorf("expected error code %d, got %d", CodeInvalidParams, wireErr.Code)
+		if wireErr.Code != codeInvalidParams {
+			t.Errorf("expected error code %d, got %d", codeInvalidParams, wireErr.Code)
 		}
 	})