diff --git a/go.mod b/go.mod index 28d79c13..4033c3ff 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.12.3 - github.com/mark3labs/mcp-go v0.55.1 + github.com/mark3labs/mcp-go v0.56.0 github.com/prometheus/client_golang v1.23.2 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index b0be0138..44949e48 100644 --- a/go.sum +++ b/go.sum @@ -84,8 +84,8 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mark3labs/mcp-go v0.55.1 h1:GLYqNm9qdMGPhCtK4g1t1y1vhAPfayOBuaibDi4mrSA= -github.com/mark3labs/mcp-go v0.55.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= +github.com/mark3labs/mcp-go v0.56.0 h1:7aCj2wODCskMi08f923ADG+EfELZBdiKILny415cIS8= +github.com/mark3labs/mcp-go v0.56.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= diff --git a/vendor/github.com/mark3labs/mcp-go/mcp/tools.go b/vendor/github.com/mark3labs/mcp-go/mcp/tools.go index 31f375f7..ba605323 100644 --- a/vendor/github.com/mark3labs/mcp-go/mcp/tools.go +++ b/vendor/github.com/mark3labs/mcp-go/mcp/tools.go @@ -45,6 +45,9 @@ type CallToolResult struct { // For backwards compatibility, a tool that returns structured content SHOULD also return // functionally equivalent unstructured content. StructuredContent any `json:"structuredContent,omitempty"` + // RawStructuredContent preserves the original JSON bytes for structuredContent when + // unmarshaled from a wire message. + RawStructuredContent json.RawMessage `json:"-"` // Whether the tool call ended in an error. // // If not set, this is assumed to be false (the call was successful). @@ -63,6 +66,9 @@ type CallToolParams struct { Arguments any `json:"arguments,omitempty"` Meta *Meta `json:"_meta,omitempty"` Task *TaskParams `json:"task,omitempty"` + // RawArguments preserves the original JSON bytes for arguments when unmarshaled + // from a wire message. This avoids precision loss for integers above 2^53. + RawArguments json.RawMessage `json:"-"` } // GetArguments returns the Arguments as map[string]any for backward compatibility @@ -74,9 +80,12 @@ func (r CallToolRequest) GetArguments() map[string]any { return nil } -// GetRawArguments returns the Arguments as-is without type conversion -// This allows users to access the raw arguments in any format +// GetRawArguments returns the original arguments payload when available. +// For JSON-RPC requests this is json.RawMessage; otherwise it falls back to Arguments. func (r CallToolRequest) GetRawArguments() any { + if len(r.Params.RawArguments) > 0 { + return r.Params.RawArguments + } return r.Params.Arguments } @@ -87,6 +96,10 @@ func (r CallToolRequest) BindArguments(target any) error { return fmt.Errorf("target must be a non-nil pointer") } + if len(r.Params.RawArguments) > 0 { + return json.Unmarshal(r.Params.RawArguments, target) + } + // Fast-path: already raw JSON if raw, ok := r.Params.Arguments.(json.RawMessage); ok { return json.Unmarshal(raw, target) @@ -111,6 +124,53 @@ func (r CallToolRequest) GetString(key string, defaultValue string) string { return defaultValue } +// UnmarshalJSON preserves the original arguments JSON while also populating Arguments. +func (p *CallToolParams) UnmarshalJSON(data []byte) error { + type params struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + Meta *Meta `json:"_meta,omitempty"` + Task *TaskParams `json:"task,omitempty"` + } + + var raw params + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + p.Name = raw.Name + p.Meta = raw.Meta + p.Task = raw.Task + + if len(raw.Arguments) == 0 { + return nil + } + + p.RawArguments = append(json.RawMessage(nil), raw.Arguments...) + return json.Unmarshal(raw.Arguments, &p.Arguments) +} + +// MarshalJSON re-emits preserved raw arguments when available. +func (p CallToolParams) MarshalJSON() ([]byte, error) { + if len(p.RawArguments) > 0 { + type params struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments,omitempty"` + Meta *Meta `json:"_meta,omitempty"` + Task *TaskParams `json:"task,omitempty"` + } + return json.Marshal(params{ + Name: p.Name, + Arguments: p.RawArguments, + Meta: p.Meta, + Task: p.Task, + }) + } + + type alias CallToolParams + return json.Marshal(alias(p)) +} + // RequireString returns a string argument by key, or an error if not found or not a string func (r CallToolRequest) RequireString(key string) (string, error) { args := r.GetArguments() @@ -489,7 +549,9 @@ func (r CallToolResult) MarshalJSON() ([]byte, error) { m["content"] = content // Marshal StructuredContent if present - if r.StructuredContent != nil { + if len(r.RawStructuredContent) > 0 { + m["structuredContent"] = json.RawMessage(r.RawStructuredContent) + } else if r.StructuredContent != nil { m["structuredContent"] = r.StructuredContent } @@ -503,45 +565,36 @@ func (r CallToolResult) MarshalJSON() ([]byte, error) { // UnmarshalJSON implements custom JSON unmarshaling for CallToolResult func (r *CallToolResult) UnmarshalJSON(data []byte) error { - var raw map[string]any + type result struct { + Meta *Meta `json:"_meta,omitempty"` + Content []json.RawMessage `json:"content"` + StructuredContent json.RawMessage `json:"structuredContent,omitempty"` + IsError bool `json:"isError,omitempty"` + } + + var raw result if err := json.Unmarshal(data, &raw); err != nil { return err } - // Unmarshal Meta - if meta, ok := raw["_meta"]; ok { - if metaMap, ok := meta.(map[string]any); ok { - r.Meta = NewMetaFromMap(metaMap) - } - } + r.Meta = raw.Meta + r.IsError = raw.IsError - // Unmarshal Content array - if contentRaw, ok := raw["content"]; ok { - if contentArray, ok := contentRaw.([]any); ok { - r.Content = make([]Content, len(contentArray)) - for i, item := range contentArray { - itemBytes, err := json.Marshal(item) - if err != nil { - return err - } - content, err := UnmarshalContent(itemBytes) - if err != nil { - return err - } - r.Content[i] = content + if len(raw.Content) > 0 { + r.Content = make([]Content, len(raw.Content)) + for i, item := range raw.Content { + content, err := UnmarshalContent(item) + if err != nil { + return err } + r.Content[i] = content } } - // Unmarshal StructuredContent if present - if structured, ok := raw["structuredContent"]; ok { - r.StructuredContent = structured - } - - // Unmarshal IsError - if isError, ok := raw["isError"]; ok { - if isErrorBool, ok := isError.(bool); ok { - r.IsError = isErrorBool + if len(raw.StructuredContent) > 0 { + r.RawStructuredContent = append(json.RawMessage(nil), raw.StructuredContent...) + if err := json.Unmarshal(raw.StructuredContent, &r.StructuredContent); err != nil { + return err } } diff --git a/vendor/github.com/mark3labs/mcp-go/mcp/utils.go b/vendor/github.com/mark3labs/mcp-go/mcp/utils.go index b21bb2ac..48344182 100644 --- a/vendor/github.com/mark3labs/mcp-go/mcp/utils.go +++ b/vendor/github.com/mark3labs/mcp-go/mcp/utils.go @@ -806,57 +806,19 @@ func ParseCallToolResult(rawMessage *json.RawMessage) (*CallToolResult, error) { return nil, fmt.Errorf("response is nil") } - var jsonContent map[string]any - if err := json.Unmarshal(*rawMessage, &jsonContent); err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) - } - - var result CallToolResult - - meta, ok := jsonContent["_meta"] - if ok { - if metaMap, ok := meta.(map[string]any); ok { - result.Meta = NewMetaFromMap(metaMap) - } + var probe struct { + Content json.RawMessage `json:"content"` } - - isError, ok := jsonContent["isError"] - if ok { - if isErrorBool, ok := isError.(bool); ok { - result.IsError = isErrorBool - } + if err := json.Unmarshal(*rawMessage, &probe); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) } - - contents, ok := jsonContent["content"] - if !ok { + if probe.Content == nil { return nil, fmt.Errorf("content is missing") } - contentArr, ok := contents.([]any) - if !ok { - return nil, fmt.Errorf("content is not an array") - } - - for _, content := range contentArr { - // Extract content - contentMap, ok := content.(map[string]any) - if !ok { - return nil, fmt.Errorf("content is not an object") - } - - // Process content - content, err := ParseContent(contentMap) - if err != nil { - return nil, err - } - - result.Content = append(result.Content, content) - } - - // Handle structured content - structuredContent, ok := jsonContent["structuredContent"] - if ok { - result.StructuredContent = structuredContent + var result CallToolResult + if err := json.Unmarshal(*rawMessage, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) } return &result, nil diff --git a/vendor/github.com/mark3labs/mcp-go/server/http_localhost.go b/vendor/github.com/mark3labs/mcp-go/server/http_localhost.go new file mode 100644 index 00000000..58904842 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/server/http_localhost.go @@ -0,0 +1,56 @@ +package server + +import ( + "fmt" + "net" + "net/http" + "net/netip" + "strings" +) + +// isLoopbackHost reports whether addr refers to a loopback interface. addr +// may be a bare host ("localhost", "127.0.0.1", "::1", "[::1]") or a +// host:port pair ("localhost:3000", "127.0.0.1:3000", "[::1]:3000"). +// +// It is used to implement DNS rebinding protection: a request that arrives +// on a loopback connection must also carry a loopback Host header, otherwise +// a malicious website could rebind its own domain to 127.0.0.1 and drive a +// victim's browser to interact with a local MCP server. +func isLoopbackHost(addr string) bool { + host, _, err := net.SplitHostPort(addr) + if err != nil { + // addr might be a bare host without a port. + host = strings.Trim(addr, "[]") + } + if strings.EqualFold(host, "localhost") { + return true + } + ip, err := netip.ParseAddr(host) + if err != nil { + return false + } + return ip.IsLoopback() +} + +// rejectDNSRebinding applies DNS rebinding protection to r. When the +// connection's local address (http.LocalAddrContextKey) is a loopback +// address but the request's Host header is not a loopback value, it writes a +// 403 Forbidden response and returns true. Otherwise it returns false and +// writes nothing. +// +// Requests arriving via non-loopback addresses are never rejected: DNS +// rebinding attacks only target servers reachable at localhost from the +// victim's browser. +// +// See https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise +func rejectDNSRebinding(w http.ResponseWriter, r *http.Request) bool { + localAddr, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr) + if !ok || localAddr == nil { + return false + } + if isLoopbackHost(localAddr.String()) && !isLoopbackHost(r.Host) { + http.Error(w, fmt.Sprintf("Forbidden: invalid Host header %q", r.Host), http.StatusForbidden) + return true + } + return false +} diff --git a/vendor/github.com/mark3labs/mcp-go/server/input_validation.go b/vendor/github.com/mark3labs/mcp-go/server/input_validation.go index 878133e4..c319746a 100644 --- a/vendor/github.com/mark3labs/mcp-go/server/input_validation.go +++ b/vendor/github.com/mark3labs/mcp-go/server/input_validation.go @@ -274,7 +274,7 @@ func formatLeafMessage(verr *jsonschema.ValidationError) string { if location == "" { location = "" } - return fmt.Sprintf("%s: %s", location, verr.ErrorKind) + return fmt.Sprintf("%s: %+v", location, verr.ErrorKind) } // jsonPointer renders a list of path segments as a RFC 6901 JSON Pointer. diff --git a/vendor/github.com/mark3labs/mcp-go/server/sse.go b/vendor/github.com/mark3labs/mcp-go/server/sse.go index 5e54ea12..c159a2ca 100644 --- a/vendor/github.com/mark3labs/mcp-go/server/sse.go +++ b/vendor/github.com/mark3labs/mcp-go/server/sse.go @@ -194,6 +194,11 @@ type SSEServer struct { // WithSSECORS. corsConfig *CORSConfig + // disableLocalhostProtection, when true, turns off the automatic DNS + // rebinding protection applied to requests arriving over loopback + // connections. See WithSSEDisableLocalhostProtection. + disableLocalhostProtection bool + mu sync.RWMutex } @@ -357,6 +362,30 @@ func WithSSECORS(opts ...CORSOption) SSEOption { } } +// WithSSEDisableLocalhostProtection disables the automatic DNS rebinding +// protection of the SSE server. +// +// By default, requests arriving over a loopback connection (127.0.0.1, +// [::1]) whose Host header is not a localhost value are rejected with 403 +// Forbidden. This protects local MCP servers against DNS rebinding attacks, +// where a malicious website rebinds its own domain to 127.0.0.1 to make a +// victim's browser issue requests against a local server. The protection +// applies regardless of whether the server listens on localhost specifically +// or on 0.0.0.0, and never affects requests arriving via non-loopback +// addresses. +// +// Disable it only if you understand the security implications, for example +// when a reverse proxy on the same host forwards requests via localhost +// while preserving the original Host header. In that case, prefer +// configuring the proxy to rewrite the Host header to localhost instead. +// +// See https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise +func WithSSEDisableLocalhostProtection(disable bool) SSEOption { + return func(s *SSEServer) { + s.disableLocalhostProtection = disable + } +} + // WithSSEContextFunc sets a function that will be called to customise the context // to the server using the incoming request. func WithSSEContextFunc(fn SSEContextFunc) SSEOption { @@ -851,10 +880,14 @@ func (s *SSEServer) MessageHandler() http.Handler { return s.withCORS(http.HandlerFunc(s.handleMessage)) } -// withCORS wraps next with CORS preflight and header handling using the -// SSE server's configured CORSConfig. It is a no-op when CORS is disabled. +// withCORS wraps next with DNS rebinding protection plus CORS preflight and +// header handling using the SSE server's configured CORSConfig. The CORS +// portion is a no-op when CORS is disabled. func (s *SSEServer) withCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.disableLocalhostProtection && rejectDNSRebinding(w, r) { + return + } if s.corsConfig.enabled() { if s.corsConfig.handlePreflight(w, r) { return @@ -871,7 +904,14 @@ func (s *SSEServer) withCORS(next http.Handler) http.Handler { // answered directly and simple cross-origin responses are decorated with the // configured Access-Control-* headers before being dispatched to the SSE or // message handlers. +// +// Requests arriving over a loopback connection with a non-localhost Host +// header are rejected with 403 Forbidden to protect against DNS rebinding +// attacks, unless WithSSEDisableLocalhostProtection is set. func (s *SSEServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !s.disableLocalhostProtection && rejectDNSRebinding(w, r) { + return + } if s.corsConfig.enabled() { if s.corsConfig.handlePreflight(w, r) { return diff --git a/vendor/github.com/mark3labs/mcp-go/server/streamable_http.go b/vendor/github.com/mark3labs/mcp-go/server/streamable_http.go index 2ec34f2b..00734201 100644 --- a/vendor/github.com/mark3labs/mcp-go/server/streamable_http.go +++ b/vendor/github.com/mark3labs/mcp-go/server/streamable_http.go @@ -111,6 +111,30 @@ func WithDisableStreaming(disable bool) StreamableHTTPOption { } } +// WithDisableLocalhostProtection disables the automatic DNS rebinding +// protection of the streamable HTTP server. +// +// By default, requests arriving over a loopback connection (127.0.0.1, +// [::1]) whose Host header is not a localhost value are rejected with 403 +// Forbidden. This protects local MCP servers against DNS rebinding attacks, +// where a malicious website rebinds its own domain to 127.0.0.1 to make a +// victim's browser issue requests against a local server. The protection +// applies regardless of whether the server listens on localhost specifically +// or on 0.0.0.0, and never affects requests arriving via non-loopback +// addresses. +// +// Disable it only if you understand the security implications, for example +// when a reverse proxy on the same host forwards requests via localhost +// while preserving the original Host header. In that case, prefer +// configuring the proxy to rewrite the Host header to localhost instead. +// +// See https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise +func WithDisableLocalhostProtection(disable bool) StreamableHTTPOption { + return func(s *StreamableHTTPServer) { + s.disableLocalhostProtection = disable + } +} + // WithHTTPContextFunc sets a function that will be called to customise the context // to the server using the incoming request. // This can be used to inject context values from headers, for example. @@ -257,6 +281,11 @@ type StreamableHTTPServer struct { sessionLogLevels *sessionLogLevelsStore disableStreaming bool + // disableLocalhostProtection, when true, turns off the automatic DNS + // rebinding protection applied to requests arriving over loopback + // connections. See WithDisableLocalhostProtection. + disableLocalhostProtection bool + tlsCertFile string tlsKeyFile string @@ -322,9 +351,16 @@ func NewStreamableHTTPServer(server *MCPServer, opts ...StreamableHTTPOption) *S // requests are answered directly and simple cross-origin responses are // decorated with the configured Access-Control-* headers before dispatch. // +// Requests arriving over a loopback connection with a non-localhost Host +// header are rejected with 403 Forbidden to protect against DNS rebinding +// attacks, unless WithDisableLocalhostProtection is set. +// // ServeHTTP is the conventional net/http entry point; for non-net/http HTTP // frameworks (fasthttp, fiber, etc.), see Handle. func (s *StreamableHTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !s.disableLocalhostProtection && rejectDNSRebinding(w, r) { + return + } if s.corsConfig.enabled() { if s.corsConfig.handlePreflight(w, r) { return diff --git a/vendor/github.com/mark3labs/mcp-go/server/streamable_http_handle.go b/vendor/github.com/mark3labs/mcp-go/server/streamable_http_handle.go index 4c8e61e5..1e0562c0 100644 --- a/vendor/github.com/mark3labs/mcp-go/server/streamable_http_handle.go +++ b/vendor/github.com/mark3labs/mcp-go/server/streamable_http_handle.go @@ -175,6 +175,10 @@ func writeHTTPErrorf(w HTTPResponseWriter, code int, format string, args ...any) // // - WithStreamableHTTPCORS is NOT applied. Frameworks should use their // native CORS middleware. +// - DNS rebinding protection is NOT applied, because Handle has no access +// to the connection's local address. Adapters for localhost-reachable +// servers should validate the Host header themselves (reject non-loopback +// Host values on loopback connections with 403 Forbidden). // - WithProtectedResourceMetadata is NOT applied. The caller should mount // the metadata route separately if needed (see ProtectedResourceMetadataHandler). // - WithHTTPContextFunc is honored for backwards compatibility; it receives diff --git a/vendor/modules.txt b/vendor/modules.txt index 59d9feaa..a24766f6 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -111,7 +111,7 @@ github.com/lib/pq/scram # github.com/lucasb-eyer/go-colorful v1.3.0 ## explicit; go 1.12 github.com/lucasb-eyer/go-colorful -# github.com/mark3labs/mcp-go v0.55.1 +# github.com/mark3labs/mcp-go v0.56.0 ## explicit; go 1.25.5 github.com/mark3labs/mcp-go/mcp github.com/mark3labs/mcp-go/server