From b835e07c2659e545679f0f1f7d8a1326cebd4220 Mon Sep 17 00:00:00 2001 From: Chuck Ha Date: Mon, 29 Sep 2025 11:19:05 -0700 Subject: [PATCH 1/5] mcp: fix inconsistent logging new lines (#541) This change keeps the logging consistent with a new line after each log whether an error or not. Fixes #540 Co-authored-by: Chuck Ha --- mcp/transport.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mcp/transport.go b/mcp/transport.go index d2109e7d..a9cfa371 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -230,7 +230,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 +250,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) From c2381a3fa5ce04ba617ad2ea4122c7262b1cf880 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 29 Sep 2025 14:45:47 -0400 Subject: [PATCH 2/5] docs: fix broken links (#542) --- docs/README.md | 10 +++++----- internal/docs/README.src.md | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/README.md b/docs/README.md index b4268c85..9f325075 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/internal/docs/README.src.md b/internal/docs/README.src.md index b252f943..7f7b2247 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) From af4bddf3ea84dd4009074f9438d2c048ae61fc6a Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 29 Sep 2025 15:33:35 -0400 Subject: [PATCH 3/5] README: remove caveats (#543) In preparation for the v1.0.0 release, remove caveats from the README. For #328 --- README.md | 14 +------------- internal/readme/README.src.md | 14 +------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 55cdfc5d..81af0b09 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,13 @@ -# MCP Go SDK v0.8.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.8.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: diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index 3fd305a4..e83c5fc4 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -1,24 +1,12 @@ -# MCP Go SDK v0.8.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.8.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: From 42eb016161956bf6a7fdbe6e54b82b3a67d76b4e Mon Sep 17 00:00:00 2001 From: iamsurajbobade Date: Tue, 30 Sep 2025 16:59:56 +0530 Subject: [PATCH 4/5] 256: added tests --- internal/oauthex/oauth2_test.go | 73 +++++++ internal/oauthex/resource_meta_test.go | 252 +++++++++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 internal/oauthex/resource_meta_test.go diff --git a/internal/oauthex/oauth2_test.go b/internal/oauthex/oauth2_test.go index 9c3da156..587b676d 100644 --- a/internal/oauthex/oauth2_test.go +++ b/internal/oauthex/oauth2_test.go @@ -268,3 +268,76 @@ func (h *fakeResourceHandler) installHandlers(serverURL string) { } })) } + +func Test_checkURLScheme(t *testing.T) { + tests := []struct { + name string + u string + wantErr bool + }{ + { + name: "empty url", + u: "", + wantErr: false, + }, + { + name: "valid http", + u: "http://example.com", + wantErr: false, + }, + { + name: "valid https", + u: "https://example.com", + wantErr: false, + }, + { + name: "valid https with uppercase scheme", + u: "HTTPS://example.com", + wantErr: false, + }, + { + name: "url with no scheme", + u: "example.com", + wantErr: false, + }, + { + name: "disallowed javascript scheme", + u: "javascript:alert('XSS')", + wantErr: true, + }, + { + name: "disallowed javascript scheme with uppercase", + u: "Javascript:alert('XSS')", + wantErr: true, + }, + { + name: "disallowed data scheme", + u: "data:text/html,", + wantErr: true, + }, + { + name: "disallowed vbscript scheme", + u: "vbscript:msgbox(\"XSS\")", + wantErr: true, + }, + { + name: "invalid url format", + u: "://invalid-url", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotErr := checkURLScheme(tt.u) + if gotErr != nil { + if !tt.wantErr { + t.Errorf("checkURLScheme() failed: %v", gotErr) + } + return + } + if tt.wantErr { + t.Fatal("checkURLScheme() succeeded unexpectedly") + } + }) + } +} diff --git a/internal/oauthex/resource_meta_test.go b/internal/oauthex/resource_meta_test.go new file mode 100644 index 00000000..7c05abe9 --- /dev/null +++ b/internal/oauthex/resource_meta_test.go @@ -0,0 +1,252 @@ +// 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 oauthex + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +func Test_getPRM(t *testing.T) { + ctx := context.Background() + const wantResource = "https://resource.example.com" + + prmOK := &ProtectedResourceMetadata{ + Resource: wantResource, + AuthorizationServers: []string{"https://as.example.com"}, + } + prmOKJSON, err := json.Marshal(prmOK) + if err != nil { + t.Fatal(err) + } + + prmMismatchedResource := &ProtectedResourceMetadata{ + Resource: "https://wrong.example.com", + } + prmMismatchedResourceJSON, err := json.Marshal(prmMismatchedResource) + if err != nil { + t.Fatal(err) + } + + prmBadAuthServer := &ProtectedResourceMetadata{ + Resource: wantResource, + AuthorizationServers: []string{"javascript:alert(1)"}, + } + prmBadAuthServerJSON, err := json.Marshal(prmBadAuthServer) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/ok": + w.Header().Set("Content-Type", "application/json") + w.Write(prmOKJSON) + case "/mismatched-resource": + w.Header().Set("Content-Type", "application/json") + w.Write(prmMismatchedResourceJSON) + case "/bad-auth-server": + w.Header().Set("Content-Type", "application/json") + w.Write(prmBadAuthServerJSON) + case "/bad-status": + http.Error(w, "not found", http.StatusNotFound) + case "/bad-content-type": + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("not json")) + case "/bad-json": + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("not-json")) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + tests := []struct { + name string + purl string + wantResource string + client *http.Client + want *ProtectedResourceMetadata + wantErr string + }{ + { + name: "non-https url", + purl: "http://example.com", + wantResource: wantResource, + wantErr: `resource URL "http://example.com" does not use HTTPS`, + }, + { + name: "http get error", + purl: "https://localhost:0", // Invalid port to cause connection error + wantResource: wantResource, + wantErr: `Get "https://localhost:0": dial tcp`, + }, + { + name: "bad status code", + purl: server.URL + "/bad-status", + wantResource: wantResource, + client: server.Client(), + wantErr: "bad status 404 Not Found", + }, + { + name: "bad content type", + purl: server.URL + "/bad-content-type", + wantResource: wantResource, + client: server.Client(), + wantErr: `bad content type "text/plain"`, + }, + { + name: "bad json", + purl: server.URL + "/bad-json", + wantResource: wantResource, + client: server.Client(), + wantErr: "invalid character 'o' in literal null (expecting 'u')", + }, + { + name: "mismatched resource", + purl: server.URL + "/mismatched-resource", + wantResource: wantResource, + client: server.Client(), + wantErr: `got metadata resource "https://wrong.example.com", want "https://resource.example.com"`, + }, + { + name: "bad auth server url scheme", + purl: server.URL + "/bad-auth-server", + wantResource: wantResource, + client: server.Client(), + wantErr: `URL has disallowed scheme "javascript"`, + }, + { + name: "success", + purl: server.URL + "/ok", + wantResource: wantResource, + client: server.Client(), + want: prmOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getPRM(ctx, tt.purl, tt.client, tt.wantResource) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("getPRM() error = nil, wantErr %q", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("getPRM() error = %q, want to contain %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("getPRM() unexpected error: %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("getPRM() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetProtectedResourceMetadataFromHeader(t *testing.T) { + ctx := context.Background() + + prmOK := &ProtectedResourceMetadata{ + Resource: "https://resource.example.com/prm", + } + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // The resource URL in prmOK doesn't match the server URL, so we need to adjust it. + // prmOK.Resource = server.URL + "/prm" + prmOKJSON, _ := json.Marshal(prmOK) + + if r.URL.Path == "/prm" { + w.Header().Set("Content-Type", "application/json") + w.Write(prmOKJSON) + } else { + http.NotFound(w, r) + } + })) + defer server.Close() + + httpsURL := server.URL + "/prm" + prmOK.Resource = server.URL + "/prm" + + // The expected resource must match the dynamic URL of the test server. + wantPRM := &ProtectedResourceMetadata{ + Resource: httpsURL, + } + + tests := []struct { + name string + header http.Header + client *http.Client + want *ProtectedResourceMetadata + wantErr string + }{ + { + name: "no www-authenticate header", + header: http.Header{}, + want: nil, + }, + { + name: "empty www-authenticate header", + header: http.Header{ + "Www-Authenticate": []string{}, + }, + want: nil, + }, + { + name: "no resource_metadata parameter", + header: http.Header{ + "Www-Authenticate": []string{`Bearer realm="example.com"`}, + }, + want: nil, + }, + { + name: "success", + header: http.Header{ + "Www-Authenticate": []string{fmt.Sprintf(`Bearer resource_metadata="%s"`, httpsURL)}, + }, + client: server.Client(), + want: wantPRM, + }, + { + name: "getPRM fails with non-https url", + header: http.Header{ + "Www-Authenticate": []string{`Bearer resource_metadata="http://insecure.com"`}, + }, + client: server.Client(), + wantErr: `resource URL "http://insecure.com" does not use HTTPS`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetProtectedResourceMetadataFromHeader(ctx, tt.header, tt.client) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("GetProtectedResourceMetadataFromHeader() error = nil, wantErr %q", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("GetProtectedResourceMetadataFromHeader() error = %q, want to contain %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("GetProtectedResourceMetadataFromHeader() unexpected error: %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetProtectedResourceMetadataFromHeader() got = %+v, want %+v", got, tt.want) + } + }) + } +} From fd0dc9d741e8e8fae8d2331d7891c3296115a8db Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Tue, 30 Sep 2025 07:33:59 -0400 Subject: [PATCH 5/5] internal/oauthex: address URL attacks in PRM (#539) Fix Protected Resource Metadata to prevent URL attacks, as described in the issue. For #526 --- internal/oauthex/oauth2.go | 18 ++++++++++++++++++ internal/oauthex/resource_meta.go | 14 ++++++++++---- oauthex/oauthex.go | 4 +++- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/internal/oauthex/oauth2.go b/internal/oauthex/oauth2.go index de164499..fc08c6b4 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 71d52cde..95c9dd04 100644 --- a/internal/oauthex/resource_meta.go +++ b/internal/oauthex/resource_meta.go @@ -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 } diff --git a/oauthex/oauthex.go b/oauthex/oauthex.go index df326781..3c28dce9 100644 --- a/oauthex/oauthex.go +++ b/oauthex/oauthex.go @@ -5,7 +5,9 @@ // 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.