Files
kandev/apps/backend/internal/mcp/server/tool_argument_validation.go
T
2026-09-02 19:48:19 +00:00

196 lines
5.4 KiB
Go

package mcp
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"github.com/mark3labs/mcp-go/mcp"
jsonschema "github.com/santhosh-tekuri/jsonschema/v6"
"github.com/santhosh-tekuri/jsonschema/v6/kind"
"go.uber.org/zap"
)
type toolArgumentValidator struct {
schema *jsonschema.Schema
err error
}
func (s *Server) rebuildToolArgumentValidators() {
tools := s.mcpServer.ListTools()
validators := make(map[string]toolArgumentValidator, len(tools))
for name, serverTool := range tools {
schema, err := compileToolArgumentSchema(name, serverTool.Tool)
validators[name] = toolArgumentValidator{schema: schema, err: err}
if err != nil {
s.logger.Error("failed to compile MCP tool argument schema",
zap.String("tool", name),
zap.Error(err))
}
}
s.validatorMu.Lock()
s.toolValidators = validators
s.validatorMu.Unlock()
}
func compileToolArgumentSchema(toolName string, tool mcp.Tool) (*jsonschema.Schema, error) {
rawSchema := tool.RawInputSchema
if rawSchema == nil {
var err error
rawSchema, err = json.Marshal(tool.InputSchema)
if err != nil {
return nil, fmt.Errorf("marshal schema: %w", err)
}
}
var schemaDoc map[string]any
if err := json.Unmarshal(rawSchema, &schemaDoc); err != nil {
return nil, fmt.Errorf("decode schema: %w", err)
}
schemaDoc["additionalProperties"] = false
compiler := jsonschema.NewCompiler()
compiler.DefaultDraft(jsonschema.Draft7)
resourceURL := "https://kandev.local/mcp/schemas/" + url.PathEscape(toolName)
if err := compiler.AddResource(resourceURL, schemaDoc); err != nil {
return nil, fmt.Errorf("add schema resource: %w", err)
}
schema, err := compiler.Compile(resourceURL)
if err != nil {
return nil, fmt.Errorf("compile schema: %w", err)
}
return schema, nil
}
func (s *Server) validateToolArguments(toolName string, req mcp.CallToolRequest) (mcp.CallToolRequest, error) {
arguments := req.GetRawArguments()
if raw, ok := arguments.(json.RawMessage); ok {
var decoded any
if err := json.Unmarshal(raw, &decoded); err != nil {
return req, fmt.Errorf("invalid arguments for %s: failed to decode arguments", toolName)
}
arguments = decoded
}
if arguments == nil {
arguments = map[string]any{}
}
arguments, err := normalizeToolArguments(toolName, arguments)
if err != nil {
return req, err
}
req.Params.Arguments = arguments
// SetMode replaces the MCP tool set before rebuilding its validators. Use
// the server read lock as a barrier so validation cannot observe that
// intermediate state or pair a newly selected handler with old validators.
s.mu.RLock()
defer s.mu.RUnlock()
s.validatorMu.RLock()
validator, ok := s.toolValidators[toolName]
s.validatorMu.RUnlock()
if !ok {
return req, fmt.Errorf("invalid arguments for %s: schema validator is unavailable", toolName)
}
if validator.err != nil || validator.schema == nil {
return req, fmt.Errorf("invalid arguments for %s: registered schema is invalid", toolName)
}
if err := validator.schema.Validate(arguments); err != nil {
return req, sanitizedToolArgumentError(toolName, err)
}
return req, nil
}
func sanitizedToolArgumentError(toolName string, err error) error {
var validationErr *jsonschema.ValidationError
if !errors.As(err, &validationErr) {
return fmt.Errorf("invalid arguments for %s: validation failed", toolName)
}
failure, keyword := firstKeywordFailure(validationErr)
if failure == nil {
failure = validationErr
keyword = "schema"
}
return fmt.Errorf("invalid arguments for %s: validation failed at %s (keyword: %s%s)",
toolName, validationInstancePath(failure.InstanceLocation), keyword, missingRequiredProperties(failure))
}
func missingRequiredProperties(err *jsonschema.ValidationError) string {
required, ok := err.ErrorKind.(*kind.Required)
if !ok || len(required.Missing) == 0 {
return ""
}
missing := make([]string, len(required.Missing))
for i, property := range required.Missing {
missing[i] = strconv.Quote(property)
}
sort.Strings(missing)
return "; missing: " + strings.Join(missing, ", ")
}
func firstKeywordFailure(err *jsonschema.ValidationError) (*jsonschema.ValidationError, string) {
if err == nil {
return nil, ""
}
if err.ErrorKind != nil {
keywordPath := err.ErrorKind.KeywordPath()
if len(keywordPath) > 0 && keywordPath[0] != "" {
return err, keywordPath[0]
}
}
for _, cause := range err.Causes {
if failure, keyword := firstKeywordFailure(cause); failure != nil {
return failure, keyword
}
}
return nil, ""
}
func validationInstancePath(tokens []string) string {
if len(tokens) == 0 {
return "$"
}
var path strings.Builder
for _, token := range tokens {
path.WriteByte('/')
token = strings.ReplaceAll(token, "~", "~0")
path.WriteString(strings.ReplaceAll(token, "/", "~1"))
}
return path.String()
}
func normalizeToolArguments(toolName string, arguments any) (any, error) {
if toolName != "create_task_kandev" {
return arguments, nil
}
args, ok := arguments.(map[string]any)
if !ok {
return arguments, nil
}
_, hasPrompt := args["prompt"]
description, hasDescription := args["description"]
if hasPrompt && hasDescription {
return nil, fmt.Errorf("invalid arguments for %s: provide either prompt or description, not both", toolName)
}
if !hasDescription {
return args, nil
}
normalized := make(map[string]any, len(args))
for key, value := range args {
normalized[key] = value
}
normalized["prompt"] = description
delete(normalized, "description")
return normalized, nil
}