diff --git a/tools/cli/pkg/openapi/openapi.go b/tools/cli/pkg/openapi/openapi.go index 298a7b84bd..5870c833dd 100644 --- a/tools/cli/pkg/openapi/openapi.go +++ b/tools/cli/pkg/openapi/openapi.go @@ -18,11 +18,21 @@ package openapi import ( "github.com/mongodb/openapi/tools/cli/internal/openapi" + "github.com/mongodb/openapi/tools/cli/internal/openapi/slice" "github.com/oasdiff/kin-openapi/openapi3" "github.com/oasdiff/oasdiff/load" "github.com/spf13/afero" ) +// SliceCriteria defines the selection criteria for slicing an OpenAPI spec. +// Operations matching ANY of the specified criteria will be included (OR logic). +type SliceCriteria = slice.Criteria + +// Slice filters an OpenAPI spec to keep only operations matching the criteria. +func Slice(spec *openapi3.T, criteria *SliceCriteria) error { + return slice.Slice(spec, criteria) +} + // Loader provides methods for loading OpenAPI specifications from files. type Loader struct { impl *openapi.OpenAPI3 diff --git a/tools/mcp-server/go.mod b/tools/mcp-server/go.mod index 69bc0848ef..35e3bd4c33 100644 --- a/tools/mcp-server/go.mod +++ b/tools/mcp-server/go.mod @@ -5,7 +5,7 @@ go 1.26 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/oasdiff/kin-openapi v0.136.10 github.com/spf13/afero v1.15.0 ) @@ -19,9 +19,9 @@ require ( 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/oasdiff/oasdiff v1.13.1 // 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/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect @@ -29,7 +29,7 @@ require ( 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/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 diff --git a/tools/mcp-server/go.sum b/tools/mcp-server/go.sum index c6b6bd1f3e..2d52f338c2 100644 --- a/tools/mcp-server/go.sum +++ b/tools/mcp-server/go.sum @@ -31,14 +31,14 @@ github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzB 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/oasdiff/kin-openapi v0.136.10 h1:3vKm43dfhRvJerEyijS74bLwLxE7ItB0caMJl+IjGA0= +github.com/oasdiff/kin-openapi v0.136.10/go.mod h1:E7LUU2UyOHENK+Mhu7stMOtUA41LJRp5sFAW+L1Ier4= +github.com/oasdiff/oasdiff v1.13.1 h1:mvEv09sUT0V4926Q+1cwurEYvzV/Lj6d54xINIxcvHA= +github.com/oasdiff/oasdiff v1.13.1/go.mod h1:ZUEb4bzuo+fpHWGLO7f+++Vq7oV2IBgVC9KDRn/z3IA= +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= @@ -66,8 +66,8 @@ 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/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= diff --git a/tools/mcp-server/internal/tools/search_test.go b/tools/mcp-server/internal/tools/search_test.go index 01666e4074..ef8e2b3173 100644 --- a/tools/mcp-server/internal/tools/search_test.go +++ b/tools/mcp-server/internal/tools/search_test.go @@ -8,146 +8,6 @@ import ( "github.com/oasdiff/kin-openapi/openapi3" ) -// createTestSpec creates a comprehensive test OpenAPI spec for search testing. -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"}, - }, - } - - // Add 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"}, - }, - }) - - // Add schemas - 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"}}}, - }, - }, - } - - // Add parameters - 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, - }, - } - - // Add responses - 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 -} - // setupTestRegistry creates a registry with the test spec loaded. func setupTestRegistry(t *testing.T) *registry.Registry { t.Helper() diff --git a/tools/mcp-server/internal/tools/slice.go b/tools/mcp-server/internal/tools/slice.go new file mode 100644 index 0000000000..bf7856e624 --- /dev/null +++ b/tools/mcp-server/internal/tools/slice.go @@ -0,0 +1,129 @@ +package tools + +import ( + "fmt" + "strings" + + "github.com/mongodb/openapi/tools/cli/pkg/openapi" + "github.com/mongodb/openapi/tools/mcp-server/internal/registry" + "github.com/oasdiff/kin-openapi/openapi3" +) + +// 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 new file mode 100644 index 0000000000..4a8ee4789c --- /dev/null +++ b/tools/mcp-server/internal/tools/slice_test.go @@ -0,0 +1,239 @@ +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 new file mode 100644 index 0000000000..1f436653de --- /dev/null +++ b/tools/mcp-server/internal/tools/testhelper_test.go @@ -0,0 +1,137 @@ +package tools + +import ( + "github.com/oasdiff/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 index a2261aee2b..c932b90409 100644 --- a/tools/mcp-server/internal/tools/tools.go +++ b/tools/mcp-server/internal/tools/tools.go @@ -47,6 +47,20 @@ func Register(server *mcp.Server, reg *registry.Registry) { "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. @@ -80,3 +94,11 @@ func makeSearchHandler(reg *registry.Registry) mcp.ToolHandlerFor[SearchParams, 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 + } +}