diff --git a/.github/workflows/code-health-mcp-server.yml b/.github/workflows/code-health-mcp-server.yml deleted file mode 100644 index 34240490e3..0000000000 --- a/.github/workflows/code-health-mcp-server.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: 'Code Health MCP Server' -on: - push: - branches: - - main - paths: - - 'tools/mcp-server/**' - - '.github/workflows/code-health-mcp-server.yml' - pull_request: - branches: - - main - paths: - - 'tools/mcp-server/**' - - '.github/workflows/code-health-mcp-server.yml' - workflow_dispatch: {} - workflow_call: {} - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout MCP Server - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c - with: - go-version-file: 'tools/mcp-server/go.mod' - - name: Build MCP Server - working-directory: tools/mcp-server - run: make build - - test: - needs: build - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c - with: - go-version-file: 'tools/mcp-server/go.mod' - - name: Run tests - working-directory: tools/mcp-server - run: make test - - lint: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - sparse-checkout: | - .github - tools - - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c - with: - go-version-file: 'tools/mcp-server/go.mod' - cache: false # see https://github.com/golangci/golangci-lint-action/issues/807 - - name: golangci-lint - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 - with: - version: v2.11.4 - working-directory: tools/mcp-server - diff --git a/.gitignore b/.gitignore index 9de5acc02b..f765072ace 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,3 @@ **/*metric-collection-results.parquet **/*spectral-output.txt **/*spectral-report.xml -tools/go.work.sum diff --git a/tools/go.work b/tools/go.work deleted file mode 100644 index 89ae96f07a..0000000000 --- a/tools/go.work +++ /dev/null @@ -1,6 +0,0 @@ -go 1.26 - -use ( - cli - mcp-server -) diff --git a/tools/mcp-server/.golangci.yml b/tools/mcp-server/.golangci.yml deleted file mode 100644 index e3a1f2613c..0000000000 --- a/tools/mcp-server/.golangci.yml +++ /dev/null @@ -1,125 +0,0 @@ -version: "2" -run: - modules-download-mode: readonly - tests: true -linters: - default: none - enable: - - copyloopvar - - dogsled - - errcheck - - errorlint - - exhaustive - - funlen - - gocritic - - godot - - goprintffuncname - - gosec - - govet - - ineffassign - - lll - - makezero - - misspell - - nakedret - - noctx - - nolintlint - - perfsprint - - prealloc - - predeclared - - revive - - rowserrcheck - - staticcheck - - testifylint - - thelper - - unconvert - - unused - - whitespace - settings: - funlen: - lines: 360 - statements: 120 - gocritic: - enabled-tags: - - diagnostic - - experimental - - opinionated - - performance - - style - govet: - enable: - - shadow - lll: - line-length: 150 - misspell: - locale: US - nestif: - min-complexity: 7 - revive: - severity: warning - rules: - - name: blank-imports - - name: context-as-argument - - name: context-keys-type - - name: defer - - name: dot-imports - - name: error-return - - name: error-strings - - name: error-naming - - name: early-return - - name: errorf - - name: exported - - name: import-shadowing - - name: indent-error-flow - - name: if-return - - name: increment-decrement - - name: var-naming - - name: var-declaration - - name: package-comments - - name: range - - name: receiver-naming - - name: time-naming - - name: unexported-return - - name: indent-error-flow - - name: errorf - - name: empty-block - - name: superfluous-else - - name: struct-tag - - name: unused-parameter - - name: unreachable-code - - name: redefines-builtin-id - - name: unused-receiver - - name: constant-logical-expr - - name: confusing-naming - - name: unnecessary-stmt - - name: use-any - - name: imports-blocklist - arguments: - - github.com/pkg/errors - exclusions: - generated: lax - presets: - - comments - - common-false-positives - - legacy - - std-error-handling - paths: - - third_party$ - - builtin$ - - examples$ -formatters: - enable: - - gci - - gofmt - - goimports - settings: - gci: - sections: - - standard - - default - exclusions: - generated: lax - paths: - - third_party$ - - builtin$ - - examples$ - diff --git a/tools/mcp-server/Makefile b/tools/mcp-server/Makefile deleted file mode 100644 index 2b035d8a06..0000000000 --- a/tools/mcp-server/Makefile +++ /dev/null @@ -1,46 +0,0 @@ -.PHONY: build -build: ## Build the MCP server binary - @echo "==> Building mcp-server binary" - go build -o bin/mcp-server ./cmd - -.PHONY: install -install: ## Install the MCP server binary to GOPATH/bin - @echo "==> Installing mcp-server" - go install ./cmd - -##@ Development - -.PHONY: fmt -fmt: ## Format Go code - @echo "==> Formatting code" - gofmt -w -s . - -.PHONY: lint -lint: ## Run linter - @echo "==> Running linter" - golangci-lint run - -.PHONY: test -test: ## Run tests - @echo "==> Running tests" - go test -v ./... - -##@ Cleanup - -.PHONY: clean -clean: ## Remove build artifacts - @echo "==> Cleaning build artifacts" - rm -rf bin/ - -##@ Dependencies - -.PHONY: deps -deps: ## Download dependencies - @echo "==> Downloading dependencies" - go mod download - -.PHONY: tidy -tidy: ## Tidy go.mod - @echo "==> Tidying go.mod" - go mod tidy - diff --git a/tools/mcp-server/cmd/main.go b/tools/mcp-server/cmd/main.go deleted file mode 100644 index d0431ed35e..0000000000 --- a/tools/mcp-server/cmd/main.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "context" - "log" - "os" - - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" - "github.com/mongodb/openapi/tools/mcp-server/internal/resources" - "github.com/mongodb/openapi/tools/mcp-server/internal/tools" -) - -const ( - serverName = "openapi-mcp-server" - serverVersion = "0.1.0" -) - -func main() { - if err := run(); err != nil { - log.Fatalf("Error: %v", err) - } -} - -func run() error { - reg := registry.New() - - impl := &mcp.Implementation{ - Name: serverName, - Version: serverVersion, - } - server := mcp.NewServer(impl, nil) - - tools.Register(server, reg) - resources.Register(server, reg) - - // Log to stderr (stdout is reserved for MCP protocol) - log.SetOutput(os.Stderr) - log.Printf("Starting %s v%s", serverName, serverVersion) - - transport := &mcp.StdioTransport{} - session, err := server.Connect(context.Background(), transport, nil) - if err != nil { - return err - } - - return session.Wait() -} diff --git a/tools/mcp-server/go.mod b/tools/mcp-server/go.mod deleted file mode 100644 index fa68405dbe..0000000000 --- a/tools/mcp-server/go.mod +++ /dev/null @@ -1,46 +0,0 @@ -module github.com/mongodb/openapi/tools/mcp-server - -go 1.26 - -require ( - github.com/getkin/kin-openapi v0.135.0 - github.com/modelcontextprotocol/go-sdk v1.5.0 - github.com/mongodb/openapi/tools/cli v0.0.0 - github.com/spf13/afero v1.15.0 - github.com/stretchr/testify v1.11.1 -) - -require ( - cloud.google.com/go v0.123.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/go-openapi/jsonpointer v0.22.5 // indirect - github.com/go-openapi/swag/jsonname v0.25.5 // indirect - github.com/google/jsonschema-go v0.4.2 // indirect - github.com/iancoleman/strcase v0.3.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/mailru/easyjson v0.9.2 // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/oasdiff/oasdiff v1.14.0 // indirect - github.com/oasdiff/yaml v0.0.9 // indirect - github.com/oasdiff/yaml3 v0.0.9 // indirect - github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/segmentio/asm v1.1.3 // indirect - github.com/segmentio/encoding v0.5.4 // indirect - github.com/tidwall/gjson v1.18.0 // indirect - github.com/tidwall/match v1.2.0 // indirect - github.com/tidwall/pretty v1.2.1 // indirect - github.com/tidwall/sjson v1.2.5 // indirect - github.com/wI2L/jsondiff v0.7.1 // indirect - github.com/woodsbury/decimal128 v1.4.0 // indirect - github.com/yargevad/filepathx v1.0.0 // indirect - github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - go.uber.org/mock v0.6.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) - -replace github.com/mongodb/openapi/tools/cli => ../cli diff --git a/tools/mcp-server/go.sum b/tools/mcp-server/go.sum deleted file mode 100644 index af2d4536cb..0000000000 --- a/tools/mcp-server/go.sum +++ /dev/null @@ -1,93 +0,0 @@ -cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= -cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg= -github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI= -github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= -github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= -github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= -github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= -github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= -github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= -github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= -github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= -github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -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.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= -github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= -github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= -github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -github.com/oasdiff/oasdiff v1.14.0 h1:R9nDNGHDoBpPUs/TthVDHGsJ63mnlL/vWUo252RRCgo= -github.com/oasdiff/oasdiff v1.14.0/go.mod h1:9Im1HSDZzkao0yWGiy/Crr6cPvUvPBiCRxzVtHtqIKA= -github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= -github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= -github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g= -github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= -github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= -github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= -github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= -github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= -github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= -github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/wI2L/jsondiff v0.7.1 h1:Fg9+yj+1/x3UtPBJhR91TKEzRkrEEWcAcLbg9dzEaNM= -github.com/wI2L/jsondiff v0.7.1/go.mod h1:yAt2W7U6Jd4HK0RA8DGSGk0zDtfEtOUUJVnH/xICpjo= -github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= -github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= -github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= -github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= -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= -go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= -go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/mcp-server/internal/registry/registry.go b/tools/mcp-server/internal/registry/registry.go deleted file mode 100644 index 72a6c7b180..0000000000 --- a/tools/mcp-server/internal/registry/registry.go +++ /dev/null @@ -1,185 +0,0 @@ -package registry - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "sort" - "sync" - "time" - - "github.com/getkin/kin-openapi/openapi3" -) - -// SourceType represents the origin of a spec entry. -type SourceType string - -const ( - // SourceTypeFile represents a spec loaded from a file. - SourceTypeFile SourceType = "file" - // SourceTypeVirtual represents a spec created by transformations. - SourceTypeVirtual SourceType = "virtual" -) - -// Entry represents a single OpenAPI specification in the registry. -type Entry struct { - Alias string // Primary key - unique identifier - SourceType SourceType // Origin: "file" or "virtual" - FilePath string // Source file path (empty for virtual specs) - Checksum string // SHA256 hash of spec content - Spec *openapi3.T // The actual spec - Metadata map[string]string // Custom metadata - LoadedAt time.Time // When the spec was loaded -} - -// Registry manages a collection of OpenAPI specifications in memory. -type Registry struct { - mu sync.RWMutex - specs map[string]*Entry // Key = alias (unique) -} - -// New creates a new empty registry. -func New() *Registry { - return &Registry{ - specs: make(map[string]*Entry), - } -} - -// Add adds or updates a spec entry in the registry. -// FilePath should be empty string for virtual specs. -// -// Collision detection logic: -// - Alias must be globally unique (regardless of source type) -// - File + File with same alias but different path: collision error -// - File + File with same alias, same path, same checksum: idempotent no-op -// - File + File with same alias, same path, different checksum: update -// - Virtual + Virtual with same alias, different checksum: update -// - Virtual + Virtual with same alias, same checksum: idempotent no-op -// - File + Virtual or Virtual + File with same alias: collision error. -func (r *Registry) Add(alias, filePath string, spec *openapi3.T, metadata map[string]string) error { - r.mu.Lock() - defer r.mu.Unlock() - - sourceType := SourceTypeFile - if filePath == "" { - sourceType = SourceTypeVirtual - } - - checksum, err := calculateChecksum(spec) - if err != nil { - return fmt.Errorf("failed to calculate checksum: %w", err) - } - - if existing, exists := r.specs[alias]; exists { - if existing.SourceType != sourceType { - return fmt.Errorf("alias '%s' is already in use by a %s spec", alias, existing.SourceType) - } - - // File-based specs: check path collision - if sourceType == SourceTypeFile { - if existing.FilePath != filePath { - return fmt.Errorf("alias '%s' is already in use by '%s'", alias, existing.FilePath) - } - } - - // No changes - idempotent no-op - if existing.Checksum == checksum { - return nil - } - - r.specs[alias] = &Entry{ - Alias: alias, - SourceType: sourceType, - FilePath: filePath, - Checksum: checksum, - Spec: spec, - Metadata: metadata, - LoadedAt: time.Now(), - } - return nil - } - - r.specs[alias] = &Entry{ - Alias: alias, - SourceType: sourceType, - FilePath: filePath, - Checksum: checksum, - Spec: spec, - Metadata: metadata, - LoadedAt: time.Now(), - } - return nil -} - -// GetByAlias retrieves a spec entry by alias. -// Returns an error if the spec doesn't exist. -func (r *Registry) GetByAlias(alias string) (*Entry, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - entry, exists := r.specs[alias] - if !exists { - return nil, fmt.Errorf("spec with alias '%s' not found", alias) - } - - return entry, nil -} - -// Remove removes a spec entry from the registry by alias. -// Returns an error if the spec doesn't exist. -func (r *Registry) Remove(alias string) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, exists := r.specs[alias]; !exists { - return fmt.Errorf("spec with alias '%s' not found", alias) - } - - delete(r.specs, alias) - return nil -} - -// List returns all spec entries in the registry, sorted by LoadedAt descending (most recent first). -func (r *Registry) List() []*Entry { - r.mu.RLock() - defer r.mu.RUnlock() - - entries := make([]*Entry, 0, len(r.specs)) - for _, entry := range r.specs { - entries = append(entries, entry) - } - - // Sort by LoadedAt descending (most recent first) - sort.Slice(entries, func(i, j int) bool { - return entries[i].LoadedAt.After(entries[j].LoadedAt) - }) - - return entries -} - -// Count returns the number of specs in the registry. -func (r *Registry) Count() int { - r.mu.RLock() - defer r.mu.RUnlock() - - return len(r.specs) -} - -// Clear removes all specs from the registry. -func (r *Registry) Clear() { - r.mu.Lock() - defer r.mu.Unlock() - - r.specs = make(map[string]*Entry) -} - -// calculateChecksum calculates SHA256 hash of the spec content. -func calculateChecksum(spec *openapi3.T) (string, error) { - data, err := json.Marshal(spec) - if err != nil { - return "", err - } - hash := sha256.Sum256(data) - return hex.EncodeToString(hash[:]), nil -} diff --git a/tools/mcp-server/internal/registry/registry_test.go b/tools/mcp-server/internal/registry/registry_test.go deleted file mode 100644 index 2a7b14b141..0000000000 --- a/tools/mcp-server/internal/registry/registry_test.go +++ /dev/null @@ -1,255 +0,0 @@ -package registry - -import ( - "testing" - - "github.com/getkin/kin-openapi/openapi3" -) - -func TestRegistry_Add_NewEntry(t *testing.T) { - reg := New() - spec := createTestSpec("Test API", "1.0.0") - - err := reg.Add("test-api", "/path/to/test.yaml", spec, nil) - if err != nil { - t.Fatalf("Add() failed for new entry: %v", err) - } - - if reg.Count() != 1 { - t.Errorf("Count() = %d, want 1", reg.Count()) - } - - entry, err := reg.GetByAlias("test-api") - if err != nil { - t.Fatalf("GetByAlias() failed: %v", err) - } - - if entry.Alias != "test-api" { - t.Errorf("entry.Alias = %q, want %q", entry.Alias, "test-api") - } - if entry.FilePath != "/path/to/test.yaml" { - t.Errorf("entry.FilePath = %q, want %q", entry.FilePath, "/path/to/test.yaml") - } -} - -func TestRegistry_Add_CollisionDifferentFile(t *testing.T) { - reg := New() - spec1 := createTestSpec("API 1", "1.0.0") - spec2 := createTestSpec("API 2", "2.0.0") - - // Add first spec with alias "my-api" - err := reg.Add("my-api", "/path/to/file1.yaml", spec1, nil) - if err != nil { - t.Fatalf("First Add() failed: %v", err) - } - - // Try to add different file with same alias - should error - err = reg.Add("my-api", "/path/to/file2.yaml", spec2, nil) - if err == nil { - t.Fatal("Add() should have returned collision error for different file") - } - - // Verify error message mentions collision - expectedMsg := "alias 'my-api' is already in use by '/path/to/file1.yaml'" - if err.Error() != expectedMsg { - t.Errorf("error = %q, want %q", err.Error(), expectedMsg) - } - - // Registry should still have only the first entry - if reg.Count() != 1 { - t.Errorf("Count() = %d, want 1", reg.Count()) - } -} - -func TestRegistry_Add_SameFileModified(t *testing.T) { - reg := New() - spec1 := createTestSpec("API", "1.0.0") - spec2 := createTestSpec("API", "2.0.0") // Different version = different checksum - - // Add original spec - err := reg.Add("my-api", "/path/to/api.yaml", spec1, nil) - if err != nil { - t.Fatalf("First Add() failed: %v", err) - } - - entry1, _ := reg.GetByAlias("my-api") - checksum1 := entry1.Checksum - - // Re-add same file with modified content - should update - err = reg.Add("my-api", "/path/to/api.yaml", spec2, nil) - if err != nil { - t.Fatalf("Second Add() should succeed (update): %v", err) - } - - // Should still have only 1 entry - if reg.Count() != 1 { - t.Errorf("Count() = %d, want 1", reg.Count()) - } - - // Checksum should have changed - entry2, _ := reg.GetByAlias("my-api") - if entry2.Checksum == checksum1 { - t.Error("Checksum should have changed after update") - } - - // Version should be updated - if entry2.Spec.Info.Version != "2.0.0" { - t.Errorf("Spec version = %q, want %q", entry2.Spec.Info.Version, "2.0.0") - } -} - -func TestRegistry_Add_SameFileUnchanged(t *testing.T) { - reg := New() - spec := createTestSpec("API", "1.0.0") - - // Add spec - err := reg.Add("my-api", "/path/to/api.yaml", spec, nil) - if err != nil { - t.Fatalf("First Add() failed: %v", err) - } - - entry1, _ := reg.GetByAlias("my-api") - loadedAt1 := entry1.LoadedAt - - // Re-add exact same spec - should be idempotent - err = reg.Add("my-api", "/path/to/api.yaml", spec, nil) - if err != nil { - t.Fatalf("Second Add() should succeed (idempotent): %v", err) - } - - entry2, _ := reg.GetByAlias("my-api") - - // LoadedAt should NOT change (idempotent operation) - if !entry2.LoadedAt.Equal(loadedAt1) { - t.Error("LoadedAt should not change for idempotent operation") - } -} - -func TestRegistry_Remove(t *testing.T) { - reg := New() - spec := createTestSpec("Test API", "1.0.0") - - err := reg.Add("test-api", "/path/to/test.yaml", spec, nil) - if err != nil { - t.Fatalf("Failed to add spec: %v", err) - } - - err = reg.Remove("test-api") - if err != nil { - t.Fatalf("Remove() failed: %v", err) - } - - if reg.Count() != 0 { - t.Errorf("Count() = %d, want 0", reg.Count()) - } - - _, err = reg.GetByAlias("test-api") - if err == nil { - t.Error("GetByAlias() should fail after removal") - } -} - -// Helper function to create a test spec. -func createTestSpec(title, version string) *openapi3.T { - return &openapi3.T{ - OpenAPI: "3.0.0", - Info: &openapi3.Info{ - Title: title, - Version: version, - }, - Paths: &openapi3.Paths{}, - } -} - -func TestRegistry_Add_VirtualSpec(t *testing.T) { - reg := New() - spec := createTestSpec("Virtual API", "1.0.0") - - // Add virtual spec (empty file path) - err := reg.Add("virtual-api", "", spec, map[string]string{"source": "filter"}) - if err != nil { - t.Fatalf("Add() failed for virtual spec: %v", err) - } - - entry, err := reg.GetByAlias("virtual-api") - if err != nil { - t.Fatalf("GetByAlias() failed: %v", err) - } - - if entry.SourceType != SourceTypeVirtual { - t.Errorf("entry.SourceType = %q, want %q", entry.SourceType, SourceTypeVirtual) - } - if entry.FilePath != "" { - t.Errorf("entry.FilePath = %q, want empty string", entry.FilePath) - } -} - -func TestRegistry_Add_VirtualSpecUpdate(t *testing.T) { - reg := New() - spec1 := createTestSpec("API", "1.0.0") - spec2 := createTestSpec("API", "2.0.0") - - // Add virtual spec - err := reg.Add("my-virtual", "", spec1, nil) - if err != nil { - t.Fatalf("First Add() failed: %v", err) - } - - // Update with different spec (different checksum) - err = reg.Add("my-virtual", "", spec2, nil) - if err != nil { - t.Fatalf("Second Add() should succeed (update): %v", err) - } - - entry, _ := reg.GetByAlias("my-virtual") - if entry.Spec.Info.Version != "2.0.0" { - t.Errorf("Spec version = %q, want %q", entry.Spec.Info.Version, "2.0.0") - } -} - -func TestRegistry_Add_CollisionFileVsVirtual(t *testing.T) { - reg := New() - spec1 := createTestSpec("API 1", "1.0.0") - spec2 := createTestSpec("API 2", "2.0.0") - - // Add file-based spec - err := reg.Add("my-api", "/path/to/file.yaml", spec1, nil) - if err != nil { - t.Fatalf("File-based Add() failed: %v", err) - } - - // Try to add virtual spec with same alias - should error - err = reg.Add("my-api", "", spec2, nil) - if err == nil { - t.Fatal("Add() should return collision error for file vs virtual") - } - - // Error message should mention type conflict - expectedMsg := "alias 'my-api' is already in use by a file spec" - if err.Error() != expectedMsg { - t.Errorf("error = %q, want %q", err.Error(), expectedMsg) - } -} - -func TestRegistry_Add_CollisionVirtualVsFile(t *testing.T) { - reg := New() - spec1 := createTestSpec("API 1", "1.0.0") - spec2 := createTestSpec("API 2", "2.0.0") - - // Add virtual spec - err := reg.Add("my-api", "", spec1, nil) - if err != nil { - t.Fatalf("Virtual Add() failed: %v", err) - } - - // Try to add file-based spec with same alias - should error - err = reg.Add("my-api", "/path/to/file.yaml", spec2, nil) - if err == nil { - t.Fatal("Add() should return collision error for virtual vs file") - } - - expectedMsg := "alias 'my-api' is already in use by a virtual spec" - if err.Error() != expectedMsg { - t.Errorf("error = %q, want %q", err.Error(), expectedMsg) - } -} diff --git a/tools/mcp-server/internal/resources/alias.go b/tools/mcp-server/internal/resources/alias.go deleted file mode 100644 index 8026586a07..0000000000 --- a/tools/mcp-server/internal/resources/alias.go +++ /dev/null @@ -1,123 +0,0 @@ -package resources - -import ( - "encoding/json" - "fmt" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/mongodb/openapi/tools/cli/pkg/apiversion" - "github.com/mongodb/openapi/tools/cli/pkg/openapi" - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" -) - -// SpecStats holds counts of the spec's top-level components. -type SpecStats struct { - Paths int `json:"paths"` - Operations int `json:"operations"` - Schemas int `json:"schemas"` - Tags int `json:"tags"` -} - -// SpecOverview is the response body for the openapi://specs/{alias} resource. -type SpecOverview struct { - Alias string `json:"alias"` - SourceType registry.SourceType `json:"sourceType"` - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` - Stats SpecStats `json:"stats"` - LatestStableVersion string `json:"latestStableVersion"` - AvailableVersions []string `json:"availableVersions"` - HasPreview bool `json:"hasPreview"` - HasUpcoming bool `json:"hasUpcoming"` -} - -func handleAlias(reg *registry.Registry, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { - alias, err := aliasFromURI(req.Params.URI) - if err != nil { - return nil, err - } - - entry, err := reg.GetByAlias(alias) - if err != nil { - return nil, fmt.Errorf("spec with alias %q not found", alias) - } - - overview := buildSpecOverview(entry) - - data, err := json.Marshal(overview) - if err != nil { - return nil, err - } - - return &mcp.ReadResourceResult{ - Contents: []*mcp.ResourceContents{ - {URI: req.Params.URI, MIMEType: mimeTypeJSON, Text: string(data)}, - }, - }, nil -} - -func buildSpecOverview(entry *registry.Entry) SpecOverview { - overview := SpecOverview{ - Alias: entry.Alias, - SourceType: entry.SourceType, - } - - if entry.Spec == nil { - return overview - } - - if entry.Spec.Info != nil { - overview.Title = entry.Spec.Info.Title - overview.Description = entry.Spec.Info.Description - } - - if entry.Spec.Paths != nil { - overview.Stats.Paths = len(entry.Spec.Paths.Map()) - overview.Stats.Operations = countOperations(entry.Spec) - } - - if entry.Spec.Components != nil { - overview.Stats.Schemas = len(entry.Spec.Components.Schemas) - } - - overview.Stats.Tags = len(entry.Spec.Tags) - - stable, hasPreview, hasUpcoming := extractVersions(entry.Spec) - overview.HasPreview = hasPreview - overview.HasUpcoming = hasUpcoming - // ExtractVersions returns versions sorted ascending by date string (YYYY-MM-DD). - overview.AvailableVersions = stable - if len(stable) > 0 { - overview.LatestStableVersion = stable[len(stable)-1] - } - - return overview -} - -func countOperations(spec *openapi3.T) int { - count := 0 - for _, item := range spec.Paths.Map() { - count += len(item.Operations()) - } - return count -} - -func extractVersions(spec *openapi3.T) (stable []string, hasPreview, hasUpcoming bool) { - stable = []string{} - all, err := openapi.ExtractVersions(spec) - if err != nil || len(all) == 0 { - return stable, false, false - } - for _, v := range all { - switch { - case apiversion.IsPreviewStabilityLevel(v): - hasPreview = true - case apiversion.IsUpcomingStabilityLevel(v): - hasUpcoming = true - default: - stable = append(stable, v) - } - } - return stable, hasPreview, hasUpcoming -} diff --git a/tools/mcp-server/internal/resources/alias_test.go b/tools/mcp-server/internal/resources/alias_test.go deleted file mode 100644 index 5b3c2a9274..0000000000 --- a/tools/mcp-server/internal/resources/alias_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package resources - -import ( - "encoding/json" - "testing" - - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestHandleAlias_Overview verifies that the spec overview contains title, stats, and version info. -func TestHandleAlias_Overview(t *testing.T) { - result, err := handleAlias(newTestRegistry(t), makeRequest("openapi://specs/test-api")) - require.NoError(t, err) - - var body SpecOverview - require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body)) - - assert.Equal(t, "test-api", body.Alias) - assert.Equal(t, "Test API", body.Title) - assert.Equal(t, registry.SourceTypeFile, body.SourceType) - assert.Equal(t, 4, body.Stats.Paths) - assert.Equal(t, 6, body.Stats.Operations) - assert.Equal(t, 2, body.Stats.Tags) - assert.Equal(t, 2, body.Stats.Schemas) - assert.Equal(t, "2025-01-01", body.LatestStableVersion) - assert.Equal(t, []string{"2024-01-01", "2025-01-01"}, body.AvailableVersions) - assert.True(t, body.HasPreview) - assert.True(t, body.HasUpcoming) -} - -// TestHandleAlias_NotFound verifies that reading a non-existent alias returns an error. -func TestHandleAlias_NotFound(t *testing.T) { - _, err := handleAlias(registry.New(), makeRequest("openapi://specs/nonexistent")) - require.Error(t, err) -} - -// TestHandleAlias_URIMissingAlias verifies that a URI without an alias segment returns an error. -func TestHandleAlias_URIMissingAlias(t *testing.T) { - _, err := handleAlias(registry.New(), makeRequest("not-a-valid-uri")) - require.Error(t, err) -} - -// TestHandleAlias_URIExtraSegments verifies that a URI with extra path segments is rejected. -func TestHandleAlias_URIExtraSegments(t *testing.T) { - _, err := handleAlias(registry.New(), makeRequest("openapi://specs/test-api/tags/Clusters")) - require.Error(t, err) -} diff --git a/tools/mcp-server/internal/resources/resources.go b/tools/mcp-server/internal/resources/resources.go deleted file mode 100644 index a26f49d213..0000000000 --- a/tools/mcp-server/internal/resources/resources.go +++ /dev/null @@ -1,52 +0,0 @@ -package resources - -import ( - "context" - - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" -) - -const mimeTypeJSON = "application/json" - -// Register registers all static resources and resource template handlers with the server. -func Register(server *mcp.Server, reg *registry.Registry) { - server.AddResource(&mcp.Resource{ - URI: "openapi://specs", - Name: "specs", - Description: "Start here. Lists all OpenAPI specifications currently loaded in the registry. " + - "Each entry includes the alias (used to reference the spec in all other resources and tools), " + - "sourceType ('file' for specs loaded from disk, 'virtual' for sliced subsets), " + - "and filePath (empty for virtual specs). " + - "Read this resource first to discover what aliases are available before using other resources or tools.", - MIMEType: mimeTypeJSON, - }, makeSpecsHandler(reg)) - - server.AddResourceTemplate(&mcp.ResourceTemplate{ - URITemplate: "openapi://specs/{alias}", - Name: "spec-overview", - Description: "Returns a structural overview of a single loaded spec identified by {alias}. " + - "Includes title, description, and stats (path count, operation count, schema count, tag count). " + - "For versioned APIs, also returns: " + - "latestStableVersion (the most recent stable YYYY-MM-DD version), " + - "availableVersions (all stable date-based versions in ascending order), " + - "hasPreview (true if any preview operations exist), " + - "hasUpcoming (true if any upcoming operations exist). " + - "Use this to understand the scope of a spec before searching or slicing it.", - MIMEType: mimeTypeJSON, - }, makeAliasHandler(reg)) -} - -// makeSpecsHandler creates the handler for the openapi://specs resource. -func makeSpecsHandler(reg *registry.Registry) mcp.ResourceHandler { - return func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { - return handleSpecs(reg, req) - } -} - -// makeAliasHandler creates the handler for the openapi://specs/{alias} resource template. -func makeAliasHandler(reg *registry.Registry) mcp.ResourceHandler { - return func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { - return handleAlias(reg, req) - } -} diff --git a/tools/mcp-server/internal/resources/specs.go b/tools/mcp-server/internal/resources/specs.go deleted file mode 100644 index 8f87c46bdf..0000000000 --- a/tools/mcp-server/internal/resources/specs.go +++ /dev/null @@ -1,45 +0,0 @@ -package resources - -import ( - "encoding/json" - - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" -) - -// SpecSummary is a summary of a single spec returned by the openapi://specs resource. -type SpecSummary struct { - Alias string `json:"alias"` - SourceType registry.SourceType `json:"sourceType"` - FilePath string `json:"filePath,omitempty"` -} - -// SpecsResource is the response body for the openapi://specs resource. -type SpecsResource struct { - Specs []SpecSummary `json:"specs"` - Total int `json:"total"` -} - -func handleSpecs(reg *registry.Registry, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { - entries := reg.List() - - summaries := make([]SpecSummary, len(entries)) - for i, entry := range entries { - summaries[i] = SpecSummary{ - Alias: entry.Alias, - SourceType: entry.SourceType, - FilePath: entry.FilePath, - } - } - - data, err := json.Marshal(SpecsResource{Specs: summaries, Total: len(summaries)}) - if err != nil { - return nil, err - } - - return &mcp.ReadResourceResult{ - Contents: []*mcp.ResourceContents{ - {URI: req.Params.URI, MIMEType: mimeTypeJSON, Text: string(data)}, - }, - }, nil -} diff --git a/tools/mcp-server/internal/resources/specs_test.go b/tools/mcp-server/internal/resources/specs_test.go deleted file mode 100644 index b484e91461..0000000000 --- a/tools/mcp-server/internal/resources/specs_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package resources - -import ( - "encoding/json" - "testing" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestHandleSpecs_EmptyRegistry verifies that an empty registry returns an empty list. -func TestHandleSpecs_EmptyRegistry(t *testing.T) { - result, err := handleSpecs(registry.New(), makeRequest("openapi://specs")) - require.NoError(t, err) - - var body SpecsResource - require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body)) - assert.Equal(t, 0, body.Total) - assert.Empty(t, body.Specs) -} - -// TestHandleSpecs_WithEntries verifies that loaded specs are returned with alias, sourceType, and filePath. -func TestHandleSpecs_WithEntries(t *testing.T) { - result, err := handleSpecs(newTestRegistry(t), makeRequest("openapi://specs")) - require.NoError(t, err) - - var body SpecsResource - require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body)) - require.Equal(t, 1, body.Total) - - s := body.Specs[0] - assert.Equal(t, "test-api", s.Alias) - assert.Equal(t, registry.SourceTypeFile, s.SourceType) - assert.Equal(t, "/test/api.yaml", s.FilePath) -} - -// TestHandleSpecs_VirtualSpecHasNoFilePath verifies that virtual specs omit filePath. -func TestHandleSpecs_VirtualSpecHasNoFilePath(t *testing.T) { - reg := registry.New() - require.NoError(t, reg.Add("virtual-api", "", &openapi3.T{Info: &openapi3.Info{Title: "Virtual"}}, nil)) - - result, err := handleSpecs(reg, makeRequest("openapi://specs")) - require.NoError(t, err) - - var body SpecsResource - require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body)) - assert.Empty(t, body.Specs[0].FilePath) - assert.Equal(t, registry.SourceTypeVirtual, body.Specs[0].SourceType) -} diff --git a/tools/mcp-server/internal/resources/tags.go b/tools/mcp-server/internal/resources/tags.go deleted file mode 100644 index 6e957bba14..0000000000 --- a/tools/mcp-server/internal/resources/tags.go +++ /dev/null @@ -1,105 +0,0 @@ -package resources - -import ( - "encoding/json" - "fmt" - "sort" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" -) - -// TagOperation represents a single operation belonging to a tag. -type TagOperation struct { - OperationID string `json:"operationId"` - Method string `json:"method"` - Path string `json:"path"` - Summary string `json:"summary"` -} - -// TagsResource is the response body for the openapi://specs/{alias}/tags/{tagName} resource. -type TagsResource struct { - Tag string `json:"tag"` - Total int `json:"total"` - Operations []TagOperation `json:"operations"` -} - -func handleTags(reg *registry.Registry, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { - alias, tagName, err := aliasAndTagFromURI(req.Params.URI) - if err != nil { - return nil, err - } - - entry, err := reg.GetByAlias(alias) - if err != nil { - return nil, fmt.Errorf("spec with alias %q not found", alias) - } - - ops, err := operationsByTag(entry.Spec, tagName) - if err != nil { - return nil, err - } - - resource := TagsResource{ - Tag: tagName, - Total: len(ops), - Operations: ops, - } - - data, err := json.Marshal(resource) - if err != nil { - return nil, err - } - - return &mcp.ReadResourceResult{ - Contents: []*mcp.ResourceContents{ - {URI: req.Params.URI, MIMEType: mimeTypeJSON, Text: string(data)}, - }, - }, nil -} - -// operationsByTag returns all operations in the spec tagged with tagName, -// sorted by path then method for deterministic output. -// Returns an error if no operations are found for the given tag. -func operationsByTag(spec *openapi3.T, tagName string) ([]TagOperation, error) { - if spec == nil || spec.Paths == nil { - return nil, fmt.Errorf("tag %q not found in spec", tagName) - } - - var ops []TagOperation - for path, item := range spec.Paths.Map() { - if item == nil { - continue - } - for method, op := range item.Operations() { - if op == nil { - continue - } - for _, t := range op.Tags { - if t == tagName { - ops = append(ops, TagOperation{ - OperationID: op.OperationID, - Method: method, - Path: path, - Summary: op.Summary, - }) - break - } - } - } - } - - if len(ops) == 0 { - return nil, fmt.Errorf("tag %q not found in spec", tagName) - } - - sort.Slice(ops, func(i, j int) bool { - if ops[i].Path != ops[j].Path { - return ops[i].Path < ops[j].Path - } - return ops[i].Method < ops[j].Method - }) - - return ops, nil -} diff --git a/tools/mcp-server/internal/resources/tags_test.go b/tools/mcp-server/internal/resources/tags_test.go deleted file mode 100644 index 16fccbd3d6..0000000000 --- a/tools/mcp-server/internal/resources/tags_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package resources - -import ( - "encoding/json" - "testing" - - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type wantOp struct { - operationID string - method string - path string - summary string -} - -func assertOp(t *testing.T, got TagOperation, want wantOp) { - t.Helper() - assert.Equal(t, want.operationID, got.OperationID) - assert.Equal(t, want.method, got.Method) - assert.Equal(t, want.path, got.Path) - assert.Equal(t, want.summary, got.Summary) -} - -// TestHandleTags_Clusters verifies Clusters operations are returned sorted by path then method. -func TestHandleTags_Clusters(t *testing.T) { - result, err := handleTags(newTestRegistry(t), makeRequest("openapi://specs/test-api/tags/Clusters")) - require.NoError(t, err) - - var body TagsResource - require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body)) - assert.Equal(t, "Clusters", body.Tag) - require.Equal(t, 4, body.Total) - - assertOp(t, body.Operations[0], wantOp{"listClusterDetails", "GET", - "/api/atlas/v2/clusters", "Return All Authorized Clusters in All Projects"}) - assertOp(t, body.Operations[1], wantOp{"listGroupClusters", "GET", - "/api/atlas/v2/groups/{groupId}/clusters", "Return All Clusters in One Project"}) - assertOp(t, body.Operations[2], wantOp{"createGroupCluster", "POST", - "/api/atlas/v2/groups/{groupId}/clusters", "Create One Cluster in One Project"}) - assertOp(t, body.Operations[3], wantOp{"deleteGroupCluster", "DELETE", - "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}", "Remove One Cluster from One Project"}) -} - -// TestHandleTags_FlexClusters verifies that tag names containing spaces are resolved correctly. -// The server decodes the URI automatically so agents can use tag names as they appear in the spec. -func TestHandleTags_FlexClusters(t *testing.T) { - result, err := handleTags(newTestRegistry(t), makeRequest("openapi://specs/test-api/tags/Flex%20Clusters")) - require.NoError(t, err) - - var body TagsResource - require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body)) - assert.Equal(t, "Flex Clusters", body.Tag) - require.Equal(t, 2, body.Total) - - // Sorted: GET before POST on the same path. - assertOp(t, body.Operations[0], wantOp{"listGroupFlexClusters", "GET", - "/api/atlas/v2/groups/{groupId}/flexClusters", "Return All Flex Clusters from One Project"}) - assertOp(t, body.Operations[1], wantOp{"createGroupFlexCluster", "POST", - "/api/atlas/v2/groups/{groupId}/flexClusters", "Create One Flex Cluster in One Project"}) -} - -// TestHandleTags_TagNotFound verifies that a non-existent tag returns an error. -func TestHandleTags_TagNotFound(t *testing.T) { - _, err := handleTags(newTestRegistry(t), makeRequest("openapi://specs/test-api/tags/NonExistent")) - require.Error(t, err) -} - -// TestHandleTags_TagCaseSensitive verifies that tag matching is case-sensitive. -func TestHandleTags_TagCaseSensitive(t *testing.T) { - _, err := handleTags(newTestRegistry(t), makeRequest("openapi://specs/test-api/tags/clusters")) - require.Error(t, err) -} - -// TestHandleTags_AliasNotFound verifies that a non-existent alias returns an error. -func TestHandleTags_AliasNotFound(t *testing.T) { - _, err := handleTags(registry.New(), makeRequest("openapi://specs/nonexistent/tags/Clusters")) - require.Error(t, err) -} - -// TestHandleTags_URIInvalid verifies that a URI missing the tag segment returns an error. -func TestHandleTags_URIInvalid(t *testing.T) { - _, err := handleTags(registry.New(), makeRequest("openapi://specs/test-api")) - require.Error(t, err) -} diff --git a/tools/mcp-server/internal/resources/testhelper_test.go b/tools/mcp-server/internal/resources/testhelper_test.go deleted file mode 100644 index 32e63416ab..0000000000 --- a/tools/mcp-server/internal/resources/testhelper_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package resources - -import ( - "testing" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" -) - -// makeRequest builds a ReadResourceRequest for the given URI. -func makeRequest(uri string) *mcp.ReadResourceRequest { - return &mcp.ReadResourceRequest{Params: &mcp.ReadResourceParams{URI: uri}} -} - -// newTestRegistry returns a registry pre-loaded with the shared test spec under alias "test-api". -func newTestRegistry(t *testing.T) *registry.Registry { - t.Helper() - reg := registry.New() - if err := reg.Add("test-api", "/test/api.yaml", newTestSpec(), nil); err != nil { - t.Fatalf("newTestRegistry: failed to add spec: %v", err) - } - return reg -} - -// newTestSpec builds a synthetic OpenAPI spec whose paths, operation IDs, summaries, and tag names -// are modeled after the real Atlas v2 spec so that test assertions reflect realistic API data. -func newTestSpec() *openapi3.T { - spec := &openapi3.T{ - Info: &openapi3.Info{ - Title: "Test API", - Description: "A test API", - }, - Paths: &openapi3.Paths{}, - Tags: openapi3.Tags{ - {Name: "Clusters"}, - {Name: "Flex Clusters"}, // space in name → percent-encoded as "Flex%20Clusters" in URIs - }, - Components: &openapi3.Components{ - Schemas: map[string]*openapi3.SchemaRef{ - "Cluster": {Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}}, - "FlexCluster": {Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}}, - }, - }, - } - - newStableResp := func() *openapi3.Responses { - return openapi3.NewResponses(openapi3.WithStatus(200, &openapi3.ResponseRef{ - Value: &openapi3.Response{ - Content: openapi3.Content{ - "application/vnd.atlas.2024-01-01+json": &openapi3.MediaType{}, - "application/vnd.atlas.2025-01-01+json": &openapi3.MediaType{}, - }, - }, - })) - } - newPreviewResp := func() *openapi3.Responses { - return openapi3.NewResponses(openapi3.WithStatus(200, &openapi3.ResponseRef{ - Value: &openapi3.Response{ - Content: openapi3.Content{ - "application/vnd.atlas.preview+json": { - Extensions: map[string]any{ - "x-xgen-preview": map[string]any{"public": "true"}, - }, - }, - }, - }, - })) - } - newUpcomingResp := func() *openapi3.Responses { - return openapi3.NewResponses(openapi3.WithStatus(200, &openapi3.ResponseRef{ - Value: &openapi3.Response{ - Content: openapi3.Content{ - "application/vnd.atlas.2026-01-01.upcoming+json": &openapi3.MediaType{}, - }, - }, - })) - } - - spec.Paths.Set("/api/atlas/v2/clusters", &openapi3.PathItem{ - Get: &openapi3.Operation{ - OperationID: "listClusterDetails", - Summary: "Return All Authorized Clusters in All Projects", - Tags: []string{"Clusters"}, - Responses: newStableResp(), - }, - }) - - spec.Paths.Set("/api/atlas/v2/groups/{groupId}/clusters", &openapi3.PathItem{ - Get: &openapi3.Operation{ - OperationID: "listGroupClusters", - Summary: "Return All Clusters in One Project", - Tags: []string{"Clusters"}, - Responses: newStableResp(), - }, - Post: &openapi3.Operation{ - OperationID: "createGroupCluster", - Summary: "Create One Cluster in One Project", - Tags: []string{"Clusters"}, - Responses: newStableResp(), - }, - }) - - spec.Paths.Set("/api/atlas/v2/groups/{groupId}/clusters/{clusterName}", &openapi3.PathItem{ - Delete: &openapi3.Operation{ - OperationID: "deleteGroupCluster", - Summary: "Remove One Cluster from One Project", - Tags: []string{"Clusters"}, - Responses: newPreviewResp(), - }, - }) - - // Flex Clusters: tag name has a space, exercising percent-encoding in URIs. - spec.Paths.Set("/api/atlas/v2/groups/{groupId}/flexClusters", &openapi3.PathItem{ - Get: &openapi3.Operation{ - OperationID: "listGroupFlexClusters", - Summary: "Return All Flex Clusters from One Project", - Tags: []string{"Flex Clusters"}, - Responses: newStableResp(), - }, - Post: &openapi3.Operation{ - OperationID: "createGroupFlexCluster", - Summary: "Create One Flex Cluster in One Project", - Tags: []string{"Flex Clusters"}, - Responses: newUpcomingResp(), - }, - }) - - return spec -} diff --git a/tools/mcp-server/internal/resources/uri.go b/tools/mcp-server/internal/resources/uri.go deleted file mode 100644 index 1fb264c84a..0000000000 --- a/tools/mcp-server/internal/resources/uri.go +++ /dev/null @@ -1,52 +0,0 @@ -package resources - -import ( - "fmt" - "net/url" - "strings" -) - -// parseSpecURI parses a resource URI and validates that it uses the openapi://specs/ base. -// It is the shared entry point for all URI parsing in this package. -func parseSpecURI(uri string) (*url.URL, error) { - u, err := url.Parse(uri) - if err != nil || u.Scheme != "openapi" || u.Host != "specs" { - return nil, fmt.Errorf("invalid resource URI %q: must use openapi://specs/ scheme", uri) - } - return u, nil -} - -// aliasFromURI extracts the alias from openapi://specs/{alias}. -// Returns an error if the path has extra segments or the alias is empty. -func aliasFromURI(uri string) (string, error) { - u, err := parseSpecURI(uri) - if err != nil { - return "", fmt.Errorf("invalid resource URI %q: expected openapi://specs/{alias}", uri) - } - parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/") - if len(parts) != 1 || parts[0] == "" { - return "", fmt.Errorf("invalid resource URI %q: expected openapi://specs/{alias}", uri) - } - return parts[0], nil -} - -// aliasAndTagFromURI extracts the alias and tag name from openapi://specs/{alias}/tags/{tagName}. -// The tag name is percent-decoded so agents can use tag names as they appear in the spec. -func aliasAndTagFromURI(uri string) (alias, tagName string, err error) { - u, err := parseSpecURI(uri) - if err != nil { - return "", "", fmt.Errorf("invalid resource URI %q: expected openapi://specs/{alias}/tags/{tagName}", uri) - } - - // path: /{alias}/tags/{tagName} - parts := strings.SplitN(strings.TrimPrefix(u.Path, "/"), "/", 3) - if len(parts) != 3 || parts[0] == "" || parts[1] != "tags" || parts[2] == "" { - return "", "", fmt.Errorf("invalid resource URI %q: expected openapi://specs/{alias}/tags/{tagName}", uri) - } - - tagName, err = url.PathUnescape(parts[2]) - if err != nil { - return "", "", fmt.Errorf("invalid tag name in URI %q: %w", uri, err) - } - return parts[0], tagName, nil -} diff --git a/tools/mcp-server/internal/resources/uri_test.go b/tools/mcp-server/internal/resources/uri_test.go deleted file mode 100644 index 5f28569580..0000000000 --- a/tools/mcp-server/internal/resources/uri_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package resources - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAliasFromURI(t *testing.T) { - tests := []struct { - name string - uri string - wantAlias string - wantErr bool - }{ - { - name: "valid URI", - uri: "openapi://specs/atlas", - wantAlias: "atlas", - }, - { - name: "wrong scheme", - uri: "https://specs/atlas", - wantErr: true, - }, - { - name: "wrong host", - uri: "openapi://other/atlas", - wantErr: true, - }, - { - name: "arbitrary https URL", - uri: "https://goodle.com/q", - wantErr: true, - }, - { - name: "extra path segments", - uri: "openapi://specs/atlas/tags/Clusters", - wantErr: true, - }, - { - name: "missing alias", - uri: "openapi://specs/", - wantErr: true, - }, - { - name: "empty string", - uri: "", - wantErr: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - alias, err := aliasFromURI(tc.uri) - if tc.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tc.wantAlias, alias) - }) - } -} diff --git a/tools/mcp-server/internal/tools/export.go b/tools/mcp-server/internal/tools/export.go deleted file mode 100644 index 9b5b623d41..0000000000 --- a/tools/mcp-server/internal/tools/export.go +++ /dev/null @@ -1,56 +0,0 @@ -package tools - -import ( - "fmt" - - "github.com/mongodb/openapi/tools/cli/pkg/openapi" - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" - "github.com/spf13/afero" -) - -// ExportParams are the parameters for the export tool. -type ExportParams struct { - Alias string `json:"alias" jsonschema:"Alias of the spec to export"` - FilePath string `json:"filePath" jsonschema:"Path where the file should be saved"` - Format string `json:"format,omitempty" jsonschema:"Output format: 'json' or 'yaml' (default: json)"` -} - -// ExportResult is the result of an export operation. -type ExportResult struct { - Success bool `json:"success"` - Alias string `json:"alias"` - FilePath string `json:"filePath"` - Format string `json:"format"` - Message string `json:"message"` -} - -// handleExport exports a spec from the registry to a file. -// The SDK handles parameter unmarshaling and validation automatically. -func handleExport(reg *registry.Registry, params ExportParams) (ExportResult, error) { - if params.Format == "" { - params.Format = "json" - } - - if params.Format != "json" && params.Format != "yaml" { - return ExportResult{Success: false}, fmt.Errorf("invalid format %q: must be 'json' or 'yaml'", params.Format) - } - - entry, err := reg.GetByAlias(params.Alias) - if err != nil { - return ExportResult{Success: false}, err - } - - fs := afero.NewOsFs() - - if err := openapi.SaveToFile(params.FilePath, params.Format, entry.Spec, fs); err != nil { - return ExportResult{Success: false}, fmt.Errorf("failed to export spec: %w", err) - } - - return ExportResult{ - Success: true, - Alias: params.Alias, - FilePath: params.FilePath, - Format: params.Format, - Message: fmt.Sprintf("Exported '%s' to %s", params.Alias, params.FilePath), - }, nil -} diff --git a/tools/mcp-server/internal/tools/load.go b/tools/mcp-server/internal/tools/load.go deleted file mode 100644 index 4d27be3be6..0000000000 --- a/tools/mcp-server/internal/tools/load.go +++ /dev/null @@ -1,114 +0,0 @@ -package tools - -import ( - "fmt" - "path/filepath" - "regexp" - "strings" - - "github.com/mongodb/openapi/tools/cli/pkg/openapi" - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" -) - -// LoadParams are the parameters for the load tool. -type LoadParams struct { - FilePath string `json:"filePath" jsonschema:"Path to the OpenAPI file (JSON or YAML)"` - Alias string `json:"alias,omitempty" jsonschema:"Optional custom alias (auto-generated from filename if not provided)"` - Metadata map[string]string `json:"metadata,omitempty" jsonschema:"Optional metadata to attach to the spec"` -} - -// LoadResult is the result of a load operation. -type LoadResult struct { - Success bool `json:"success"` - Alias string `json:"alias"` - Message string `json:"message"` - SpecInfo SpecInfo `json:"specInfo,omitempty"` -} - -// SpecInfo contains metadata about the loaded spec. -type SpecInfo struct { - Title string `json:"title,omitempty"` - Version string `json:"version,omitempty"` - PathCount int `json:"pathCount"` - SchemaCount int `json:"schemaCount"` -} - -// handleLoad loads an OpenAPI spec from a file into the registry. -// The SDK handles parameter unmarshaling and validation automatically. -func handleLoad(reg *registry.Registry, params LoadParams) (LoadResult, error) { - alias := params.Alias - if alias == "" { - var err error - alias, err = generateAliasFromPath(params.FilePath) - if err != nil { - return LoadResult{Success: false}, fmt.Errorf("failed to generate alias: %w", err) - } - } else { - alias = strings.ToLower(alias) - if !isValidAlias(alias) { - return LoadResult{Success: false}, fmt.Errorf("invalid alias '%s': only lowercase letters, numbers, and hyphens allowed", alias) - } - } - - loader := openapi.NewLoader() - specInfo, err := loader.LoadFromPath(params.FilePath) - if err != nil { - return LoadResult{Success: false}, fmt.Errorf("failed to load spec from %q: %w", params.FilePath, err) - } - - err = reg.Add(alias, params.FilePath, specInfo.Spec, params.Metadata) - if err != nil { - return LoadResult{Success: false}, fmt.Errorf("%w. Please use 'unload' first or provide a different alias", err) - } - - schemaCount := 0 - if specInfo.Spec.Components != nil && specInfo.Spec.Components.Schemas != nil { - schemaCount = len(specInfo.Spec.Components.Schemas) - } - - info := SpecInfo{ - Title: specInfo.Spec.Info.Title, - Version: specInfo.Spec.Info.Version, - PathCount: len(specInfo.Spec.Paths.Map()), - SchemaCount: schemaCount, - } - - return LoadResult{ - Success: true, - Alias: alias, - Message: fmt.Sprintf("Loaded '%s' successfully", alias), - SpecInfo: info, - }, nil -} - -// generateAliasFromPath creates a valid alias from a file path. -func generateAliasFromPath(filePath string) (string, error) { - filename := filepath.Base(filePath) - ext := filepath.Ext(filename) - name := strings.TrimSuffix(filename, ext) - - name = strings.ToLower(name) - - re := regexp.MustCompile(`[^a-z0-9-]+`) - alias := re.ReplaceAllString(name, "-") - - alias = strings.Trim(alias, "-") - - re = regexp.MustCompile(`-+`) - alias = re.ReplaceAllString(alias, "-") - - if !isValidAlias(alias) { - return "", fmt.Errorf("could not generate valid alias from filename '%s'", filename) - } - - return alias, nil -} - -// isValidAlias checks if alias contains only allowed characters. -func isValidAlias(alias string) bool { - if alias == "" { - return false - } - matched, _ := regexp.MatchString(`^[a-z0-9-]+$`, alias) - return matched -} diff --git a/tools/mcp-server/internal/tools/load_test.go b/tools/mcp-server/internal/tools/load_test.go deleted file mode 100644 index f9df5eb53e..0000000000 --- a/tools/mcp-server/internal/tools/load_test.go +++ /dev/null @@ -1,287 +0,0 @@ -package tools - -import ( - "testing" - - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" - "github.com/spf13/afero" -) - -func TestGenerateAliasFromPath(t *testing.T) { - tests := []struct { - name string - filePath string - want string - wantErr bool - }{ - { - name: "simple yaml file", - filePath: "/path/to/api.yaml", - want: "api", - wantErr: false, - }, - { - name: "simple json file", - filePath: "/path/to/openapi.json", - want: "openapi", - wantErr: false, - }, - { - name: "file with hyphen", - filePath: "petstore-api.yaml", - want: "petstore-api", - wantErr: false, - }, - { - name: "file with spaces", - filePath: "Pet Store API.yaml", - want: "pet-store-api", - wantErr: false, - }, - { - name: "file with uppercase", - filePath: "PetStoreAPI.yaml", - want: "petstoreapi", - wantErr: false, - }, - { - name: "file with mixed case and spaces", - filePath: "MongoDB Atlas API.yaml", - want: "mongodb-atlas-api", - wantErr: false, - }, - { - name: "file with underscores", - filePath: "my_awesome_api.yaml", - want: "my-awesome-api", - wantErr: false, - }, - { - name: "file with numbers", - filePath: "api-v2.yaml", - want: "api-v2", - wantErr: false, - }, - { - name: "file with special characters", - filePath: "api@#$%spec.yaml", - want: "api-spec", - wantErr: false, - }, - { - name: "file with multiple consecutive special chars", - filePath: "api___---spec.yaml", - want: "api-spec", - wantErr: false, - }, - { - name: "file with leading/trailing hyphens", - filePath: "-api-.yaml", - want: "api", - wantErr: false, - }, - { - name: "complex real-world example", - filePath: "/Users/me/projects/MongoDB Atlas Admin API v2.0.yaml", - want: "mongodb-atlas-admin-api-v2-0", - wantErr: false, - }, - { - name: "file with dots in name", - filePath: "api.spec.v1.yaml", - want: "api-spec-v1", - wantErr: false, - }, - { - name: "only special chars (should error)", - filePath: "@#$%.yaml", - want: "", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := generateAliasFromPath(tt.filePath) - if (err != nil) != tt.wantErr { - t.Errorf("generateAliasFromPath() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got != tt.want { - t.Errorf("generateAliasFromPath() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestIsValidAlias(t *testing.T) { - tests := []struct { - name string - alias string - want bool - }{ - {"valid simple", "api", true}, - {"valid with hyphen", "my-api", true}, - {"valid with numbers", "api-v2", true}, - {"valid complex", "mongodb-atlas-api-v2", true}, - {"invalid uppercase", "MyAPI", false}, - {"invalid underscore", "my_api", false}, - {"invalid space", "my api", false}, - {"invalid special char", "api@spec", false}, - {"invalid empty", "", false}, - {"invalid just hyphen", "-", true}, // hyphen is allowed - {"invalid starts with number", "2api", true}, // numbers are allowed - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := isValidAlias(tt.alias); got != tt.want { - t.Errorf("isValidAlias(%q) = %v, want %v", tt.alias, got, tt.want) - } - }) - } -} - -func TestHandleLoad_AutoGeneratedAlias(t *testing.T) { - reg := registry.New() - - // Create a test YAML file - testFile := createTestOpenAPIFile(t, "test-api.yaml") - defer cleanupTestFile(t, testFile) - - params := LoadParams{ - FilePath: testFile, - // No alias - should auto-generate - } - - result, err := handleLoad(reg, params) - if err != nil { - t.Fatalf("handleLoad() failed: %v", err) - } - - if !result.Success { - t.Error("result.Success = false, want true") - } - - if result.Alias != "test-api" { - t.Errorf("result.Alias = %q, want %q", result.Alias, "test-api") - } - - // Verify it's in the registry - entry, err := reg.GetByAlias("test-api") - if err != nil { - t.Fatalf("GetByAlias() failed: %v", err) - } - - if entry.FilePath != testFile { - t.Errorf("entry.FilePath = %q, want %q", entry.FilePath, testFile) - } -} - -func TestHandleLoad_CustomAlias(t *testing.T) { - reg := registry.New() - - testFile := createTestOpenAPIFile(t, "api.yaml") - defer cleanupTestFile(t, testFile) - - params := LoadParams{ - FilePath: testFile, - Alias: "my-custom-alias", - } - - result, err := handleLoad(reg, params) - if err != nil { - t.Fatalf("handleLoad() failed: %v", err) - } - - if result.Alias != "my-custom-alias" { - t.Errorf("result.Alias = %q, want %q", result.Alias, "my-custom-alias") - } - - // Verify it's in the registry with custom alias - _, err = reg.GetByAlias("my-custom-alias") - if err != nil { - t.Errorf("GetByAlias() failed: %v", err) - } -} - -func TestHandleLoad_InvalidAlias(t *testing.T) { - reg := registry.New() - - testFile := createTestOpenAPIFile(t, "api.yaml") - defer cleanupTestFile(t, testFile) - - params := LoadParams{ - FilePath: testFile, - Alias: "Invalid_Alias!", // Contains invalid characters - } - - _, err := handleLoad(reg, params) - if err == nil { - t.Fatal("handleLoad() should fail with invalid alias") - } -} - -func TestHandleLoad_Collision(t *testing.T) { - reg := registry.New() - - // Load first file - testFile1 := createTestOpenAPIFile(t, "api1.yaml") - defer cleanupTestFile(t, testFile1) - - params1 := LoadParams{ - FilePath: testFile1, - Alias: "shared-alias", - } - - _, err := handleLoad(reg, params1) - if err != nil { - t.Fatalf("First load failed: %v", err) - } - - // Try to load different file with same alias - testFile2 := createTestOpenAPIFile(t, "api2.yaml") - defer cleanupTestFile(t, testFile2) - - params2 := LoadParams{ - FilePath: testFile2, - Alias: "shared-alias", - } - - _, err = handleLoad(reg, params2) - if err == nil { - t.Fatal("handleLoad() should fail with collision error") - } -} - -// Helper: creates a temporary OpenAPI file for testing. -func createTestOpenAPIFile(t *testing.T, filename string) string { - t.Helper() - - content := `openapi: 3.0.0 -info: - title: Test API - version: 1.0.0 -paths: - /test: - get: - summary: Test endpoint - responses: - '200': - description: Success -` - - tmpFile := t.TempDir() + "/" + filename - fs := afero.NewOsFs() - err := afero.WriteFile(fs, tmpFile, []byte(content), 0o600) - if err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - - return tmpFile -} - -func cleanupTestFile(t *testing.T, _ string) { - t.Helper() - // t.TempDir() handles cleanup automatically -} diff --git a/tools/mcp-server/internal/tools/search.go b/tools/mcp-server/internal/tools/search.go deleted file mode 100644 index 061c753944..0000000000 --- a/tools/mcp-server/internal/tools/search.go +++ /dev/null @@ -1,487 +0,0 @@ -package tools - -import ( - "fmt" - "regexp" - "strings" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" -) - -// SearchParams are the parameters for the search tool. -type SearchParams struct { - Alias string `json:"alias" jsonschema:"Alias of the spec to search"` - Pattern string `json:"pattern" jsonschema:"Regular expression pattern to search for"` - SearchIn []string `json:"searchIn,omitempty" jsonschema:"Optional: categories to search"` - CaseSensitive bool `json:"caseSensitive,omitempty" jsonschema:"Optional: case-sensitive search"` - Limit int `json:"limit,omitempty" jsonschema:"Optional: max results per category"` -} - -// SearchResult is the result of a search operation. -type SearchResult struct { - Success bool `json:"success"` - Alias string `json:"alias"` - Pattern string `json:"pattern"` - Operations []OperationMatch `json:"operations"` - Schemas []SchemaMatch `json:"schemas"` - Parameters []ParameterMatch `json:"parameters"` - Responses []ResponseMatch `json:"responses"` - Tags []TagMatch `json:"tags"` - Paths []PathMatch `json:"paths"` - Pagination PaginationMetadata `json:"pagination"` -} - -// PaginationMetadata contains pagination information. -type PaginationMetadata struct { - Limit int `json:"limit"` - TotalMatches int `json:"totalMatches"` - CategoryCounts map[string]int `json:"categoryCounts"` - CategoryHasMore map[string]bool `json:"categoryHasMore,omitempty"` -} - -// OperationMatch represents a matched operation. -type OperationMatch struct { - Path string `json:"path"` - Method string `json:"method"` - OperationID string `json:"operationId,omitempty"` - Summary string `json:"summary,omitempty"` - Description string `json:"description,omitempty"` - Tags []string `json:"tags,omitempty"` - MatchedIn []string `json:"matchedIn"` - MatchedText string `json:"matchedText"` -} - -// SchemaMatch represents a matched schema. -type SchemaMatch struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - MatchedIn []string `json:"matchedIn"` - MatchedText string `json:"matchedText"` - MatchedProperties []string `json:"matchedProperties,omitempty"` -} - -// ParameterMatch represents a matched parameter. -type ParameterMatch struct { - Name string `json:"name"` - In string `json:"in,omitempty"` - Description string `json:"description,omitempty"` - MatchedIn []string `json:"matchedIn"` - MatchedText string `json:"matchedText"` -} - -// ResponseMatch represents a matched response. -type ResponseMatch struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - MatchedIn []string `json:"matchedIn"` - MatchedText string `json:"matchedText"` -} - -// TagMatch represents a matched tag. -type TagMatch struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - MatchedIn []string `json:"matchedIn"` - MatchedText string `json:"matchedText"` -} - -// PathMatch represents a matched path. -type PathMatch struct { - Path string `json:"path"` - MatchedIn []string `json:"matchedIn"` - MatchedText string `json:"matchedText"` -} - -// validSearchCategories defines the valid categories for searchIn parameter. -var validSearchCategories = map[string]bool{ - "operations": true, - "schemas": true, - "parameters": true, - "responses": true, - "tags": true, - "paths": true, -} - -// Helper methods for SearchResult - -// totalCount returns the total number of matches across all categories. -func (r *SearchResult) totalCount() int { - return len(r.Operations) + len(r.Schemas) + len(r.Parameters) + - len(r.Responses) + len(r.Tags) + len(r.Paths) -} - -// categoryCounts returns a map of category names to their match counts. -func (r *SearchResult) categoryCounts() map[string]int { - return map[string]int{ - "operations": len(r.Operations), - "schemas": len(r.Schemas), - "parameters": len(r.Parameters), - "responses": len(r.Responses), - "tags": len(r.Tags), - "paths": len(r.Paths), - } -} - -// Helper functions for matching - -// checkAndRecordMatch checks if a value matches the regex and records the match. -func checkAndRecordMatch(re *regexp.Regexp, fieldName, value string, matchedIn, matchedTexts *[]string) { - if value != "" && re.MatchString(value) { - *matchedIn = append(*matchedIn, fieldName) - *matchedTexts = append(*matchedTexts, re.FindString(value)) - } -} - -// handleSearch searches for matches in an OpenAPI spec. -func handleSearch(reg *registry.Registry, params SearchParams) (SearchResult, error) { - // Set defaults - if params.Limit == 0 { - params.Limit = 100 - } - - // Validate searchIn categories - if len(params.SearchIn) > 0 { - var invalidCategories []string - for _, category := range params.SearchIn { - if !validSearchCategories[category] { - invalidCategories = append(invalidCategories, category) - } - } - if len(invalidCategories) > 0 { - return SearchResult{Success: false}, fmt.Errorf( - "invalid searchIn categories: %v. Valid categories are: operations, schemas, parameters, responses, tags, paths", - invalidCategories, - ) - } - } - - // Validate and compile regex - var re *regexp.Regexp - var err error - if params.CaseSensitive { - re, err = regexp.Compile(params.Pattern) - } else { - re, err = regexp.Compile("(?i)" + params.Pattern) - } - if err != nil { - return SearchResult{Success: false}, fmt.Errorf("invalid regex pattern: %w", err) - } - - // Get spec from registry - entry, err := reg.GetByAlias(params.Alias) - if err != nil { - return SearchResult{Success: false}, err - } - - // Determine what to search - searchIn := params.SearchIn - if len(searchIn) == 0 { - searchIn = []string{"operations", "schemas", "parameters", "responses", "tags", "paths"} - } - - // Perform search - result := SearchResult{ - Success: true, - Alias: params.Alias, - Pattern: params.Pattern, - } - - for _, category := range searchIn { - switch category { - case "operations": - result.Operations = searchOperations(entry.Spec, re) - case "schemas": - result.Schemas = searchSchemas(entry.Spec, re) - case "parameters": - result.Parameters = searchParameters(entry.Spec, re) - case "responses": - result.Responses = searchResponses(entry.Spec, re) - case "tags": - result.Tags = searchTags(entry.Spec, re) - case "paths": - result.Paths = searchPaths(entry.Spec, re) - } - } - - // Apply per-category pagination - applyPagination(&result, params.Limit) - - return result, nil -} - -// searchOperations searches for matches in operations. -func searchOperations(spec *openapi3.T, re *regexp.Regexp) []OperationMatch { - var matches []OperationMatch - - if spec.Paths == nil { - return matches - } - - for path, pathItem := range spec.Paths.Map() { - for method, operation := range pathItem.Operations() { - match := OperationMatch{ - Path: path, - Method: strings.ToUpper(method), - OperationID: operation.OperationID, - Summary: operation.Summary, - Description: operation.Description, - Tags: operation.Tags, - } - - var matchedIn []string - var matchedTexts []string - - // Search in various fields - checkAndRecordMatch(re, "operationId", operation.OperationID, &matchedIn, &matchedTexts) - checkAndRecordMatch(re, "summary", operation.Summary, &matchedIn, &matchedTexts) - checkAndRecordMatch(re, "description", operation.Description, &matchedIn, &matchedTexts) - - // Search in tags - for _, tag := range operation.Tags { - if re.MatchString(tag) { - matchedIn = append(matchedIn, "tags") - matchedTexts = append(matchedTexts, re.FindString(tag)) - break - } - } - - if len(matchedIn) > 0 { - match.MatchedIn = matchedIn - match.MatchedText = strings.Join(matchedTexts, ", ") - matches = append(matches, match) - } - } - } - - return matches -} - -// searchSchemas searches for matches in component schemas. -func searchSchemas(spec *openapi3.T, re *regexp.Regexp) []SchemaMatch { - var matches []SchemaMatch - - if spec.Components == nil || spec.Components.Schemas == nil { - return matches - } - - for name, schemaRef := range spec.Components.Schemas { - if schemaRef == nil || schemaRef.Value == nil { - continue - } - - schema := schemaRef.Value - match := SchemaMatch{ - Name: name, - Description: schema.Description, - } - - var matchedIn []string - var matchedTexts []string - var matchedProps []string - - // Search in schema name and description - checkAndRecordMatch(re, "name", name, &matchedIn, &matchedTexts) - checkAndRecordMatch(re, "description", schema.Description, &matchedIn, &matchedTexts) - - // Search in property names - if schema.Properties != nil { - for propName := range schema.Properties { - if re.MatchString(propName) { - matchedIn = append(matchedIn, "properties") - matchedProps = append(matchedProps, propName) - matchedTexts = append(matchedTexts, re.FindString(propName)) - } - } - } - - if len(matchedIn) > 0 { - match.MatchedIn = matchedIn - match.MatchedText = strings.Join(matchedTexts, ", ") - if len(matchedProps) > 0 { - match.MatchedProperties = matchedProps - } - matches = append(matches, match) - } - } - - return matches -} - -// searchParameters searches for matches in component parameters. -func searchParameters(spec *openapi3.T, re *regexp.Regexp) []ParameterMatch { - var matches []ParameterMatch - - if spec.Components == nil || spec.Components.Parameters == nil { - return matches - } - - for name, paramRef := range spec.Components.Parameters { - if paramRef == nil || paramRef.Value == nil { - continue - } - - param := paramRef.Value - match := ParameterMatch{ - Name: param.Name, - In: param.In, - Description: param.Description, - } - - var matchedIn []string - var matchedTexts []string - - // Search in parameter name (check both component name and param.Name) - if re.MatchString(name) || re.MatchString(param.Name) { - matchedIn = append(matchedIn, "name") - matchedTexts = append(matchedTexts, re.FindString(param.Name)) - } - - // Search in description - checkAndRecordMatch(re, "description", param.Description, &matchedIn, &matchedTexts) - - if len(matchedIn) > 0 { - match.MatchedIn = matchedIn - match.MatchedText = strings.Join(matchedTexts, ", ") - matches = append(matches, match) - } - } - - return matches -} - -// searchResponses searches for matches in component responses. -func searchResponses(spec *openapi3.T, re *regexp.Regexp) []ResponseMatch { - var matches []ResponseMatch - - if spec.Components == nil || spec.Components.Responses == nil { - return matches - } - - for name, respRef := range spec.Components.Responses { - if respRef == nil || respRef.Value == nil { - continue - } - - resp := respRef.Value - description := "" - if resp.Description != nil { - description = *resp.Description - } - - match := ResponseMatch{ - Name: name, - Description: description, - } - - var matchedIn []string - var matchedTexts []string - - // Search in response name and description - checkAndRecordMatch(re, "name", name, &matchedIn, &matchedTexts) - checkAndRecordMatch(re, "description", description, &matchedIn, &matchedTexts) - - if len(matchedIn) > 0 { - match.MatchedIn = matchedIn - match.MatchedText = strings.Join(matchedTexts, ", ") - matches = append(matches, match) - } - } - - return matches -} - -// searchTags searches for matches in tags. -func searchTags(spec *openapi3.T, re *regexp.Regexp) []TagMatch { - var matches []TagMatch - - if spec.Tags == nil { - return matches - } - - for _, tag := range spec.Tags { - match := TagMatch{ - Name: tag.Name, - Description: tag.Description, - } - - var matchedIn []string - var matchedTexts []string - - // Search in tag name and description - checkAndRecordMatch(re, "name", tag.Name, &matchedIn, &matchedTexts) - checkAndRecordMatch(re, "description", tag.Description, &matchedIn, &matchedTexts) - - if len(matchedIn) > 0 { - match.MatchedIn = matchedIn - match.MatchedText = strings.Join(matchedTexts, ", ") - matches = append(matches, match) - } - } - - return matches -} - -// searchPaths searches for matches in path patterns. -func searchPaths(spec *openapi3.T, re *regexp.Regexp) []PathMatch { - var matches []PathMatch - - if spec.Paths == nil { - return matches - } - - for path := range spec.Paths.Map() { - if re.MatchString(path) { - match := PathMatch{ - Path: path, - MatchedIn: []string{"path"}, - MatchedText: re.FindString(path), - } - matches = append(matches, match) - } - } - - return matches -} - -// applyPagination applies per-category limit to search results. -func applyPagination(result *SearchResult, limit int) { - // Store counts before truncation - totalMatches := result.totalCount() - categoryCounts := result.categoryCounts() - categoryHasMore := make(map[string]bool) - - // Apply limit to each category - if len(result.Operations) > limit { - result.Operations = result.Operations[:limit] - categoryHasMore["operations"] = true - } - if len(result.Schemas) > limit { - result.Schemas = result.Schemas[:limit] - categoryHasMore["schemas"] = true - } - if len(result.Parameters) > limit { - result.Parameters = result.Parameters[:limit] - categoryHasMore["parameters"] = true - } - if len(result.Responses) > limit { - result.Responses = result.Responses[:limit] - categoryHasMore["responses"] = true - } - if len(result.Tags) > limit { - result.Tags = result.Tags[:limit] - categoryHasMore["tags"] = true - } - if len(result.Paths) > limit { - result.Paths = result.Paths[:limit] - categoryHasMore["paths"] = true - } - - // Build pagination metadata - result.Pagination = PaginationMetadata{ - Limit: limit, - TotalMatches: totalMatches, - CategoryCounts: categoryCounts, - CategoryHasMore: categoryHasMore, - } -} diff --git a/tools/mcp-server/internal/tools/search_test.go b/tools/mcp-server/internal/tools/search_test.go deleted file mode 100644 index 29a37450ce..0000000000 --- a/tools/mcp-server/internal/tools/search_test.go +++ /dev/null @@ -1,497 +0,0 @@ -package tools - -import ( - "strings" - "testing" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" -) - -// setupTestRegistry creates a registry with the test spec loaded. -func setupTestRegistry(t *testing.T) *registry.Registry { - t.Helper() - reg := registry.New() - spec := createTestSpec() - err := reg.Add("test-api", "/test/api.yaml", spec, nil) - if err != nil { - t.Fatalf("Failed to add spec: %v", err) - } - return reg -} - -// ExpectedResults defines expected search results for table-driven tests. -type ExpectedResults struct { - Operations []string - Schemas []string - Parameters []string - Paths []string - Tags []string - TotalCount int -} - -// assertSearchResults verifies all aspects of search results. -func assertSearchResults(t *testing.T, result *SearchResult, expected *ExpectedResults) { - t.Helper() - assertExactOperationIDs(t, result.Operations, expected.Operations) - assertExactSchemaNames(t, result.Schemas, expected.Schemas) - assertExactParameterNames(t, result.Parameters, expected.Parameters) - assertExactPaths(t, result.Paths, expected.Paths) - - if len(expected.Tags) > 0 { - if len(result.Tags) != len(expected.Tags) { - t.Errorf("Expected %d tags, got %d", len(expected.Tags), len(result.Tags)) - } - for i, expectedTag := range expected.Tags { - if i < len(result.Tags) && result.Tags[i].Name != expectedTag { - t.Errorf("Expected tag '%s', got '%s'", expectedTag, result.Tags[i].Name) - } - } - } - - if expected.TotalCount > 0 && result.Pagination.TotalMatches != expected.TotalCount { - t.Errorf("Expected totalMatches=%d, got %d", expected.TotalCount, result.Pagination.TotalMatches) - } -} - -// assertExactOperationIDs verifies the exact set of operation IDs. -func assertExactOperationIDs(t *testing.T, operations []OperationMatch, expectedIDs []string) { - t.Helper() - if len(operations) != len(expectedIDs) { - t.Errorf("Expected %d operations, got %d", len(expectedIDs), len(operations)) - t.Logf("Got: %v", getOperationIDs(operations)) - t.Logf("Expected: %v", expectedIDs) - } - found := make(map[string]bool) - for i := range operations { - found[operations[i].OperationID] = true - } - for _, expectedID := range expectedIDs { - if !found[expectedID] { - t.Errorf("Expected operation ID '%s' not found", expectedID) - } - } - expectedSet := make(map[string]bool) - for _, id := range expectedIDs { - expectedSet[id] = true - } - for i := range operations { - if !expectedSet[operations[i].OperationID] { - t.Errorf("Unexpected operation ID '%s' found", operations[i].OperationID) - } - } -} - -// assertExactSchemaNames verifies the exact set of schema names. -func assertExactSchemaNames(t *testing.T, schemas []SchemaMatch, expectedNames []string) { - t.Helper() - if len(schemas) != len(expectedNames) { - t.Errorf("Expected %d schemas, got %d", len(expectedNames), len(schemas)) - t.Logf("Got: %v", getSchemaNames(schemas)) - t.Logf("Expected: %v", expectedNames) - } - found := make(map[string]bool) - for _, schema := range schemas { - found[schema.Name] = true - } - for _, expectedName := range expectedNames { - if !found[expectedName] { - t.Errorf("Expected schema '%s' not found", expectedName) - } - } -} - -// assertExactParameterNames verifies the exact set of parameter names. -func assertExactParameterNames(t *testing.T, parameters []ParameterMatch, expectedNames []string) { - t.Helper() - if len(parameters) != len(expectedNames) { - t.Errorf("Expected %d parameters, got %d", len(expectedNames), len(parameters)) - t.Logf("Got: %v", getParameterNames(parameters)) - t.Logf("Expected: %v", expectedNames) - } - found := make(map[string]bool) - for _, param := range parameters { - found[param.Name] = true - } - for _, expectedName := range expectedNames { - if !found[expectedName] { - t.Errorf("Expected parameter '%s' not found", expectedName) - } - } -} - -// assertExactPaths verifies the exact set of paths. -func assertExactPaths(t *testing.T, paths []PathMatch, expectedPaths []string) { - t.Helper() - if len(paths) != len(expectedPaths) { - t.Errorf("Expected %d paths, got %d", len(expectedPaths), len(paths)) - t.Logf("Got: %v", getPaths(paths)) - t.Logf("Expected: %v", expectedPaths) - } - found := make(map[string]bool) - for _, path := range paths { - found[path.Path] = true - } - for _, expectedPath := range expectedPaths { - if !found[expectedPath] { - t.Errorf("Expected path '%s' not found", expectedPath) - } - } -} - -// Helper functions to extract names/IDs for logging. -func getOperationIDs(operations []OperationMatch) []string { - ids := make([]string, len(operations)) - for i := range operations { - ids[i] = operations[i].OperationID - } - return ids -} - -func getSchemaNames(schemas []SchemaMatch) []string { - names := make([]string, len(schemas)) - for i, schema := range schemas { - names[i] = schema.Name - } - return names -} - -func getParameterNames(parameters []ParameterMatch) []string { - names := make([]string, len(parameters)) - for i, param := range parameters { - names[i] = param.Name - } - return names -} - -func getPaths(paths []PathMatch) []string { - pathStrs := make([]string, len(paths)) - for i, path := range paths { - pathStrs[i] = path.Path - } - return pathStrs -} - -func TestHandleSearch_Patterns(t *testing.T) { - reg := setupTestRegistry(t) - - tests := []struct { - name string - params SearchParams - expected ExpectedResults - checkFn func(*testing.T, SearchResult) // Optional additional checks - }{ - { - name: "pattern: user", - params: SearchParams{ - Alias: "test-api", - Pattern: "user", - }, - expected: ExpectedResults{ - Operations: []string{"getUsers", "createUser", "getUser"}, - Schemas: []string{"User"}, - Parameters: []string{"userId"}, - Paths: []string{"/users", "/users/{userId}"}, - Tags: []string{"Users"}, - TotalCount: 8, - }, - checkFn: func(t *testing.T, result SearchResult) { - t.Helper() - // Verify matchedIn is populated - for i := range result.Operations { - if len(result.Operations[i].MatchedIn) == 0 { - t.Errorf("Expected matchedIn populated for %s", result.Operations[i].OperationID) - } - } - }, - }, - { - name: "pattern: cluster", - params: SearchParams{ - Alias: "test-api", - Pattern: "cluster", - }, - expected: ExpectedResults{ - Operations: []string{"createCluster", "listClusters", "getCluster"}, - Schemas: []string{"Cluster"}, - Parameters: []string{"clusterId"}, - Paths: []string{"/clusters", "/clusters/{clusterId}"}, - Tags: []string{"Clusters"}, - TotalCount: 8, - }, - checkFn: func(t *testing.T, result SearchResult) { - t.Helper() - // Verify Cluster schema matched by both name and property - if len(result.Schemas) > 0 { - schema := result.Schemas[0] - hasName := false - hasProps := false - for _, field := range schema.MatchedIn { - if field == "name" { - hasName = true - } - if field == "properties" { - hasProps = true - } - } - if !hasName || !hasProps { - t.Errorf("Expected Cluster to match by name and properties, got: %v", schema.MatchedIn) - } - } - }, - }, - { - name: "case-insensitive: USER", - params: SearchParams{ - Alias: "test-api", - Pattern: "USER", - }, - expected: ExpectedResults{ - Operations: []string{"getUsers", "createUser", "getUser"}, - Schemas: []string{"User"}, - Parameters: []string{"userId"}, - Paths: []string{"/users", "/users/{userId}"}, - Tags: []string{"Users"}, - TotalCount: 8, - }, - }, - { - name: "case-sensitive: USER (no match)", - params: SearchParams{ - Alias: "test-api", - Pattern: "USER", - CaseSensitive: true, - }, - expected: ExpectedResults{ - Operations: []string{}, - Schemas: []string{}, - Parameters: []string{}, - Paths: []string{}, - Tags: []string{}, - TotalCount: 0, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := handleSearch(reg, tt.params) - if err != nil { - t.Fatalf("handleSearch() failed: %v", err) - } - - if !result.Success { - t.Error("Expected success=true") - } - - assertSearchResults(t, &result, &tt.expected) - - if tt.checkFn != nil { - tt.checkFn(t, result) - } - }) - } -} - -func TestHandleSearch_InvalidRegex(t *testing.T) { - reg := setupTestRegistry(t) - - params := SearchParams{ - Alias: "test-api", - Pattern: "[invalid(regex", - } - - _, err := handleSearch(reg, params) - if err == nil { - t.Error("Expected error for invalid regex") - } - - if !strings.Contains(err.Error(), "invalid regex") && !strings.Contains(err.Error(), "error parsing regexp") { - t.Errorf("Expected regex error message, got: %v", err) - } -} - -func TestHandleSearch_Pagination(t *testing.T) { - reg := registry.New() - - spec := &openapi3.T{ - OpenAPI: "3.0.0", - Info: &openapi3.Info{ - Title: "Test API", - Version: "1.0.0", - }, - Paths: &openapi3.Paths{}, - } - - // Add multiple operations (15 to test limit of 5) - for i := 0; i < 15; i++ { - path := "/endpoint" + string(rune('a'+i)) - spec.Paths.Set(path, &openapi3.PathItem{ - Get: &openapi3.Operation{ - OperationID: "getEndpoint" + string(rune('A'+i)), - Summary: "Test endpoint " + string(rune('A'+i)), - }, - }) - } - - err := reg.Add("test-api", "/test/api.yaml", spec, nil) - if err != nil { - t.Fatalf("Failed to add spec: %v", err) - } - - // Test with limit=5 per category - params := SearchParams{ - Alias: "test-api", - Pattern: "endpoint", - Limit: 5, - } - - result, err := handleSearch(reg, params) - if err != nil { - t.Fatalf("handleSearch() failed: %v", err) - } - - // We should get 5 operations and 5 paths (limited) - if len(result.Operations) != 5 { - t.Errorf("Expected 5 operations (limited), got %d", len(result.Operations)) - } - - if len(result.Paths) != 5 { - t.Errorf("Expected 5 paths (limited), got %d", len(result.Paths)) - } - - // Total matches should be 30 (15 paths + 15 operations) - if result.Pagination.TotalMatches != 30 { - t.Errorf("Expected totalMatches=30, got %d", result.Pagination.TotalMatches) - } - - // Verify pagination metadata is exact - if result.Pagination.Limit != 5 { - t.Errorf("Expected limit=5, got %d", result.Pagination.Limit) - } - - if result.Pagination.TotalMatches != 30 { - t.Errorf("Expected totalMatches=30 (15 paths + 15 operations), got %d", result.Pagination.TotalMatches) - } - - // Verify category counts (before truncation) - if result.Pagination.CategoryCounts["operations"] != 15 { - t.Errorf("Expected categoryCounts['operations']=15, got %d", result.Pagination.CategoryCounts["operations"]) - } - - if result.Pagination.CategoryCounts["paths"] != 15 { - t.Errorf("Expected categoryCounts['paths']=15, got %d", result.Pagination.CategoryCounts["paths"]) - } - - // Should indicate more available for both categories - if !result.Pagination.CategoryHasMore["operations"] { - t.Error("Expected categoryHasMore['operations']=true (15 total, limit 5)") - } - - if !result.Pagination.CategoryHasMore["paths"] { - t.Error("Expected categoryHasMore['paths']=true (15 total, limit 5)") - } - - // Other categories should not be in categoryHasMore - if result.Pagination.CategoryHasMore["schemas"] { - t.Error("Expected categoryHasMore['schemas'] to be false (0 matches)") - } -} - -func TestHandleSearch_SearchInFilter(t *testing.T) { - reg := setupTestRegistry(t) - - tests := []struct { - name string - searchIn []string - expected ExpectedResults - }{ - { - name: "only schemas", - searchIn: []string{"schemas"}, - expected: ExpectedResults{ - Operations: []string{}, - Schemas: []string{"User"}, - Parameters: []string{}, - Paths: []string{}, - Tags: []string{}, - }, - }, - { - name: "only operations", - searchIn: []string{"operations"}, - expected: ExpectedResults{ - Operations: []string{"getUsers", "createUser", "getUser"}, - Schemas: []string{}, - Parameters: []string{}, - Paths: []string{}, - Tags: []string{}, - }, - }, - { - name: "operations and schemas", - searchIn: []string{"operations", "schemas"}, - expected: ExpectedResults{ - Operations: []string{"getUsers", "createUser", "getUser"}, - Schemas: []string{"User"}, - Parameters: []string{}, - Paths: []string{}, - Tags: []string{}, - }, - }, - { - name: "empty searchIn (all categories)", - searchIn: []string{}, - expected: ExpectedResults{ - Operations: []string{"getUsers", "createUser", "getUser"}, - Schemas: []string{"User"}, - Parameters: []string{"userId"}, - Paths: []string{"/users", "/users/{userId}"}, - Tags: []string{"Users"}, - TotalCount: 8, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - params := SearchParams{ - Alias: "test-api", - Pattern: "user", - SearchIn: tt.searchIn, - } - - result, err := handleSearch(reg, params) - if err != nil { - t.Fatalf("handleSearch() failed: %v", err) - } - - assertSearchResults(t, &result, &tt.expected) - }) - } -} - -func TestHandleSearch_InvalidSearchInCategory(t *testing.T) { - reg := setupTestRegistry(t) - - params := SearchParams{ - Alias: "test-api", - Pattern: "test", - SearchIn: []string{"operations", "invalid-category", "foo"}, - } - - _, err := handleSearch(reg, params) - if err == nil { - t.Error("Expected error for invalid searchIn categories") - } - - expectedError := "invalid searchIn categories" - if !strings.Contains(err.Error(), expectedError) { - t.Errorf("Expected error to contain '%s', got: %v", expectedError, err) - } - - // Should mention the invalid categories - if !strings.Contains(err.Error(), "invalid-category") || !strings.Contains(err.Error(), "foo") { - t.Errorf("Expected error to mention invalid categories, got: %v", err) - } -} diff --git a/tools/mcp-server/internal/tools/slice.go b/tools/mcp-server/internal/tools/slice.go deleted file mode 100644 index ef336d14ae..0000000000 --- a/tools/mcp-server/internal/tools/slice.go +++ /dev/null @@ -1,129 +0,0 @@ -package tools - -import ( - "fmt" - "strings" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/mongodb/openapi/tools/cli/pkg/openapi" - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" -) - -// SliceParams are the parameters for the slice tool. -type SliceParams struct { - SourceAlias string `json:"sourceAlias" jsonschema:"Alias of the source spec to slice"` - SaveAs string `json:"saveAs" jsonschema:"Alias for the resulting virtual spec (e.g. 'my-api-users')"` - Tags []string `json:"tags,omitempty" jsonschema:"Optional: filter by tags"` - OperationIDs []string `json:"operationIds,omitempty" jsonschema:"Optional: filter by operation IDs"` - Paths []string `json:"paths,omitempty" jsonschema:"Optional: filter by path patterns"` -} - -// SliceResult is the response from the slice tool. -type SliceResult struct { - Success bool `json:"success"` - Message string `json:"message,omitempty"` - Alias string `json:"alias,omitempty"` - Error string `json:"error,omitempty"` -} - -func handleSlice(reg *registry.Registry, params *SliceParams) (SliceResult, error) { - if params.SaveAs == "" { - return SliceResult{ - Success: false, - Error: "saveAs is required: provide an alias for the resulting virtual spec (e.g. 'my-api-users')", - }, nil - } - - if !isValidAlias(params.SaveAs) { - return SliceResult{ - Success: false, - Error: fmt.Sprintf("invalid saveAs alias '%s': only lowercase letters, numbers, and hyphens allowed", params.SaveAs), - }, nil - } - - if len(params.Tags) == 0 && len(params.OperationIDs) == 0 && len(params.Paths) == 0 { - return SliceResult{ - Success: false, - Error: "at least one of tags, operationIds, or paths must be specified", - }, nil - } - - if _, err := reg.GetByAlias(params.SaveAs); err == nil { - return SliceResult{ - Success: false, - Error: fmt.Sprintf("alias '%s' is already in use, choose a different saveAs alias", params.SaveAs), - }, nil - } - - entry, err := reg.GetByAlias(params.SourceAlias) - if err != nil { - return SliceResult{ - Success: false, - Error: fmt.Sprintf("spec with alias '%s' not found", params.SourceAlias), - }, nil - } - - specCopy, err := copySpec(entry.Spec) - if err != nil { - return SliceResult{ - Success: false, - Error: fmt.Sprintf("failed to copy spec: %v", err), - }, nil - } - - criteria := &openapi.SliceCriteria{ - Tags: params.Tags, - OperationIDs: params.OperationIDs, - Paths: params.Paths, - } - - if sliceErr := openapi.Slice(specCopy, criteria); sliceErr != nil { - return SliceResult{ - Success: false, - Error: fmt.Sprintf("failed to slice spec: %v", sliceErr), - }, nil - } - - if addErr := reg.Add(params.SaveAs, "", specCopy, entry.Metadata); addErr != nil { - return SliceResult{ - Success: false, - Error: fmt.Sprintf("failed to save virtual spec: %v", addErr), - }, nil - } - - return SliceResult{ - Success: true, - Message: "Successfully created sliced spec filtered by " + buildFilterDescription(params), - Alias: params.SaveAs, - }, nil -} - -func buildFilterDescription(params *SliceParams) string { - filters := []string{} - if len(params.Tags) > 0 { - filters = append(filters, fmt.Sprintf("tags: %v", params.Tags)) - } - if len(params.OperationIDs) > 0 { - filters = append(filters, fmt.Sprintf("operation IDs: %v", params.OperationIDs)) - } - if len(params.Paths) > 0 { - filters = append(filters, fmt.Sprintf("paths: %v", params.Paths)) - } - - return strings.Join(filters, ", ") -} - -// copySpec creates a deep copy of an OpenAPI spec by marshaling and unmarshaling. -func copySpec(spec *openapi3.T) (*openapi3.T, error) { - data, err := spec.MarshalJSON() - if err != nil { - return nil, fmt.Errorf("failed to marshal spec: %w", err) - } - - specCopy, err := openapi3.NewLoader().LoadFromData(data) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal spec: %w", err) - } - - return specCopy, nil -} diff --git a/tools/mcp-server/internal/tools/slice_test.go b/tools/mcp-server/internal/tools/slice_test.go deleted file mode 100644 index 4a8ee4789c..0000000000 --- a/tools/mcp-server/internal/tools/slice_test.go +++ /dev/null @@ -1,239 +0,0 @@ -package tools - -import ( - "testing" - - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" -) - -func setupSliceRegistry(t *testing.T) *registry.Registry { - t.Helper() - reg := registry.New() - if err := reg.Add("test-api", "/test/api.yaml", createTestSpec(), nil); err != nil { - t.Fatalf("failed to set up registry: %v", err) - } - return reg -} - -func getSlicedEntry(t *testing.T, reg *registry.Registry, alias string) *registry.Entry { - t.Helper() - entry, err := reg.GetByAlias(alias) - if err != nil { - t.Fatalf("GetByAlias(%q) failed: %v", alias, err) - } - if entry.SourceType != registry.SourceTypeVirtual { - t.Errorf("entry.SourceType = %q, want %q", entry.SourceType, registry.SourceTypeVirtual) - } - if entry.FilePath != "" { - t.Errorf("entry.FilePath = %q, want empty string", entry.FilePath) - } - return entry -} - -func collectOperationIDs(t *testing.T, reg *registry.Registry, alias string) map[string]bool { - t.Helper() - entry := getSlicedEntry(t, reg, alias) - ops := make(map[string]bool) - for _, pathItem := range entry.Spec.Paths.Map() { - for _, op := range pathItem.Operations() { - if op != nil { - ops[op.OperationID] = true - } - } - } - return ops -} - -// TestHandleSlice_ByTags verifies that slicing by tag "Users" keeps only the 3 -// Users operations (getUsers, createUser, getUser) and excludes the 2 Clusters ones. -func TestHandleSlice_ByTags(t *testing.T) { - reg := setupSliceRegistry(t) - - result, err := handleSlice(reg, &SliceParams{ - SourceAlias: "test-api", - SaveAs: "test-api-users", - Tags: []string{"Users"}, - }) - if err != nil { - t.Fatalf("handleSlice() returned unexpected error: %v", err) - } - if !result.Success { - t.Fatalf("handleSlice() failed: %s", result.Error) - } - if result.Alias != "test-api-users" { - t.Errorf("result.Alias = %q, want %q", result.Alias, "test-api-users") - } - - ops := collectOperationIDs(t, reg, result.Alias) - wantOps := map[string]bool{"getUsers": true, "createUser": true, "getUser": true} - for opID := range wantOps { - if !ops[opID] { - t.Errorf("expected operation %q to be present", opID) - } - } - for opID := range ops { - if !wantOps[opID] { - t.Errorf("unexpected operation %q in sliced spec", opID) - } - } -} - -// TestHandleSlice_ByOperationIDs verifies that slicing by operationIds keeps -// exactly the requested operations and no others. -func TestHandleSlice_ByOperationIDs(t *testing.T) { - reg := setupSliceRegistry(t) - - result, err := handleSlice(reg, &SliceParams{ - SourceAlias: "test-api", - SaveAs: "test-api-user-ops", - OperationIDs: []string{"getUser", "createUser"}, - }) - if err != nil { - t.Fatalf("handleSlice() returned unexpected error: %v", err) - } - if !result.Success { - t.Fatalf("handleSlice() failed: %s", result.Error) - } - - ops := collectOperationIDs(t, reg, result.Alias) - wantOps := map[string]bool{"getUser": true, "createUser": true} - for opID := range wantOps { - if !ops[opID] { - t.Errorf("expected operation %q to be present", opID) - } - } - for opID := range ops { - if !wantOps[opID] { - t.Errorf("unexpected operation %q in sliced spec", opID) - } - } -} - -// TestHandleSlice_ByPaths verifies that slicing by path "/users" keeps only -// the operations under that exact path and excludes "/users/{userId}" and "/clusters". -func TestHandleSlice_ByPaths(t *testing.T) { - reg := setupSliceRegistry(t) - - result, err := handleSlice(reg, &SliceParams{ - SourceAlias: "test-api", - SaveAs: "test-api-users-path", - Paths: []string{"/users"}, - }) - if err != nil { - t.Fatalf("handleSlice() returned unexpected error: %v", err) - } - if !result.Success { - t.Fatalf("handleSlice() failed: %s", result.Error) - } - - ops := collectOperationIDs(t, reg, result.Alias) - // /users has GET (getUsers) and POST (createUser); /users/{userId} and /clusters must be excluded - wantOps := map[string]bool{"getUsers": true, "createUser": true} - for opID := range wantOps { - if !ops[opID] { - t.Errorf("expected operation %q to be present", opID) - } - } - for opID := range ops { - if !wantOps[opID] { - t.Errorf("unexpected operation %q in sliced spec", opID) - } - } -} - -// TestHandleSlice_NoCriteria verifies that omitting all filter criteria is rejected. -func TestHandleSlice_NoCriteria(t *testing.T) { - reg := setupSliceRegistry(t) - - result, err := handleSlice(reg, &SliceParams{ - SourceAlias: "test-api", - SaveAs: "test-api-sliced", - }) - if err != nil { - t.Fatalf("handleSlice() returned unexpected error: %v", err) - } - wantErr := "at least one of tags, operationIds, or paths must be specified" - if result.Success || result.Error != wantErr { - t.Errorf("result = {Success: %v, Error: %q}, want {false, %q}", result.Success, result.Error, wantErr) - } -} - -// TestHandleSlice_SourceAliasNotFound verifies that referencing a non-existent source alias is rejected. -func TestHandleSlice_SourceAliasNotFound(t *testing.T) { - reg := registry.New() - - result, err := handleSlice(reg, &SliceParams{ - SourceAlias: "nonexistent", - SaveAs: "nonexistent-sliced", - Tags: []string{"Users"}, - }) - if err != nil { - t.Fatalf("handleSlice() returned unexpected error: %v", err) - } - wantErr := "spec with alias 'nonexistent' not found" - if result.Success || result.Error != wantErr { - t.Errorf("result = {Success: %v, Error: %q}, want {false, %q}", result.Success, result.Error, wantErr) - } -} - -// TestHandleSlice_SaveAsAliasAlreadyInUse verifies that reusing an existing alias for saveAs is rejected. -func TestHandleSlice_SaveAsAliasAlreadyInUse(t *testing.T) { - reg := setupSliceRegistry(t) - - _, err := handleSlice(reg, &SliceParams{ - SourceAlias: "test-api", - SaveAs: "test-api-users", - Tags: []string{"Users"}, - }) - if err != nil { - t.Fatalf("first handleSlice() returned unexpected error: %v", err) - } - - result, err := handleSlice(reg, &SliceParams{ - SourceAlias: "test-api", - SaveAs: "test-api-users", - Tags: []string{"Clusters"}, - }) - if err != nil { - t.Fatalf("handleSlice() returned unexpected error: %v", err) - } - wantErr := "alias 'test-api-users' is already in use, choose a different saveAs alias" - if result.Success || result.Error != wantErr { - t.Errorf("result = {Success: %v, Error: %q}, want {false, %q}", result.Success, result.Error, wantErr) - } -} - -// TestHandleSlice_InvalidSaveAsAlias verifies that a saveAs alias with invalid characters is rejected. -func TestHandleSlice_InvalidSaveAsAlias(t *testing.T) { - reg := setupSliceRegistry(t) - - result, err := handleSlice(reg, &SliceParams{ - SourceAlias: "test-api", - SaveAs: "Invalid Alias!", - Tags: []string{"Users"}, - }) - if err != nil { - t.Fatalf("handleSlice() returned unexpected error: %v", err) - } - wantErr := "invalid saveAs alias 'Invalid Alias!': only lowercase letters, numbers, and hyphens allowed" - if result.Success || result.Error != wantErr { - t.Errorf("result = {Success: %v, Error: %q}, want {false, %q}", result.Success, result.Error, wantErr) - } -} - -// TestHandleSlice_MissingSaveAs verifies that an empty saveAs is rejected. -func TestHandleSlice_MissingSaveAs(t *testing.T) { - reg := setupSliceRegistry(t) - - result, err := handleSlice(reg, &SliceParams{ - SourceAlias: "test-api", - Tags: []string{"Users"}, - }) - if err != nil { - t.Fatalf("handleSlice() returned unexpected error: %v", err) - } - wantErr := "saveAs is required: provide an alias for the resulting virtual spec (e.g. 'my-api-users')" - if result.Success || result.Error != wantErr { - t.Errorf("result = {Success: %v, Error: %q}, want {false, %q}", result.Success, result.Error, wantErr) - } -} diff --git a/tools/mcp-server/internal/tools/testhelper_test.go b/tools/mcp-server/internal/tools/testhelper_test.go deleted file mode 100644 index 8e2e7ad7a7..0000000000 --- a/tools/mcp-server/internal/tools/testhelper_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package tools - -import ( - "github.com/getkin/kin-openapi/openapi3" -) - -// createTestSpec creates a comprehensive test OpenAPI spec shared across test files. -func createTestSpec() *openapi3.T { - spec := &openapi3.T{ - OpenAPI: "3.0.0", - Info: &openapi3.Info{ - Title: "Test API", - Version: "1.0.0", - }, - Paths: &openapi3.Paths{}, - Components: &openapi3.Components{}, - Tags: []*openapi3.Tag{ - {Name: "Users", Description: "User management endpoints"}, - {Name: "Clusters", Description: "Cluster operations"}, - }, - } - - spec.Paths.Set("/users", &openapi3.PathItem{ - Get: &openapi3.Operation{ - OperationID: "getUsers", - Summary: "Get all users", - Description: "Retrieve a list of all users in the system", - Tags: []string{"Users"}, - }, - Post: &openapi3.Operation{ - OperationID: "createUser", - Summary: "Create a user", - Description: "Create a new user account", - Tags: []string{"Users"}, - }, - }) - - spec.Paths.Set("/users/{userId}", &openapi3.PathItem{ - Get: &openapi3.Operation{ - OperationID: "getUser", - Summary: "Get user by ID", - Description: "Retrieve a specific user by their ID", - Tags: []string{"Users"}, - }, - }) - - spec.Paths.Set("/clusters", &openapi3.PathItem{ - Post: &openapi3.Operation{ - OperationID: "createCluster", - Summary: "Create a new cluster", - Description: "Creates a new cluster in the project", - Tags: []string{"Clusters"}, - }, - Get: &openapi3.Operation{ - OperationID: "listClusters", - Summary: "List clusters", - Description: "Get all clusters in the project", - Tags: []string{"Clusters"}, - }, - }) - - spec.Paths.Set("/clusters/{clusterId}", &openapi3.PathItem{ - Get: &openapi3.Operation{ - OperationID: "getCluster", - Summary: "Get cluster details", - Description: "Retrieve details for a specific cluster", - Tags: []string{"Clusters"}, - }, - }) - - spec.Components.Schemas = make(map[string]*openapi3.SchemaRef) - spec.Components.Schemas["User"] = &openapi3.SchemaRef{ - Value: &openapi3.Schema{ - Type: &openapi3.Types{"object"}, - Description: "User account information", - Properties: map[string]*openapi3.SchemaRef{ - "userId": {Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, - "username": {Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, - "email": {Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, - }, - }, - } - - spec.Components.Schemas["Cluster"] = &openapi3.SchemaRef{ - Value: &openapi3.Schema{ - Type: &openapi3.Types{"object"}, - Description: "Cluster configuration", - Properties: map[string]*openapi3.SchemaRef{ - "clusterId": {Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, - "name": {Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, - "region": {Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, - }, - }, - } - - spec.Components.Schemas["Database"] = &openapi3.SchemaRef{ - Value: &openapi3.Schema{ - Type: &openapi3.Types{"object"}, - Description: "Database information", - Properties: map[string]*openapi3.SchemaRef{ - "databaseName": {Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, - }, - }, - } - - spec.Components.Parameters = make(map[string]*openapi3.ParameterRef) - spec.Components.Parameters["userId"] = &openapi3.ParameterRef{ - Value: &openapi3.Parameter{ - Name: "userId", - In: "path", - Description: "Unique identifier for the user", - Required: true, - }, - } - - spec.Components.Parameters["clusterId"] = &openapi3.ParameterRef{ - Value: &openapi3.Parameter{ - Name: "clusterId", - In: "path", - Description: "Unique identifier for the cluster", - Required: true, - }, - } - - spec.Components.Responses = make(map[string]*openapi3.ResponseRef) - notFound := "Not Found" - spec.Components.Responses["NotFound"] = &openapi3.ResponseRef{ - Value: &openapi3.Response{Description: ¬Found}, - } - - unauthorized := "Unauthorized" - spec.Components.Responses["Unauthorized"] = &openapi3.ResponseRef{ - Value: &openapi3.Response{Description: &unauthorized}, - } - - return spec -} diff --git a/tools/mcp-server/internal/tools/tools.go b/tools/mcp-server/internal/tools/tools.go deleted file mode 100644 index fa52171455..0000000000 --- a/tools/mcp-server/internal/tools/tools.go +++ /dev/null @@ -1,104 +0,0 @@ -package tools - -import ( - "context" - - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" -) - -// Register registers all tool handlers with the server. -func Register(server *mcp.Server, reg *registry.Registry) { - // Register load tool - loadTool := &mcp.Tool{ - Name: "load", - Description: "Load an OpenAPI specification file (JSON or YAML) into memory. " + - "Assigns an alias for easy reference in other commands. " + - "Validates the spec and makes it available for searching, exporting, and inspection. " + - "Required before using search or export tools on a spec.", - } - mcp.AddTool(server, loadTool, makeLoadHandler(reg)) - - // Register unload tool - unloadTool := &mcp.Tool{ - Name: "unload", - Description: "Remove a previously loaded OpenAPI specification from memory by its alias. " + - "Frees up resources and removes the spec from the available list. " + - "Use this to clean up specs you no longer need to search or reference.", - } - mcp.AddTool(server, unloadTool, makeUnloadHandler(reg)) - - // Register export tool - exportTool := &mcp.Tool{ - Name: "export", - Description: "Export a loaded OpenAPI specification to a file in JSON or YAML format. " + - "Useful for converting between formats, saving modified specs, or creating copies. " + - "Specify the output path and desired format (json/yaml).", - } - mcp.AddTool(server, exportTool, makeExportHandler(reg)) - - // Register search tool - searchTool := &mcp.Tool{ - Name: "search", - Description: "Search across OpenAPI specifications using regex patterns. " + - "Searches operations, schemas, parameters, responses, tags, and paths. " + - "Returns matches grouped by category with details on what matched (operationId, schema name, property names, etc.). " + - "Supports case-sensitive/insensitive search, category filtering, and per-category result limits. " + - "Use this to find specific endpoints, data structures, or API components by name or pattern.", - } - mcp.AddTool(server, searchTool, makeSearchHandler(reg)) - - // Register slice tool - sliceTool := &mcp.Tool{ - Name: "slice", - Description: "Create a filtered subset of an OpenAPI specification by selecting specific operations. " + - "Filter by tags, operation IDs, or path patterns. " + - "Uses OR logic: operations matching ANY of the specified criteria are included. " + - "Automatically includes all schemas, parameters, and components referenced by the selected operations. " + - "Requires a 'saveAs' alias chosen by the agent to name the resulting virtual spec (e.g. 'atlas-api-users'). " + - "The same source spec can be sliced multiple times with different aliases for different subsets. " + - "Returns the alias and updated list of available specs. " + - "Useful for creating API subsets, generating client SDKs for specific features, or analyzing specific API areas.", - } - mcp.AddTool(server, sliceTool, makeSliceHandler(reg)) -} - -// makeLoadHandler creates the handler for the load tool. -func makeLoadHandler(reg *registry.Registry) mcp.ToolHandlerFor[LoadParams, LoadResult] { - return func(_ context.Context, _ *mcp.CallToolRequest, params LoadParams) (*mcp.CallToolResult, LoadResult, error) { - result, err := handleLoad(reg, params) - return nil, result, err - } -} - -// makeUnloadHandler creates the handler for the unload tool. -func makeUnloadHandler(reg *registry.Registry) mcp.ToolHandlerFor[UnloadParams, UnloadResult] { - return func(_ context.Context, _ *mcp.CallToolRequest, params UnloadParams) (*mcp.CallToolResult, UnloadResult, error) { - result, err := handleUnload(reg, params) - return nil, result, err - } -} - -// makeExportHandler creates the handler for the export tool. -func makeExportHandler(reg *registry.Registry) mcp.ToolHandlerFor[ExportParams, ExportResult] { - return func(_ context.Context, _ *mcp.CallToolRequest, params ExportParams) (*mcp.CallToolResult, ExportResult, error) { - result, err := handleExport(reg, params) - return nil, result, err - } -} - -// makeSearchHandler creates the handler for the search tool. -func makeSearchHandler(reg *registry.Registry) mcp.ToolHandlerFor[SearchParams, SearchResult] { - return func(_ context.Context, _ *mcp.CallToolRequest, params SearchParams) (*mcp.CallToolResult, SearchResult, error) { - result, err := handleSearch(reg, params) - return nil, result, err - } -} - -// makeSliceHandler creates the handler for the slice tool. -func makeSliceHandler(reg *registry.Registry) mcp.ToolHandlerFor[SliceParams, SliceResult] { - return func(_ context.Context, _ *mcp.CallToolRequest, params SliceParams) (*mcp.CallToolResult, SliceResult, error) { - result, err := handleSlice(reg, ¶ms) - return nil, result, err - } -} diff --git a/tools/mcp-server/internal/tools/unload.go b/tools/mcp-server/internal/tools/unload.go deleted file mode 100644 index 7f7cd10eab..0000000000 --- a/tools/mcp-server/internal/tools/unload.go +++ /dev/null @@ -1,33 +0,0 @@ -package tools - -import ( - "fmt" - - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" -) - -// UnloadParams are the parameters for the unload tool. -type UnloadParams struct { - Alias string `json:"alias" jsonschema:"Alias of the spec to unload"` -} - -// UnloadResult is the result of an unload operation. -type UnloadResult struct { - Success bool `json:"success"` - Alias string `json:"alias"` - Message string `json:"message"` -} - -// handleUnload removes a spec from the registry. -// The SDK handles parameter unmarshaling and validation automatically. -func handleUnload(reg *registry.Registry, params UnloadParams) (UnloadResult, error) { - if err := reg.Remove(params.Alias); err != nil { - return UnloadResult{Success: false}, err - } - - return UnloadResult{ - Success: true, - Alias: params.Alias, - Message: fmt.Sprintf("Unloaded '%s' successfully", params.Alias), - }, nil -}