From af3b706699c82ec4d5ce3424ef78419fdd5a05a8 Mon Sep 17 00:00:00 2001 From: Yeliz Henden Date: Wed, 8 Apr 2026 16:01:08 +0100 Subject: [PATCH 1/4] feat: add mcp server (load, unload, export tools + registry) --- tools/cli/pkg/openapi/openapi.go | 47 +++ tools/mcp-server/Makefile | 46 +++ tools/mcp-server/cmd/main.go | 46 +++ tools/mcp-server/go.mod | 45 +++ tools/mcp-server/go.sum | 93 ++++++ .../mcp-server/internal/registry/registry.go | 185 +++++++++++ .../internal/registry/registry_test.go | 252 +++++++++++++++ tools/mcp-server/internal/tools/export.go | 56 ++++ tools/mcp-server/internal/tools/load.go | 114 +++++++ tools/mcp-server/internal/tools/load_test.go | 287 ++++++++++++++++++ tools/mcp-server/internal/tools/tools.go | 56 ++++ tools/mcp-server/internal/tools/unload.go | 33 ++ 12 files changed, 1260 insertions(+) create mode 100644 tools/cli/pkg/openapi/openapi.go create mode 100644 tools/mcp-server/Makefile create mode 100644 tools/mcp-server/cmd/main.go create mode 100644 tools/mcp-server/go.mod create mode 100644 tools/mcp-server/go.sum create mode 100644 tools/mcp-server/internal/registry/registry.go create mode 100644 tools/mcp-server/internal/registry/registry_test.go create mode 100644 tools/mcp-server/internal/tools/export.go create mode 100644 tools/mcp-server/internal/tools/load.go create mode 100644 tools/mcp-server/internal/tools/load_test.go create mode 100644 tools/mcp-server/internal/tools/tools.go create mode 100644 tools/mcp-server/internal/tools/unload.go diff --git a/tools/cli/pkg/openapi/openapi.go b/tools/cli/pkg/openapi/openapi.go new file mode 100644 index 0000000000..d6bc5d1c6a --- /dev/null +++ b/tools/cli/pkg/openapi/openapi.go @@ -0,0 +1,47 @@ +// Copyright 2024 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package openapi provides public interfaces for loading and saving OpenAPI specifications. +// This package wraps the internal openapi package to provide a stable public API. +package openapi + +import ( + "github.com/mongodb/openapi/tools/cli/internal/openapi" + "github.com/oasdiff/kin-openapi/openapi3" + "github.com/oasdiff/oasdiff/load" + "github.com/spf13/afero" +) + +// Loader provides methods for loading OpenAPI specifications from files. +type Loader struct { + impl *openapi.OpenAPI3 +} + +// NewLoader creates a new OpenAPI loader. +func NewLoader() *Loader { + return &Loader{ + impl: openapi.NewOpenAPI3(), + } +} + +// LoadFromPath loads an OpenAPI spec from the given file path. +func (l *Loader) LoadFromPath(path string) (*load.SpecInfo, error) { + return l.impl.CreateOpenAPISpecFromPath(path) +} + +// SaveToFile saves an OpenAPI spec to a file in the specified format. +// Format can be "json", "yaml", or "all". +func SaveToFile(path string, format string, spec *openapi3.T, fs afero.Fs) error { + return openapi.Save(path, spec, format, fs) +} diff --git a/tools/mcp-server/Makefile b/tools/mcp-server/Makefile new file mode 100644 index 0000000000..2b035d8a06 --- /dev/null +++ b/tools/mcp-server/Makefile @@ -0,0 +1,46 @@ +.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 new file mode 100644 index 0000000000..cd9e81fdc8 --- /dev/null +++ b/tools/mcp-server/cmd/main.go @@ -0,0 +1,46 @@ +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/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) + + // 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 new file mode 100644 index 0000000000..447753c602 --- /dev/null +++ b/tools/mcp-server/go.mod @@ -0,0 +1,45 @@ +module github.com/mongodb/openapi/tools/mcp-server + +go 1.26 + +toolchain go1.26.0 + +require ( + github.com/modelcontextprotocol/go-sdk v1.5.0 + github.com/mongodb/openapi/tools/cli v0.0.0 + github.com/oasdiff/kin-openapi v0.136.4 + github.com/spf13/afero v1.15.0 +) + +require ( + cloud.google.com/go v0.123.0 // 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.12.7 // indirect + github.com/oasdiff/yaml v0.0.4 // indirect + github.com/oasdiff/yaml3 v0.0.4 // indirect + github.com/perimeterx/marshmallow v1.1.5 // 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.0 // 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.42.0 // indirect + golang.org/x/text v0.35.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 new file mode 100644 index 0000000000..c6b6bd1f3e --- /dev/null +++ b/tools/mcp-server/go.sum @@ -0,0 +1,93 @@ +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/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/kin-openapi v0.136.4 h1:idO/oW/6liYIOns49liiCN8KbUke7ckWoi+dePDu6Dc= +github.com/oasdiff/kin-openapi v0.136.4/go.mod h1:P7JOZRsZdRww4urEHzu0VFejiJC25RbS7XqQ4h4KDFQ= +github.com/oasdiff/oasdiff v1.12.7 h1:DAbAyqWie1kbk6lXD2ZPME/I3lTXu4U7VR96jmDoQBw= +github.com/oasdiff/oasdiff v1.12.7/go.mod h1:GHJYWhxCAQ7AdpATB9mcTvYe/IrJ423+SdMrQHiPa8Q= +github.com/oasdiff/yaml v0.0.4 h1:airPco4LbUoK4nbVwu+wwkRg2WarLC96cgBhgN93fsE= +github.com/oasdiff/yaml v0.0.4/go.mod h1:EaJ6/lcrRLK+syawtvtrHdbrrln4/SUmQw6aBTIlaMs= +github.com/oasdiff/yaml3 v0.0.4 h1:U5RTQZpBmsbcyCFlzPVuMctk6Jme6lOrbl0jJoOovMw= +github.com/oasdiff/yaml3 v0.0.4/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.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ= +github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM= +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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +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 new file mode 100644 index 0000000000..dc2402c3b5 --- /dev/null +++ b/tools/mcp-server/internal/registry/registry.go @@ -0,0 +1,185 @@ +package registry + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "sync" + "time" + + "github.com/oasdiff/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 new file mode 100644 index 0000000000..7d01bbbab6 --- /dev/null +++ b/tools/mcp-server/internal/registry/registry_test.go @@ -0,0 +1,252 @@ +package registry + +import ( + "testing" + + "github.com/oasdiff/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") + + reg.Add("test-api", "/path/to/test.yaml", spec, nil) + + 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/tools/export.go b/tools/mcp-server/internal/tools/export.go new file mode 100644 index 0000000000..9b5b623d41 --- /dev/null +++ b/tools/mcp-server/internal/tools/export.go @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000000..4d27be3be6 --- /dev/null +++ b/tools/mcp-server/internal/tools/load.go @@ -0,0 +1,114 @@ +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 new file mode 100644 index 0000000000..dbabacfbb0 --- /dev/null +++ b/tools/mcp-server/internal/tools/load_test.go @@ -0,0 +1,287 @@ +package tools + +import ( + "os" + "testing" + + "github.com/mongodb/openapi/tools/mcp-server/internal/registry" +) + +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 := ®istry.Registry{} + 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 + err := os.WriteFile(tmpFile, []byte(content), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + return tmpFile +} + +func cleanupTestFile(t *testing.T, path string) { + t.Helper() + // t.TempDir() handles cleanup automatically +} diff --git a/tools/mcp-server/internal/tools/tools.go b/tools/mcp-server/internal/tools/tools.go new file mode 100644 index 0000000000..49fc65b222 --- /dev/null +++ b/tools/mcp-server/internal/tools/tools.go @@ -0,0 +1,56 @@ +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 using the official SDK. +func Register(server *mcp.Server, reg *registry.Registry) { + // Register load tool + loadTool := &mcp.Tool{ + Name: "load", + Description: "Load an OpenAPI specification from a file into memory", + } + mcp.AddTool(server, loadTool, makeLoadHandler(reg)) + + // Register unload tool + unloadTool := &mcp.Tool{ + Name: "unload", + Description: "Remove a loaded OpenAPI specification from memory", + } + mcp.AddTool(server, unloadTool, makeUnloadHandler(reg)) + + // Register export tool + exportTool := &mcp.Tool{ + Name: "export", + Description: "Export a loaded OpenAPI specification to a file", + } + mcp.AddTool(server, exportTool, makeExportHandler(reg)) +} + +// makeLoadHandler creates the handler for the load tool. +func makeLoadHandler(reg *registry.Registry) mcp.ToolHandlerFor[LoadParams, LoadResult] { + return func(ctx context.Context, req *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(ctx context.Context, req *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(ctx context.Context, req *mcp.CallToolRequest, params ExportParams) (*mcp.CallToolResult, ExportResult, error) { + result, err := handleExport(reg, params) + return nil, result, err + } +} diff --git a/tools/mcp-server/internal/tools/unload.go b/tools/mcp-server/internal/tools/unload.go new file mode 100644 index 0000000000..7f7cd10eab --- /dev/null +++ b/tools/mcp-server/internal/tools/unload.go @@ -0,0 +1,33 @@ +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 +} From 7e46a83f0d15ebe6db5faf43fcdcdf508436960d Mon Sep 17 00:00:00 2001 From: Yeliz Henden Date: Wed, 8 Apr 2026 16:37:44 +0100 Subject: [PATCH 2/4] CI fix --- .github/workflows/code-health-mcp-server.yml | 68 ++++++++++ tools/cli/pkg/openapi/openapi.go | 2 +- tools/go.work | 6 + tools/mcp-server/.golangci.yml | 125 +++++++++++++++++++ tools/mcp-server/go.mod | 2 - 5 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/code-health-mcp-server.yml create mode 100644 tools/go.work create mode 100644 tools/mcp-server/.golangci.yml diff --git a/.github/workflows/code-health-mcp-server.yml b/.github/workflows/code-health-mcp-server.yml new file mode 100644 index 0000000000..34240490e3 --- /dev/null +++ b/.github/workflows/code-health-mcp-server.yml @@ -0,0 +1,68 @@ +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/tools/cli/pkg/openapi/openapi.go b/tools/cli/pkg/openapi/openapi.go index d6bc5d1c6a..793f4d7b4e 100644 --- a/tools/cli/pkg/openapi/openapi.go +++ b/tools/cli/pkg/openapi/openapi.go @@ -42,6 +42,6 @@ func (l *Loader) LoadFromPath(path string) (*load.SpecInfo, error) { // SaveToFile saves an OpenAPI spec to a file in the specified format. // Format can be "json", "yaml", or "all". -func SaveToFile(path string, format string, spec *openapi3.T, fs afero.Fs) error { +func SaveToFile(path, format string, spec *openapi3.T, fs afero.Fs) error { return openapi.Save(path, spec, format, fs) } diff --git a/tools/go.work b/tools/go.work new file mode 100644 index 0000000000..9eceff811a --- /dev/null +++ b/tools/go.work @@ -0,0 +1,6 @@ +go 1.26 + +use ( + mcp-server + cli +) \ No newline at end of file diff --git a/tools/mcp-server/.golangci.yml b/tools/mcp-server/.golangci.yml new file mode 100644 index 0000000000..e3a1f2613c --- /dev/null +++ b/tools/mcp-server/.golangci.yml @@ -0,0 +1,125 @@ +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/go.mod b/tools/mcp-server/go.mod index 447753c602..69bc0848ef 100644 --- a/tools/mcp-server/go.mod +++ b/tools/mcp-server/go.mod @@ -2,8 +2,6 @@ module github.com/mongodb/openapi/tools/mcp-server go 1.26 -toolchain go1.26.0 - require ( github.com/modelcontextprotocol/go-sdk v1.5.0 github.com/mongodb/openapi/tools/cli v0.0.0 From 48b75a1a7f5686d3679dfc63818a78b61de74c67 Mon Sep 17 00:00:00 2001 From: Yeliz Henden Date: Wed, 8 Apr 2026 16:54:59 +0100 Subject: [PATCH 3/4] CI fix --- tools/mcp-server/internal/registry/registry.go | 2 +- tools/mcp-server/internal/registry/registry_test.go | 9 ++++++--- tools/mcp-server/internal/tools/load_test.go | 12 ++++++------ tools/mcp-server/internal/tools/tools.go | 6 +++--- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/tools/mcp-server/internal/registry/registry.go b/tools/mcp-server/internal/registry/registry.go index dc2402c3b5..dbefc4609b 100644 --- a/tools/mcp-server/internal/registry/registry.go +++ b/tools/mcp-server/internal/registry/registry.go @@ -56,7 +56,7 @@ func New() *Registry { // - 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 +// - 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() diff --git a/tools/mcp-server/internal/registry/registry_test.go b/tools/mcp-server/internal/registry/registry_test.go index 7d01bbbab6..a0d435fe6c 100644 --- a/tools/mcp-server/internal/registry/registry_test.go +++ b/tools/mcp-server/internal/registry/registry_test.go @@ -129,9 +129,12 @@ func TestRegistry_Remove(t *testing.T) { reg := New() spec := createTestSpec("Test API", "1.0.0") - reg.Add("test-api", "/path/to/test.yaml", spec, nil) + 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") + err = reg.Remove("test-api") if err != nil { t.Fatalf("Remove() failed: %v", err) } @@ -146,7 +149,7 @@ func TestRegistry_Remove(t *testing.T) { } } -// Helper function to create a test spec +// Helper function to create a test spec. func createTestSpec(title, version string) *openapi3.T { return &openapi3.T{ OpenAPI: "3.0.0", diff --git a/tools/mcp-server/internal/tools/load_test.go b/tools/mcp-server/internal/tools/load_test.go index dbabacfbb0..f9df5eb53e 100644 --- a/tools/mcp-server/internal/tools/load_test.go +++ b/tools/mcp-server/internal/tools/load_test.go @@ -1,10 +1,10 @@ package tools import ( - "os" "testing" "github.com/mongodb/openapi/tools/mcp-server/internal/registry" + "github.com/spf13/afero" ) func TestGenerateAliasFromPath(t *testing.T) { @@ -143,8 +143,7 @@ func TestIsValidAlias(t *testing.T) { } func TestHandleLoad_AutoGeneratedAlias(t *testing.T) { - reg := ®istry.Registry{} - reg = registry.New() + reg := registry.New() // Create a test YAML file testFile := createTestOpenAPIFile(t, "test-api.yaml") @@ -255,7 +254,7 @@ func TestHandleLoad_Collision(t *testing.T) { } } -// Helper: creates a temporary OpenAPI file for testing +// Helper: creates a temporary OpenAPI file for testing. func createTestOpenAPIFile(t *testing.T, filename string) string { t.Helper() @@ -273,7 +272,8 @@ paths: ` tmpFile := t.TempDir() + "/" + filename - err := os.WriteFile(tmpFile, []byte(content), 0644) + fs := afero.NewOsFs() + err := afero.WriteFile(fs, tmpFile, []byte(content), 0o600) if err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -281,7 +281,7 @@ paths: return tmpFile } -func cleanupTestFile(t *testing.T, path string) { +func cleanupTestFile(t *testing.T, _ string) { t.Helper() // t.TempDir() handles cleanup automatically } diff --git a/tools/mcp-server/internal/tools/tools.go b/tools/mcp-server/internal/tools/tools.go index 49fc65b222..2546727889 100644 --- a/tools/mcp-server/internal/tools/tools.go +++ b/tools/mcp-server/internal/tools/tools.go @@ -33,7 +33,7 @@ func Register(server *mcp.Server, reg *registry.Registry) { // makeLoadHandler creates the handler for the load tool. func makeLoadHandler(reg *registry.Registry) mcp.ToolHandlerFor[LoadParams, LoadResult] { - return func(ctx context.Context, req *mcp.CallToolRequest, params LoadParams) (*mcp.CallToolResult, LoadResult, error) { + return func(_ context.Context, _ *mcp.CallToolRequest, params LoadParams) (*mcp.CallToolResult, LoadResult, error) { result, err := handleLoad(reg, params) return nil, result, err } @@ -41,7 +41,7 @@ func makeLoadHandler(reg *registry.Registry) mcp.ToolHandlerFor[LoadParams, Load // makeUnloadHandler creates the handler for the unload tool. func makeUnloadHandler(reg *registry.Registry) mcp.ToolHandlerFor[UnloadParams, UnloadResult] { - return func(ctx context.Context, req *mcp.CallToolRequest, params UnloadParams) (*mcp.CallToolResult, UnloadResult, error) { + return func(_ context.Context, _ *mcp.CallToolRequest, params UnloadParams) (*mcp.CallToolResult, UnloadResult, error) { result, err := handleUnload(reg, params) return nil, result, err } @@ -49,7 +49,7 @@ func makeUnloadHandler(reg *registry.Registry) mcp.ToolHandlerFor[UnloadParams, // makeExportHandler creates the handler for the export tool. func makeExportHandler(reg *registry.Registry) mcp.ToolHandlerFor[ExportParams, ExportResult] { - return func(ctx context.Context, req *mcp.CallToolRequest, params ExportParams) (*mcp.CallToolResult, ExportResult, error) { + return func(_ context.Context, _ *mcp.CallToolRequest, params ExportParams) (*mcp.CallToolResult, ExportResult, error) { result, err := handleExport(reg, params) return nil, result, err } From 7eba694c1f75f73cfa575da488ca06e619a861ac Mon Sep 17 00:00:00 2001 From: Yeliz Henden <165907936+yelizhenden-mdb@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:58:21 +0100 Subject: [PATCH 4/4] Update tools/cli/pkg/openapi/openapi.go Co-authored-by: Andrei Matei <29061011+andmatei@users.noreply.github.com> --- tools/cli/pkg/openapi/openapi.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/cli/pkg/openapi/openapi.go b/tools/cli/pkg/openapi/openapi.go index 793f4d7b4e..298a7b84bd 100644 --- a/tools/cli/pkg/openapi/openapi.go +++ b/tools/cli/pkg/openapi/openapi.go @@ -1,4 +1,4 @@ -// Copyright 2024 MongoDB Inc +// Copyright 2026 MongoDB Inc // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License.