Repository: modelcontextprotocol/go-sdk

Issue:
How to check client capabilities and handle unsupported capability errors?
**Is your feature request related to a problem? Please describe.**
I have two semi-related questions, caused by uncertainty of how to check client capabilities from an MCP server.
1. How can I checking the client capabilities before using them?
2. Can you expos the errors from internal/jsonrpc so I can check for them? (is this a possible solution?)

**re 1: How to check Capabilities**
I'd like to figure out if a MCP client supports a specific MCP server capability (e.g., `ListRoots`). I'm assuming client capabilities by reading the fields in the `mcp.ClientCapabilities` struct- Is this advisable?

Also, the Roots field is a value struct, so I can't check for `nil` on that like I can with the other capabilities fields `Sampling` or `Elicitation`: https://github.com/modelcontextprotocol/go-sdk/blob/d256a9c1094ee4044dc3fa6e55d3559df0047107/mcp/protocol.go#L183-L195

**re 2: Error types from internal/jsonrpc**
When using the elicitation/sampleing/logging capabilities, the examples just return an error to the client. The don't differentiate between the different types, or use any `errors.Is()`. So when a client doesn't support a capability (e.g., roots), the SDK returns a JSON-RPC error code -32601 (Method Not Found). However, `ErrMethodNotFound` is in the internal jsonrpc2 package and cannot be imported by my project, so I can't use `errors.Is()` to check if an error from ListRoots(), CreateMessage(), or Elicit() is due to an unsupported client capability.

**Describe the solution you'd like**

1. Consistent capability structure: Make Roots a pointer type like the other capabilities:

```go
type ClientCapabilities struct {
	Roots       *RootsCapabilities       `json:"roots,omitempty"`  // Changed from inline struct
	Sampling    *SamplingCapabilities    `json:"sampling,omitempty"`
	Elicitation *ElicitationCapabilities `json:"elicitation,omitempty"`
}

type RootsCapabilities struct {
	ListChanged bool `json:"listChanged,omitempty"`
}
```
This would allow checking `Capabilities.Roots != nil` to determine if roots are supported at all.

2. Export JSON-RPC errors: Export common JSON-RPC errors from the public jsonrpc package:

```go
// jsonrpc/jsonrpc.go
var (
	ErrMethodNotFound = internal.ErrMethodNotFound
	ErrInvalidParams  = internal.ErrInvalidParams
	// etc.
)
```

This would enable library authors to use `errors.Is(err, jsonrpc.ErrMethodNotFound)` to detect specific error conditions.

**Describe alternatives you've considered**

Right now I need to do string matching on the error, or infer client capabilities from reading the `ClientCapabilities`.

**Additional context**
Possibly related to #166
Thank you for reading, and designing such a useful library!


Hints from issue comments:
@findleyr: CC @jba 

The way this is supposed to work is that you'd check for the presence of the Roots capability. The fact that the Roots client capability is not a distinguished type is almost certainly an oversight. It looks like it dates back to an earlier version of the SDK, and was somehow un-pointerized in https://go-review.git.corp.google.com/c/tools/+/672575.

Given that we have a backward compatibility promise at this point, it may be too late to fix this :(. I'm not sure if we can make an exception for what appears to clearly be a bug.@findleyr: Well, this is unfortunate. I've drafted a workaround in #608, for discussion.

Regarding errors: yes I agree we should expose distinguished errors. I think this is largely covered by #452.

Solution patch:
diff --git a/docs/rough_edges.md b/docs/rough_edges.md
index 5c732bd..d6dc826 100644
--- a/docs/rough_edges.md
+++ b/docs/rough_edges.md
@@ -30,3 +30,9 @@ v2.
 
 - `AudioContent.MarshalJSON` should have had a pointer receiver, to be
   consistent with other content types.
+
+- `ClientCapabilities.Roots` should have been a distinguished struct pointer
+  ([see #607](https://github.com/modelcontextprotocol/go-sdk/issues/607)).
+
+  **Workaround**: use `ClientCapabilities.RootsV2`, which aligns with the
+  semantics of other capability fields.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index c22a279..b8cc776 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -51,7 +51,7 @@ func ExampleLoggingTransport() {
 
 	// Output:
 	// read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.0.1"}}}
-	// write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"roots":{"listChanged":true}},"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18"}}
+	// write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18","capabilities":{"roots":{"listChanged":true}}}}
 	// write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}}
 }
 ```
diff --git a/internal/docs/rough_edges.src.md b/internal/docs/rough_edges.src.md
index ff95b26..4566032 100644
--- a/internal/docs/rough_edges.src.md
+++ b/internal/docs/rough_edges.src.md
@@ -29,3 +29,9 @@ v2.
 
 - `AudioContent.MarshalJSON` should have had a pointer receiver, to be
   consistent with other content types.
+
+- `ClientCapabilities.Roots` should have been a distinguished struct pointer
+  ([see #607](https://github.com/modelcontextprotocol/go-sdk/issues/607)).
+
+  **Workaround**: use `ClientCapabilities.RootsV2`, which aligns with the
+  semantics of other capability fields.
diff --git a/mcp/client.go b/mcp/client.go
index b862c79..612b12f 100644
--- a/mcp/client.go
+++ b/mcp/client.go
@@ -131,7 +131,11 @@ type ClientSessionOptions struct {
 
 func (c *Client) capabilities() *ClientCapabilities {
 	caps := &ClientCapabilities{}
+	// Due to an oversight (#607), roots require special handling.
 	caps.Roots.ListChanged = true
+	caps.RootsV2 = &RootCapabilities{
+		ListChanged: true,
+	}
 	if c.opts.CreateMessageHandler != nil {
 		caps.Sampling = &SamplingCapabilities{}
 	}
diff --git a/mcp/protocol.go b/mcp/protocol.go
index 8a88f8e..563b379 100644
--- a/mcp/protocol.go
+++ b/mcp/protocol.go
@@ -177,23 +177,62 @@ func (x *CancelledParams) isParams()              {}
 func (x *CancelledParams) GetProgressToken() any  { return getProgressToken(x) }
 func (x *CancelledParams) SetProgressToken(t any) { setProgressToken(x, t) }
 
+// RootCapabilities describes a client's support for roots.
+type RootCapabilities struct {
+	// ListChanged reports whether the client supports notifications for
+	// changes to the roots list.
+	ListChanged bool `json:"listChanged,omitempty"`
+}
+
 // Capabilities a client may support. Known capabilities are defined here, in
 // this schema, but this is not a closed set: any client can define its own,
 // additional capabilities.
 type ClientCapabilities struct {
-	// Experimental, non-standard capabilities that the client supports.
+	// Experimental reports non-standard capabilities that the client supports.
 	Experimental map[string]any `json:"experimental,omitempty"`
-	// Present if the client supports listing roots.
+	// Roots describes the client's support for roots.
+	//
+	// Deprecated: use RootsV2. As described in #607, Roots should have been a
+	// pointer to a RootCapabilities value. Roots will be continue to be
+	// populated, but any new fields will only be added in the RootsV2 field.
 	Roots struct {
-		// Whether the client supports notifications for changes to the roots list.
+		// ListChanged reports whether the client supports notifications for
+		// changes to the roots list.
 		ListChanged bool `json:"listChanged,omitempty"`
 	} `json:"roots,omitempty"`
-	// Present if the client supports sampling from an LLM.
+	// RootsV2 is present if the client supports roots.
+	RootsV2 *RootCapabilities `json:"-"`
+	// Sampling is present if the client supports sampling from an LLM.
 	Sampling *SamplingCapabilities `json:"sampling,omitempty"`
-	// Present if the client supports elicitation from the server.
+	// Elicitation is present if the client supports elicitation from the server.
 	Elicitation *ElicitationCapabilities `json:"elicitation,omitempty"`
 }
 
+func (c *ClientCapabilities) toV2() *clientCapabilitiesV2 {
+	return &clientCapabilitiesV2{
+		ClientCapabilities: *c,
+		Roots:              c.RootsV2,
+	}
+}
+
+// clientCapabilitiesV2 is a version of ClientCapabilities that fixes the bug
+// described in #607: Roots should have been a pointer to value type
+// RootCapabilities.
+type clientCapabilitiesV2 struct {
+	ClientCapabilities
+	Roots *RootCapabilities `json:"roots,omitempty"`
+}
+
+func (c *clientCapabilitiesV2) toV1() *ClientCapabilities {
+	caps := c.ClientCapabilities
+	caps.RootsV2 = c.Roots
+	// Sync Roots from RootsV2 for backward compatibility (#607).
+	if caps.RootsV2 != nil {
+		caps.Roots = *caps.RootsV2
+	}
+	return &caps
+}
+
 type CompleteParamsArgument struct {
 	// The name of the argument
 	Name string `json:"name"`
@@ -373,27 +412,53 @@ type GetPromptResult struct {
 
 func (*GetPromptResult) isResult() {}
 
+// InitializeParams is sent by the client to initialize the session.
 type InitializeParams struct {
 	// This property is reserved by the protocol to allow clients and servers to
 	// attach additional metadata to their responses.
-	Meta         `json:"_meta,omitempty"`
+	Meta `json:"_meta,omitempty"`
+	// Capabilities describes the client's capabilities.
 	Capabilities *ClientCapabilities `json:"capabilities"`
-	ClientInfo   *Implementation     `json:"clientInfo"`
-	// The latest version of the Model Context Protocol that the client supports.
-	// The client may decide to support older versions as well.
+	// ClientInfo provides information about the client.
+	ClientInfo *Implementation `json:"clientInfo"`
+	// ProtocolVersion is the latest version of the Model Context Protocol that
+	// the client supports.
 	ProtocolVersion string `json:"protocolVersion"`
 }
 
+func (p *InitializeParams) toV2() *initializeParamsV2 {
+	return &initializeParamsV2{
+		InitializeParams: *p,
+		Capabilities:     p.Capabilities.toV2(),
+	}
+}
+
+// initializeParamsV2 works around the mistake in #607: Capabilities.Roots
+// should have been a pointer.
+type initializeParamsV2 struct {
+	InitializeParams
+	Capabilities *clientCapabilitiesV2 `json:"capabilities"`
+}
+
+func (p *initializeParamsV2) toV1() *InitializeParams {
+	p1 := p.InitializeParams
+	if p.Capabilities != nil {
+		p1.Capabilities = p.Capabilities.toV1()
+	}
+	return &p1
+}
+
 func (x *InitializeParams) isParams()              {}
 func (x *InitializeParams) GetProgressToken() any  { return getProgressToken(x) }
 func (x *InitializeParams) SetProgressToken(t any) { setProgressToken(x, t) }
 
-// After receiving an initialize request from the client, the server sends this
-// response.
+// InitializeResult is sent by the server in response to an initialize request
+// from the client.
 type InitializeResult struct {
 	// This property is reserved by the protocol to allow clients and servers to
 	// attach additional metadata to their responses.
-	Meta         `json:"_meta,omitempty"`
+	Meta `json:"_meta,omitempty"`
+	// Capabilities describes the server's capabilities.
 	Capabilities *ServerCapabilities `json:"capabilities"`
 	// Instructions describing how to use the server and its features.
 	//
@@ -411,8 +476,8 @@ type InitializeResult struct {
 func (*InitializeResult) isResult() {}
 
 type InitializedParams struct {
-	// This property is reserved by the protocol to allow clients and servers to
-	// attach additional metadata to their responses.
+	// Meta is reserved by the protocol to allow clients and servers to attach
+	// additional metadata to their responses.
 	Meta `json:"_meta,omitempty"`
 }
 
@@ -875,7 +940,10 @@ func (x *RootsListChangedParams) isParams()              {}
 func (x *RootsListChangedParams) GetProgressToken() any  { return getProgressToken(x) }
 func (x *RootsListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) }
 
-// SamplingCapabilities describes the capabilities for sampling.
+// TODO: to be consistent with ServerCapabilities, move the capability types
+// below directly above ClientCapabilities.
+
+// SamplingCapabilities describes the client's support for sampling.
 type SamplingCapabilities struct{}
 
 // ElicitationCapabilities describes the capabilities for elicitation.
@@ -1160,50 +1228,52 @@ type Implementation struct {
 	Icons []Icon `json:"icons,omitempty"`
 }
 
-// Present if the server supports argument autocompletion suggestions.
+// CompletionCapabilities describes the server's support for argument autocompletion.
 type CompletionCapabilities struct{}
 
-// Present if the server supports sending log messages to the client.
+// LoggingCapabilities describes the server's support for sending log messages to the client.
 type LoggingCapabilities struct{}
 
-// Present if the server offers any prompt templates.
+// PromptCapabilities describes the server's support for prompts.
 type PromptCapabilities struct {
 	// Whether this server supports notifications for changes to the prompt list.
 	ListChanged bool `json:"listChanged,omitempty"`
 }
 
-// Present if the server offers any resources to read.
+// ResourceCapabilities describes the server's support for resources.
 type ResourceCapabilities struct {
-	// Whether this server supports notifications for changes to the resource list.
+	// ListChanged reports whether the client supports notifications for
+	// changes to the resource list.
 	ListChanged bool `json:"listChanged,omitempty"`
-	// Whether this server supports subscribing to resource updates.
+	// Subscribe reports whether this server supports subscribing to resource
+	// updates.
 	Subscribe bool `json:"subscribe,omitempty"`
 }
 
-// Capabilities that a server may support. Known capabilities are defined here,
-// in this schema, but this is not a closed set: any server can define its own,
-// additional capabilities.
+// ToolCapabilities describes the server's support for tools.
+type ToolCapabilities struct {
+	// ListChanged reports whether the client supports notifications for
+	// changes to the tool list.
+	ListChanged bool `json:"listChanged,omitempty"`
+}
+
+// ServerCapabilities describes capabilities that a server supports.
 type ServerCapabilities struct {
-	// Present if the server supports argument autocompletion suggestions.
-	Completions *CompletionCapabilities `json:"completions,omitempty"`
-	// Experimental, non-standard capabilities that the server supports.
+	// Experimental reports non-standard capabilities that the server supports.
 	Experimental map[string]any `json:"experimental,omitempty"`
-	// Present if the server supports sending log messages to the client.
+	// Completions is present if the server supports argument autocompletion
+	// suggestions.
+	Completions *CompletionCapabilities `json:"completions,omitempty"`
+	// Logging is present if the server supports log messages.
 	Logging *LoggingCapabilities `json:"logging,omitempty"`
-	// Present if the server offers any prompt templates.
+	// Prompts is present if the server supports prompts.
 	Prompts *PromptCapabilities `json:"prompts,omitempty"`
-	// Present if the server offers any resources to read.
+	// Resources is present if the server supports resourcs.
 	Resources *ResourceCapabilities `json:"resources,omitempty"`
-	// Present if the server offers any tools to call.
+	// Tools is present if the supports tools.
 	Tools *ToolCapabilities `json:"tools,omitempty"`
 }
 
-// Present if the server offers any tools to call.
-type ToolCapabilities struct {
-	// Whether this server supports notifications for changes to the tool list.
-	ListChanged bool `json:"listChanged,omitempty"`
-}
-
 const (
 	methodCallTool                  = "tools/call"
 	notificationCancelled           = "notifications/cancelled"
diff --git a/mcp/server.go b/mcp/server.go
index 5f5a4a5..a8f4885 100644
--- a/mcp/server.go
+++ b/mcp/server.go
@@ -1138,7 +1138,7 @@ func (s *Server) AddReceivingMiddleware(middleware ...Middleware) {
 // curating these method flags.
 var serverMethodInfos = map[string]methodInfo{
 	methodComplete:               newServerMethodInfo(serverMethod((*Server).complete), 0),
-	methodInitialize:             newServerMethodInfo(serverSessionMethod((*ServerSession).initialize), 0),
+	methodInitialize:             initializeMethodInfo(),
 	methodPing:                   newServerMethodInfo(serverSessionMethod((*ServerSession).ping), missingParamsOK),
 	methodListPrompts:            newServerMethodInfo(serverMethod((*Server).listPrompts), missingParamsOK),
 	methodGetPrompt:              newServerMethodInfo(serverMethod((*Server).getPrompt), 0),
@@ -1156,6 +1156,25 @@ var serverMethodInfos = map[string]methodInfo{
 	notificationProgress:         newServerMethodInfo(serverSessionMethod((*ServerSession).callProgressNotificationHandler), notification),
 }
 
+// initializeMethodInfo handles the workaround for #607: we must set
+// params.Capabilities.RootsV2.
+func initializeMethodInfo() methodInfo {
+	info := newServerMethodInfo(serverSessionMethod((*ServerSession).initialize), 0)
+	info.unmarshalParams = func(m json.RawMessage) (Params, error) {
+		var params *initializeParamsV2
+		if m != nil {
+			if err := json.Unmarshal(m, &params); err != nil {
+				return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, params, err)
+			}
+		}
+		if params == nil {
+			return nil, fmt.Errorf(`missing required "params"`)
+		}
+		return params.toV1(), nil
+	}
+	return info
+}
+
 func (ss *ServerSession) sendingMethodInfos() map[string]methodInfo { return clientMethodInfos }
 
 func (ss *ServerSession) receivingMethodInfos() map[string]methodInfo { return serverMethodInfos }
diff --git a/mcp/shared.go b/mcp/shared.go
index c2e50d9..5e1dc0c 100644
--- a/mcp/shared.go
+++ b/mcp/shared.go
@@ -94,14 +94,22 @@ func defaultSendingMethodHandler(ctx context.Context, method string, req Request
 		// This can be called from user code, with an arbitrary value for method.
 		return nil, jsonrpc2.ErrNotHandled
 	}
+	params := req.GetParams()
+	if initParams, ok := params.(*InitializeParams); ok {
+		// Fix the marshaling of initialize params, to work around #607.
+		//
+		// The initialize params we produce should never be nil, nor have nil
+		// capabilities, so any panic here is a bug.
+		params = initParams.toV2()
+	}
 	// Notifications don't have results.
 	if strings.HasPrefix(method, "notifications/") {
-		return nil, req.GetSession().getConn().Notify(ctx, method, req.GetParams())
+		return nil, req.GetSession().getConn().Notify(ctx, method, params)
 	}
 	// Create the result to unmarshal into.
 	// The concrete type of the result is the return type of the receiving function.
 	res := info.newResult()
-	if err := call(ctx, req.GetSession().getConn(), method, req.GetParams(), res); err != nil {
+	if err := call(ctx, req.GetSession().getConn(), method, params, res); err != nil {
 		return nil, err
 	}
 	return res, nil
diff --git a/oauthex/auth_meta.go b/oauthex/auth_meta.go
index 3723318..9aa0c8d 100644
--- a/oauthex/auth_meta.go
+++ b/oauthex/auth_meta.go
@@ -22,6 +22,10 @@ import (
 // Not supported:
 // - signed metadata
 //
+// Note: URL fields in this struct are validated by validateAuthServerMetaURLs to
+// prevent XSS attacks. If you add a new URL field, you must also add it to that
+// function.
+//
 // [RFC 8414]: https://tools.ietf.org/html/rfc8414)
 type AuthServerMeta struct {
 	// GENERATED BY GEMINI 2.5.
@@ -144,9 +148,40 @@ func GetAuthServerMeta(ctx context.Context, issuerURL string, c *http.Client) (*
 				return nil, fmt.Errorf("authorization server at %s does not implement PKCE", issuerURL)
 			}
 
+			// Validate endpoint URLs to prevent XSS attacks (see #526).
+			if err := validateAuthServerMetaURLs(asm); err != nil {
+				return nil, err
+			}
+
 			return asm, nil
 		}
 		errs = append(errs, err)
 	}
 	return nil, fmt.Errorf("failed to get auth server metadata from %q: %w", issuerURL, errors.Join(errs...))
 }
+
+// validateAuthServerMetaURLs validates all URL fields in AuthServerMeta
+// to ensure they don't use dangerous schemes that could enable XSS attacks.
+func validateAuthServerMetaURLs(asm *AuthServerMeta) error {
+	urls := []struct {
+		name  string
+		value string
+	}{
+		{"authorization_endpoint", asm.AuthorizationEndpoint},
+		{"token_endpoint", asm.TokenEndpoint},
+		{"jwks_uri", asm.JWKSURI},
+		{"registration_endpoint", asm.RegistrationEndpoint},
+		{"service_documentation", asm.ServiceDocumentation},
+		{"op_policy_uri", asm.OpPolicyURI},
+		{"op_tos_uri", asm.OpTOSURI},
+		{"revocation_endpoint", asm.RevocationEndpoint},
+		{"introspection_endpoint", asm.IntrospectionEndpoint},
+	}
+
+	for _, u := range urls {
+		if err := checkURLScheme(u.value); err != nil {
+			return fmt.Errorf("%s: %w", u.name, err)
+		}
+	}
+	return nil
+}
diff --git a/oauthex/dcr.go b/oauthex/dcr.go
index 75ce296..c64cb8c 100644
--- a/oauthex/dcr.go
+++ b/oauthex/dcr.go
@@ -20,6 +20,10 @@ import (
 )
 
 // ClientRegistrationMetadata represents the client metadata fields for the DCR POST request (RFC 7591).
+//
+// Note: URL fields in this struct are validated by validateClientRegistrationURLs
+// to prevent XSS attacks. If you add a new URL field, you must also add it to
+// that function.
 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).
@@ -208,6 +212,10 @@ func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta
 		if regResponse.ClientID == "" {
 			return nil, fmt.Errorf("registration response is missing required 'client_id' field")
 		}
+		// Validate URL fields to prevent XSS attacks (see #526).
+		if err := validateClientRegistrationURLs(&regResponse.ClientRegistrationMetadata); err != nil {
+			return nil, err
+		}
 		return &regResponse, nil
 	}
 
@@ -221,3 +229,33 @@ func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta
 
 	return nil, fmt.Errorf("registration failed with status %s: %s", resp.Status, string(body))
 }
+
+// validateClientRegistrationURLs validates all URL fields in ClientRegistrationMetadata
+// to ensure they don't use dangerous schemes that could enable XSS attacks.
+func validateClientRegistrationURLs(meta *ClientRegistrationMetadata) error {
+	// Validate redirect URIs
+	for i, uri := range meta.RedirectURIs {
+		if err := checkURLScheme(uri); err != nil {
+			return fmt.Errorf("redirect_uris[%d]: %w", i, err)
+		}
+	}
+
+	// Validate other URL fields
+	urls := []struct {
+		name  string
+		value string
+	}{
+		{"client_uri", meta.ClientURI},
+		{"logo_uri", meta.LogoURI},
+		{"tos_uri", meta.TOSURI},
+		{"policy_uri", meta.PolicyURI},
+		{"jwks_uri", meta.JWKSURI},
+	}
+
+	for _, u := range urls {
+		if err := checkURLScheme(u.value); err != nil {
+			return fmt.Errorf("%s: %w", u.name, err)
+		}
+	}
+	return nil
+}



Test patch:
diff --git a/mcp/client_test.go b/mcp/client_test.go
index a7fb68d..0522c06 100644
--- a/mcp/client_test.go
+++ b/mcp/client_test.go
@@ -202,9 +202,8 @@ func TestClientCapabilities(t *testing.T) {
 			name:            "With initial capabilities",
 			configureClient: func(s *Client) {},
 			wantCapabilities: &ClientCapabilities{
-				Roots: struct {
-					ListChanged bool "json:\"listChanged,omitempty\""
-				}{ListChanged: true},
+				Roots:   RootCapabilities{ListChanged: true},
+				RootsV2: &RootCapabilities{ListChanged: true},
 			},
 		},
 		{
@@ -216,9 +215,8 @@ func TestClientCapabilities(t *testing.T) {
 				},
 			},
 			wantCapabilities: &ClientCapabilities{
-				Roots: struct {
-					ListChanged bool "json:\"listChanged,omitempty\""
-				}{ListChanged: true},
+				Roots:    RootCapabilities{ListChanged: true},
+				RootsV2:  &RootCapabilities{ListChanged: true},
 				Sampling: &SamplingCapabilities{},
 			},
 		},
@@ -232,9 +230,8 @@ func TestClientCapabilities(t *testing.T) {
 				},
 			},
 			wantCapabilities: &ClientCapabilities{
-				Roots: struct {
-					ListChanged bool "json:\"listChanged,omitempty\""
-				}{ListChanged: true},
+				Roots:   RootCapabilities{ListChanged: true},
+				RootsV2: &RootCapabilities{ListChanged: true},
 				Elicitation: &ElicitationCapabilities{
 					Form: &FormElicitationCapabilities{},
 				},
@@ -250,9 +247,8 @@ func TestClientCapabilities(t *testing.T) {
 				},
 			},
 			wantCapabilities: &ClientCapabilities{
-				Roots: struct {
-					ListChanged bool "json:\"listChanged,omitempty\""
-				}{ListChanged: true},
+				Roots:   RootCapabilities{ListChanged: true},
+				RootsV2: &RootCapabilities{ListChanged: true},
 				Elicitation: &ElicitationCapabilities{
 					URL: &URLElicitationCapabilities{},
 				},
@@ -268,9 +264,8 @@ func TestClientCapabilities(t *testing.T) {
 				},
 			},
 			wantCapabilities: &ClientCapabilities{
-				Roots: struct {
-					ListChanged bool "json:\"listChanged,omitempty\""
-				}{ListChanged: true},
+				Roots:   RootCapabilities{ListChanged: true},
+				RootsV2: &RootCapabilities{ListChanged: true},
 				Elicitation: &ElicitationCapabilities{
 					Form: &FormElicitationCapabilities{},
 					URL:  &URLElicitationCapabilities{},
diff --git a/mcp/server_test.go b/mcp/server_test.go
index d8c0df6..b578cbf 100644
--- a/mcp/server_test.go
+++ b/mcp/server_test.go
@@ -17,6 +17,7 @@ import (
 
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/jsonschema-go/jsonschema"
+	"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
 )
 
 type testItem struct {
@@ -600,6 +601,128 @@ func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out
 	}
 }
 
+// TestClientRootCapabilities verifies that the server correctly observes
+// RootsV2 for various client capability configurations. This tests the fix
+// for #607.
+func TestClientRootCapabilities(t *testing.T) {
+	testCases := []struct {
+		name         string
+		capabilities *string // JSON for the capabilities field; nil means omit the field
+		wantRootsV2  *RootCapabilities
+	}{
+		{
+			name:         "capabilities field omitted",
+			capabilities: nil,
+			wantRootsV2:  nil,
+		},
+		{
+			name:         "empty capabilities",
+			capabilities: ptr(`{}`),
+			wantRootsV2:  nil,
+		},
+		{
+			name:         "capabilities with no roots",
+			capabilities: ptr(`{"sampling": {}}`),
+			wantRootsV2:  nil,
+		},
+		{
+			name:         "capabilities with empty roots",
+			capabilities: ptr(`{"roots": {}}`),
+			wantRootsV2:  &RootCapabilities{ListChanged: false},
+		},
+		{
+			name:         "capabilities with roots without listChanged",
+			capabilities: ptr(`{"roots": {"listChanged": false}}`),
+			wantRootsV2:  &RootCapabilities{ListChanged: false},
+		},
+		{
+			name:         "capabilities with roots with listChanged",
+			capabilities: ptr(`{"roots": {"listChanged": true}}`),
+			wantRootsV2:  &RootCapabilities{ListChanged: true},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			// Create a minimal server.
+			impl := &Implementation{Name: "testServer", Version: "v1.0.0"}
+			s := NewServer(impl, nil)
+
+			// Connect the server.
+			cTransport, sTransport := NewInMemoryTransports()
+			ss, err := s.Connect(ctx, sTransport, nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			// Connect the client JSON-RPC connection (raw, no client).
+			cConn, err := cTransport.Connect(ctx)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			// Build initialize params, optionally including capabilities.
+			var initParams json.RawMessage
+			if tc.capabilities != nil {
+				initParams = json.RawMessage(`{
+					"protocolVersion": "2025-06-18",
+					"capabilities": ` + *tc.capabilities + `,
+					"clientInfo": {"name": "TestClient", "version": "1.0.0"}
+				}`)
+			} else {
+				initParams = json.RawMessage(`{
+					"protocolVersion": "2025-06-18",
+					"clientInfo": {"name": "TestClient", "version": "1.0.0"}
+				}`)
+			}
+
+			initReq, err := jsonrpc2.NewCall(jsonrpc2.Int64ID(1), "initialize", initParams)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			if err := cConn.Write(ctx, initReq); err != nil {
+				t.Fatalf("Write failed: %v", err)
+			}
+
+			// Read the initialize response.
+			msg, err := cConn.Read(ctx)
+			if err != nil {
+				t.Fatalf("Read failed: %v", err)
+			}
+			resp, ok := msg.(*jsonrpc2.Response)
+			if !ok {
+				t.Fatalf("expected Response, got %T", msg)
+			}
+			if resp.Error != nil {
+				t.Fatalf("initialize failed: %v", resp.Error)
+			}
+
+			// Verify that the server session has the correct RootsV2 value.
+			params := ss.InitializeParams()
+			if params == nil {
+				t.Fatal("InitializeParams is nil")
+			}
+
+			var gotRootsV2 *RootCapabilities
+			if params.Capabilities != nil {
+				gotRootsV2 = params.Capabilities.RootsV2
+			}
+			if diff := cmp.Diff(tc.wantRootsV2, gotRootsV2); diff != "" {
+				t.Errorf("RootsV2 mismatch (-want +got):\n%s", diff)
+			}
+
+			// Close the client connection.
+			if err := cConn.Close(); err != nil {
+				t.Fatalf("Stream.Close failed: %v", err)
+			}
+			ss.Wait()
+		})
+	}
+}
+
 // TODO: move this to tool_test.go
 func TestToolForSchemas(t *testing.T) {
 	// Validate that toolForErr handles schemas properly.
diff --git a/mcp/transport_example_test.go b/mcp/transport_example_test.go
index 7390ea4..bb032f1 100644
--- a/mcp/transport_example_test.go
+++ b/mcp/transport_example_test.go
@@ -46,7 +46,7 @@ func ExampleLoggingTransport() {
 
 	// Output:
 	// read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.0.1"}}}
-	// write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"roots":{"listChanged":true}},"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18"}}
+	// write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18","capabilities":{"roots":{"listChanged":true}}}}
 	// write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}}
 }
 
diff --git a/oauthex/url_scheme_test.go b/oauthex/url_scheme_test.go
new file mode 100644
index 0000000..531a1f9
--- /dev/null
+++ b/oauthex/url_scheme_test.go
@@ -0,0 +1,314 @@
+// 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 oauthex
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+// TestCheckURLScheme tests the checkURLScheme function directly.
+func TestCheckURLScheme(t *testing.T) {
+	tests := []struct {
+		name    string
+		url     string
+		wantErr bool
+	}{
+		// Valid schemes
+		{"empty string", "", false},
+		{"https url", "https://example.com/path", false},
+		{"http url", "http://example.com/path", false},
+		{"custom scheme", "myapp://callback", false},
+
+		// Dangerous schemes that should be blocked
+		{"javascript scheme", "javascript:alert('xss')", true},
+		{"javascript uppercase", "JAVASCRIPT:alert('xss')", true},
+		{"javascript mixed case", "JaVaScRiPt:alert('xss')", true},
+		{"data scheme", "data:text/html,<script>alert('xss')</script>", true},
+		{"data uppercase", "DATA:text/html,test", true},
+		{"vbscript scheme", "vbscript:msgbox('xss')", true},
+		{"vbscript uppercase", "VBSCRIPT:test", true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := checkURLScheme(tt.url)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("checkURLScheme(%q): got err %v, want err %v", tt.url, err != nil, tt.wantErr)
+			}
+		})
+	}
+}
+
+// TestValidateAuthServerMetaURLs tests validation of AuthServerMeta URL fields.
+func TestValidateAuthServerMetaURLs(t *testing.T) {
+	validMeta := &AuthServerMeta{
+		Issuer:                "https://auth.example.com",
+		AuthorizationEndpoint: "https://auth.example.com/authorize",
+		TokenEndpoint:         "https://auth.example.com/token",
+		JWKSURI:               "https://auth.example.com/.well-known/jwks.json",
+		RegistrationEndpoint:  "https://auth.example.com/register",
+		ServiceDocumentation:  "https://docs.example.com",
+		OpPolicyURI:           "https://example.com/policy",
+		OpTOSURI:              "https://example.com/tos",
+		RevocationEndpoint:    "https://auth.example.com/revoke",
+		IntrospectionEndpoint: "https://auth.example.com/introspect",
+	}
+
+	t.Run("valid metadata", func(t *testing.T) {
+		if err := validateAuthServerMetaURLs(validMeta); err != nil {
+			t.Errorf("validateAuthServerMetaURLs(): got err %v, want nil", err)
+		}
+	})
+
+	// Test each URL field with a dangerous scheme
+	dangerousFields := []struct {
+		name     string
+		setField func(*AuthServerMeta)
+	}{
+		{"authorization_endpoint", func(m *AuthServerMeta) { m.AuthorizationEndpoint = "javascript:alert(1)" }},
+		{"token_endpoint", func(m *AuthServerMeta) { m.TokenEndpoint = "javascript:alert(1)" }},
+		{"jwks_uri", func(m *AuthServerMeta) { m.JWKSURI = "data:text/html,test" }},
+		{"registration_endpoint", func(m *AuthServerMeta) { m.RegistrationEndpoint = "vbscript:test" }},
+		{"service_documentation", func(m *AuthServerMeta) { m.ServiceDocumentation = "javascript:void(0)" }},
+		{"op_policy_uri", func(m *AuthServerMeta) { m.OpPolicyURI = "javascript:x" }},
+		{"op_tos_uri", func(m *AuthServerMeta) { m.OpTOSURI = "data:,test" }},
+		{"revocation_endpoint", func(m *AuthServerMeta) { m.RevocationEndpoint = "javascript:1" }},
+		{"introspection_endpoint", func(m *AuthServerMeta) { m.IntrospectionEndpoint = "javascript:2" }},
+	}
+
+	for _, tt := range dangerousFields {
+		t.Run("dangerous "+tt.name, func(t *testing.T) {
+			// Copy valid metadata
+			meta := *validMeta
+			// Set one field to a dangerous value
+			tt.setField(&meta)
+
+			err := validateAuthServerMetaURLs(&meta)
+			if err == nil {
+				t.Errorf("validateAuthServerMetaURLs(): got nil error, want error for dangerous %s", tt.name)
+			} else if !strings.Contains(err.Error(), tt.name) {
+				t.Errorf("validateAuthServerMetaURLs(): got error %v, want error containing %q", err, tt.name)
+			}
+		})
+	}
+
+	t.Run("empty optional fields are valid", func(t *testing.T) {
+		meta := &AuthServerMeta{
+			Issuer:                "https://auth.example.com",
+			AuthorizationEndpoint: "https://auth.example.com/authorize",
+			TokenEndpoint:         "https://auth.example.com/token",
+			JWKSURI:               "https://auth.example.com/.well-known/jwks.json",
+			// All optional fields left empty
+		}
+		if err := validateAuthServerMetaURLs(meta); err != nil {
+			t.Errorf("validateAuthServerMetaURLs(): got err %v, want nil", err)
+		}
+	})
+}
+
+// TestValidateClientRegistrationURLs tests validation of ClientRegistrationMetadata URL fields.
+func TestValidateClientRegistrationURLs(t *testing.T) {
+	validMeta := &ClientRegistrationMetadata{
+		RedirectURIs: []string{"https://app.example.com/callback", "myapp://callback"},
+		ClientURI:    "https://example.com",
+		LogoURI:      "https://example.com/logo.png",
+		TOSURI:       "https://example.com/tos",
+		PolicyURI:    "https://example.com/policy",
+		JWKSURI:      "https://example.com/.well-known/jwks.json",
+	}
+
+	t.Run("valid metadata", func(t *testing.T) {
+		if err := validateClientRegistrationURLs(validMeta); err != nil {
+			t.Errorf("validateClientRegistrationURLs(): got err %v, want nil", err)
+		}
+	})
+
+	t.Run("dangerous redirect_uri", func(t *testing.T) {
+		meta := *validMeta
+		meta.RedirectURIs = []string{"https://safe.com/cb", "javascript:alert(1)"}
+
+		err := validateClientRegistrationURLs(&meta)
+		if err == nil {
+			t.Error("validateClientRegistrationURLs(): got nil error, want error for dangerous redirect_uri")
+		} else if !strings.Contains(err.Error(), "redirect_uris[1]") {
+			t.Errorf("validateClientRegistrationURLs(): got error %v, want error containing \"redirect_uris[1]\"", err)
+		}
+	})
+
+	// Test each URL field with a dangerous scheme
+	dangerousFields := []struct {
+		name     string
+		setField func(*ClientRegistrationMetadata)
+	}{
+		{"client_uri", func(m *ClientRegistrationMetadata) { m.ClientURI = "javascript:alert(1)" }},
+		{"logo_uri", func(m *ClientRegistrationMetadata) { m.LogoURI = "data:image/svg,<script>alert(1)</script>" }},
+		{"tos_uri", func(m *ClientRegistrationMetadata) { m.TOSURI = "vbscript:test" }},
+		{"policy_uri", func(m *ClientRegistrationMetadata) { m.PolicyURI = "javascript:void(0)" }},
+		{"jwks_uri", func(m *ClientRegistrationMetadata) { m.JWKSURI = "data:application/json,{}" }},
+	}
+
+	for _, tt := range dangerousFields {
+		t.Run("dangerous "+tt.name, func(t *testing.T) {
+			meta := *validMeta
+			tt.setField(&meta)
+
+			err := validateClientRegistrationURLs(&meta)
+			if err == nil {
+				t.Errorf("validateClientRegistrationURLs(): got nil error, want error for dangerous %s", tt.name)
+			} else if !strings.Contains(err.Error(), tt.name) {
+				t.Errorf("validateClientRegistrationURLs(): got error %v, want error containing %q", err, tt.name)
+			}
+		})
+	}
+
+	t.Run("empty optional fields are valid", func(t *testing.T) {
+		meta := &ClientRegistrationMetadata{
+			RedirectURIs: []string{"https://app.example.com/callback"},
+			// All optional URL fields left empty
+		}
+		if err := validateClientRegistrationURLs(meta); err != nil {
+			t.Errorf("validateClientRegistrationURLs(): got err %v, want nil", err)
+		}
+	})
+}
+
+// TestGetAuthServerMetaRejectsDangerousURLs tests that GetAuthServerMeta rejects
+// metadata containing dangerous URL schemes.
+func TestGetAuthServerMetaRejectsDangerousURLs(t *testing.T) {
+	tests := []struct {
+		name        string
+		metadata    AuthServerMeta
+		wantErrText string
+	}{
+		{
+			name: "javascript authorization_endpoint",
+			metadata: AuthServerMeta{
+				Issuer:                        "", // Will be set dynamically
+				AuthorizationEndpoint:         "javascript:alert('xss')",
+				TokenEndpoint:                 "https://auth.example.com/token",
+				JWKSURI:                       "https://auth.example.com/.well-known/jwks.json",
+				ResponseTypesSupported:        []string{"code"},
+				CodeChallengeMethodsSupported: []string{"S256"},
+			},
+			wantErrText: "authorization_endpoint",
+		},
+		{
+			name: "data token_endpoint",
+			metadata: AuthServerMeta{
+				Issuer:                        "",
+				AuthorizationEndpoint:         "https://auth.example.com/authorize",
+				TokenEndpoint:                 "data:text/html,<script>alert(1)</script>",
+				JWKSURI:                       "https://auth.example.com/.well-known/jwks.json",
+				ResponseTypesSupported:        []string{"code"},
+				CodeChallengeMethodsSupported: []string{"S256"},
+			},
+			wantErrText: "token_endpoint",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				meta := tt.metadata
+				meta.Issuer = "https://" + r.Host
+				w.Header().Set("Content-Type", "application/json")
+				json.NewEncoder(w).Encode(meta)
+			}))
+			defer server.Close()
+
+			ctx := context.Background()
+			_, err := GetAuthServerMeta(ctx, server.URL, server.Client())
+			if err == nil {
+				t.Fatal("GetAuthServerMeta(): got nil error, want error")
+			}
+			if !strings.Contains(err.Error(), tt.wantErrText) {
+				t.Errorf("GetAuthServerMeta(): got error %v, want error containing %q", err, tt.wantErrText)
+			}
+		})
+	}
+}
+
+// TestGetProtectedResourceMetadataRejectsDangerousURLs tests that
+// GetProtectedResourceMetadataFromID rejects metadata with dangerous authorization server URLs.
+func TestGetProtectedResourceMetadataRejectsDangerousURLs(t *testing.T) {
+	server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		serverURL := "https://" + r.Host
+		meta := ProtectedResourceMetadata{
+			Resource:             serverURL,
+			AuthorizationServers: []string{"javascript:alert('xss')"},
+		}
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(meta)
+	}))
+	defer server.Close()
+
+	ctx := context.Background()
+	_, err := GetProtectedResourceMetadataFromID(ctx, server.URL, server.Client())
+	if err == nil {
+		t.Fatal("GetProtectedResourceMetadataFromID(): got nil error, want error")
+	}
+	if !strings.Contains(err.Error(), "disallowed scheme") {
+		t.Errorf("GetProtectedResourceMetadataFromID(): got error %v, want error containing \"disallowed scheme\"", err)
+	}
+}
+
+// TestRegisterClientRejectsDangerousURLs tests that RegisterClient rejects
+// responses containing dangerous URL schemes.
+func TestRegisterClientRejectsDangerousURLs(t *testing.T) {
+	tests := []struct {
+		name         string
+		responseJSON string
+		wantErrText  string
+	}{
+		{
+			name: "javascript redirect_uri in response",
+			responseJSON: `{
+				"client_id": "test-client",
+				"redirect_uris": ["javascript:alert(1)"]
+			}`,
+			wantErrText: "redirect_uris[0]",
+		},
+		{
+			name: "data client_uri",
+			responseJSON: `{
+				"client_id": "test-client",
+				"redirect_uris": ["https://app.example.com/callback"],
+				"client_uri": "data:text/html,<script>alert(1)</script>"
+			}`,
+			wantErrText: "client_uri",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusCreated)
+				w.Write([]byte(tt.responseJSON))
+			}))
+			defer server.Close()
+
+			ctx := context.Background()
+			clientMeta := &ClientRegistrationMetadata{
+				RedirectURIs: []string{"https://app.example.com/callback"},
+			}
+
+			_, err := RegisterClient(ctx, server.URL+"/register", clientMeta, server.Client())
+			if err == nil {
+				t.Fatal("RegisterClient(): got nil error, want error")
+			}
+			if !strings.Contains(err.Error(), tt.wantErrText) {
+				t.Errorf("RegisterClient(): got error %v, want error containing %q", err, tt.wantErrText)
+			}
+		})
+	}
+}