From 36f6cd253980c3854e52aa83a8bfc96c32008d33 Mon Sep 17 00:00:00 2001 From: Yeliz Henden Date: Tue, 14 Apr 2026 12:26:29 +0100 Subject: [PATCH 1/6] feat: add resources for listing specs in the registry and get a summary for a specific one --- tools/cli/pkg/apiversion/apiversion.go | 23 +++ tools/cli/pkg/openapi/openapi.go | 9 ++ tools/mcp-server/cmd/main.go | 2 + .../internal/resources/resource_alias.go | 136 ++++++++++++++++++ .../internal/resources/resource_alias_test.go | 71 +++++++++ .../internal/resources/resource_specs.go | 45 ++++++ .../internal/resources/resource_specs_test.go | 73 ++++++++++ .../internal/resources/resources.go | 49 +++++++ .../internal/resources/testhelper_test.go | 124 ++++++++++++++++ tools/mcp-server/internal/tools/tools.go | 2 +- 10 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 tools/cli/pkg/apiversion/apiversion.go create mode 100644 tools/mcp-server/internal/resources/resource_alias.go create mode 100644 tools/mcp-server/internal/resources/resource_alias_test.go create mode 100644 tools/mcp-server/internal/resources/resource_specs.go create mode 100644 tools/mcp-server/internal/resources/resource_specs_test.go create mode 100644 tools/mcp-server/internal/resources/resources.go create mode 100644 tools/mcp-server/internal/resources/testhelper_test.go diff --git a/tools/cli/pkg/apiversion/apiversion.go b/tools/cli/pkg/apiversion/apiversion.go new file mode 100644 index 0000000000..9478a22a64 --- /dev/null +++ b/tools/cli/pkg/apiversion/apiversion.go @@ -0,0 +1,23 @@ +// Package apiversion exposes API version parsing utilities for use outside the cli module. +package apiversion + +import ( + "github.com/mongodb/openapi/tools/cli/internal/apiversion" +) + +// Parse extracts the API version string from a versioned media type +// (e.g. "application/vnd.atlas.2024-01-01+json" → "2024-01-01"). +// Returns an error if the media type does not match the expected pattern. +func Parse(contentType string) (string, error) { + return apiversion.Parse(contentType) +} + +// IsPreviewStabilityLevel reports whether the given version string represents a preview release. +func IsPreviewStabilityLevel(version string) bool { + return apiversion.IsPreviewStabilityLevel(version) +} + +// IsUpcomingStabilityLevel reports whether the given version string represents an upcoming release. +func IsUpcomingStabilityLevel(version string) bool { + return apiversion.IsUpcomingStabilityLevel(version) +} diff --git a/tools/cli/pkg/openapi/openapi.go b/tools/cli/pkg/openapi/openapi.go index 5870c833dd..28b97c4c61 100644 --- a/tools/cli/pkg/openapi/openapi.go +++ b/tools/cli/pkg/openapi/openapi.go @@ -24,6 +24,15 @@ import ( "github.com/spf13/afero" ) +// ExtractVersions returns all API version strings present in the spec, +// including stable date versions (e.g. "2024-01-01"), preview names +// (e.g. "preview", "public-preview"), and upcoming versions +// (e.g. "2024-01-01.upcoming") when no stable counterpart exists. +// The returned slice is sorted. +func ExtractVersions(spec *openapi3.T) ([]string, error) { + return openapi.ExtractVersionsWithEnv(spec, "") +} + // 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 diff --git a/tools/mcp-server/cmd/main.go b/tools/mcp-server/cmd/main.go index cd9e81fdc8..d0431ed35e 100644 --- a/tools/mcp-server/cmd/main.go +++ b/tools/mcp-server/cmd/main.go @@ -7,6 +7,7 @@ import ( "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" ) @@ -31,6 +32,7 @@ func run() error { 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) diff --git a/tools/mcp-server/internal/resources/resource_alias.go b/tools/mcp-server/internal/resources/resource_alias.go new file mode 100644 index 0000000000..c2596e4b15 --- /dev/null +++ b/tools/mcp-server/internal/resources/resource_alias.go @@ -0,0 +1,136 @@ +package resources + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + "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" + "github.com/oasdiff/kin-openapi/openapi3" +) + +// 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://{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,omitempty"` + AvailableVersions []string `json:"availableVersions,omitempty"` + HasPreview bool `json:"hasPreview,omitempty"` + HasUpcoming bool `json:"hasUpcoming,omitempty"` +} + +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 + if len(stable) > 0 { + // ExtractVersions returns versions sorted ascending by date string (YYYY-MM-DD). + overview.AvailableVersions = stable + 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) { + all, err := openapi.ExtractVersions(spec) + if err != nil || len(all) == 0 { + return nil, 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 +} + +func aliasFromURI(uri string) (string, error) { + u, err := url.Parse(uri) + if err != nil { + return "", fmt.Errorf("invalid resource URI %q: expected openapi://specs/{alias}", uri) + } + alias := strings.TrimPrefix(u.Path, "/") + if alias == "" { + return "", fmt.Errorf("invalid resource URI %q: expected openapi://specs/{alias}", uri) + } + return alias, nil +} diff --git a/tools/mcp-server/internal/resources/resource_alias_test.go b/tools/mcp-server/internal/resources/resource_alias_test.go new file mode 100644 index 0000000000..ff1e74bbbb --- /dev/null +++ b/tools/mcp-server/internal/resources/resource_alias_test.go @@ -0,0 +1,71 @@ +package resources + +import ( + "encoding/json" + "testing" + + "github.com/mongodb/openapi/tools/mcp-server/internal/registry" +) + +// TestHandleAlias_Overview verifies that the spec overview contains title, stats, and source info. +func TestHandleAlias_Overview(t *testing.T) { + reg := newTestRegistry(t) + + result, err := handleAlias(reg, makeRequest("openapi://specs/test-api")) + if err != nil { + t.Fatalf("handleAlias() returned unexpected error: %v", err) + } + var body SpecOverview + if err := json.Unmarshal([]byte(result.Contents[0].Text), &body); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if body.Alias != "test-api" { + t.Errorf("alias = %q, want %q", body.Alias, "test-api") + } + if body.Title != "Test API" { + t.Errorf("title = %q, want %q", body.Title, "Test API") + } + if body.SourceType != registry.SourceTypeFile { + t.Errorf("sourceType = %q, want %q", body.SourceType, registry.SourceTypeFile) + } + if body.Stats.Paths != 4 { + t.Errorf("stats.paths = %d, want 4", body.Stats.Paths) + } + if body.Stats.Operations != 5 { + t.Errorf("stats.operations = %d, want 5", body.Stats.Operations) + } + if body.Stats.Tags != 2 { + t.Errorf("stats.tags = %d, want 2", body.Stats.Tags) + } + if body.Stats.Schemas != 2 { + t.Errorf("stats.schemas = %d, want 2", body.Stats.Schemas) + } + if body.LatestStableVersion != "2025-01-01" { + t.Errorf("latestStableVersion = %q, want %q", body.LatestStableVersion, "2025-01-01") + } + if len(body.AvailableVersions) != 2 { + t.Errorf("availableVersions = %v, want [2024-01-01 2025-01-01]", body.AvailableVersions) + } + if !body.HasPreview { + t.Error("hasPreview = false, want true") + } + if !body.HasUpcoming { + t.Error("hasUpcoming = false, want true") + } +} + +// 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")) + if err == nil { + t.Error("expected error for non-existent alias, got nil") + } +} + +// 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")) + if err == nil { + t.Error("expected error for URI with no alias, got nil") + } +} diff --git a/tools/mcp-server/internal/resources/resource_specs.go b/tools/mcp-server/internal/resources/resource_specs.go new file mode 100644 index 0000000000..8f87c46bdf --- /dev/null +++ b/tools/mcp-server/internal/resources/resource_specs.go @@ -0,0 +1,45 @@ +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/resource_specs_test.go b/tools/mcp-server/internal/resources/resource_specs_test.go new file mode 100644 index 0000000000..a51b7f6c5e --- /dev/null +++ b/tools/mcp-server/internal/resources/resource_specs_test.go @@ -0,0 +1,73 @@ +package resources + +import ( + "encoding/json" + "testing" + + "github.com/mongodb/openapi/tools/mcp-server/internal/registry" + "github.com/oasdiff/kin-openapi/openapi3" +) + +// 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")) + if err != nil { + t.Fatalf("handleSpecs() returned unexpected error: %v", err) + } + var body SpecsResource + if err := json.Unmarshal([]byte(result.Contents[0].Text), &body); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if body.Total != 0 || len(body.Specs) != 0 { + t.Errorf("expected empty specs list, got total=%d", body.Total) + } +} + +// TestHandleSpecs_WithEntries verifies that loaded specs are returned with alias, sourceType, and filePath. +func TestHandleSpecs_WithEntries(t *testing.T) { + reg := newTestRegistry(t) + + result, err := handleSpecs(reg, makeRequest("openapi://specs")) + if err != nil { + t.Fatalf("handleSpecs() returned unexpected error: %v", err) + } + var body SpecsResource + if err := json.Unmarshal([]byte(result.Contents[0].Text), &body); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if body.Total != 1 { + t.Fatalf("expected total=1, got %d", body.Total) + } + s := body.Specs[0] + if s.Alias != "test-api" { + t.Errorf("alias = %q, want %q", s.Alias, "test-api") + } + if s.SourceType != registry.SourceTypeFile { + t.Errorf("sourceType = %q, want %q", s.SourceType, registry.SourceTypeFile) + } + if s.FilePath != "/test/api.yaml" { + t.Errorf("filePath = %q, want %q", s.FilePath, "/test/api.yaml") + } +} + +// TestHandleSpecs_VirtualSpecHasNoFilePath verifies that virtual specs omit filePath. +func TestHandleSpecs_VirtualSpecHasNoFilePath(t *testing.T) { + reg := registry.New() + if err := reg.Add("virtual-api", "", &openapi3.T{Info: &openapi3.Info{Title: "Virtual"}}, nil); err != nil { + t.Fatalf("failed to add virtual spec: %v", err) + } + result, err := handleSpecs(reg, makeRequest("openapi://specs")) + if err != nil { + t.Fatalf("handleSpecs() returned unexpected error: %v", err) + } + var body SpecsResource + if err := json.Unmarshal([]byte(result.Contents[0].Text), &body); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if body.Specs[0].FilePath != "" { + t.Errorf("expected empty filePath for virtual spec, got %q", body.Specs[0].FilePath) + } + if body.Specs[0].SourceType != registry.SourceTypeVirtual { + t.Errorf("sourceType = %q, want %q", body.Specs[0].SourceType, registry.SourceTypeVirtual) + } +} diff --git a/tools/mcp-server/internal/resources/resources.go b/tools/mcp-server/internal/resources/resources.go new file mode 100644 index 0000000000..2055c812c5 --- /dev/null +++ b/tools/mcp-server/internal/resources/resources.go @@ -0,0 +1,49 @@ +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 latestVersion (the most recent stable API version to use in Accept/Content-Type headers), " + + "availableVersions (all stable date-based versions), and hasPreview (true if preview 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://{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/testhelper_test.go b/tools/mcp-server/internal/resources/testhelper_test.go new file mode 100644 index 0000000000..9a5e44ffff --- /dev/null +++ b/tools/mcp-server/internal/resources/testhelper_test.go @@ -0,0 +1,124 @@ +package resources + +import ( + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/mongodb/openapi/tools/mcp-server/internal/registry" + "github.com/oasdiff/kin-openapi/openapi3" +) + +// 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 creates a comprehensive OpenAPI spec shared across all resource tests. +// It includes multiple paths, tags, schemas, and versioned media types. +func newTestSpec() *openapi3.T { + spec := &openapi3.T{ + Info: &openapi3.Info{ + Title: "Test API", + Version: "2.0", + Description: "A test API", + }, + Paths: &openapi3.Paths{}, + Tags: openapi3.Tags{ + {Name: "Users", Description: "User operations"}, + {Name: "Clusters", Description: "Cluster operations"}, + }, + Components: &openapi3.Components{ + Schemas: map[string]*openapi3.SchemaRef{ + "User": {Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}}, + "Cluster": {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("/users", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "getUsers", + Summary: "List users", + Tags: []string{"Users"}, + Responses: newStableResp(), + }, + Post: &openapi3.Operation{ + OperationID: "createUser", + Summary: "Create a user", + Tags: []string{"Users"}, + Responses: newStableResp(), + }, + }) + + spec.Paths.Set("/users/{userId}", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "getUser", + Summary: "Get a user", + Tags: []string{"Users"}, + Responses: newStableResp(), + }, + }) + + spec.Paths.Set("/clusters", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "listClusters", + Summary: "List clusters", + Tags: []string{"Clusters"}, + Responses: newPreviewResp(), + }, + }) + + spec.Paths.Set("/clusters/{clusterId}/upcoming-feature", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "getUpcomingFeature", + Summary: "Get upcoming feature", + Tags: []string{"Clusters"}, + Responses: newUpcomingResp(), + }, + }) + + return spec +} diff --git a/tools/mcp-server/internal/tools/tools.go b/tools/mcp-server/internal/tools/tools.go index c932b90409..fa52171455 100644 --- a/tools/mcp-server/internal/tools/tools.go +++ b/tools/mcp-server/internal/tools/tools.go @@ -7,7 +7,7 @@ import ( "github.com/mongodb/openapi/tools/mcp-server/internal/registry" ) -// Register registers all tool handlers with the server using the official SDK. +// Register registers all tool handlers with the server. func Register(server *mcp.Server, reg *registry.Registry) { // Register load tool loadTool := &mcp.Tool{ From a2f19da85cf8ce48fc492f80d4286f23a256f4c4 Mon Sep 17 00:00:00 2001 From: Yeliz Henden Date: Tue, 14 Apr 2026 13:18:24 +0100 Subject: [PATCH 2/6] description fix --- tools/mcp-server/internal/resources/resources.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/mcp-server/internal/resources/resources.go b/tools/mcp-server/internal/resources/resources.go index 2055c812c5..a5e4af70ab 100644 --- a/tools/mcp-server/internal/resources/resources.go +++ b/tools/mcp-server/internal/resources/resources.go @@ -27,8 +27,11 @@ func Register(server *mcp.Server, reg *registry.Registry) { 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 latestVersion (the most recent stable API version to use in Accept/Content-Type headers), " + - "availableVersions (all stable date-based versions), and hasPreview (true if preview operations exist). " + + "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)) From c7a222993866b17b3b4894748b33266658dbb1f9 Mon Sep 17 00:00:00 2001 From: Yeliz Henden Date: Tue, 14 Apr 2026 14:55:19 +0100 Subject: [PATCH 3/6] fixes --- .../resources/{resource_alias.go => alias.go} | 14 ---- .../{resource_alias_test.go => alias_test.go} | 6 +- .../resources/{resource_specs.go => specs.go} | 0 .../{resource_specs_test.go => specs_test.go} | 0 .../internal/resources/testhelper_test.go | 64 ++++++++++--------- tools/mcp-server/internal/resources/uri.go | 20 ++++++ 6 files changed, 58 insertions(+), 46 deletions(-) rename tools/mcp-server/internal/resources/{resource_alias.go => alias.go} (89%) rename tools/mcp-server/internal/resources/{resource_alias_test.go => alias_test.go} (94%) rename tools/mcp-server/internal/resources/{resource_specs.go => specs.go} (100%) rename tools/mcp-server/internal/resources/{resource_specs_test.go => specs_test.go} (100%) create mode 100644 tools/mcp-server/internal/resources/uri.go diff --git a/tools/mcp-server/internal/resources/resource_alias.go b/tools/mcp-server/internal/resources/alias.go similarity index 89% rename from tools/mcp-server/internal/resources/resource_alias.go rename to tools/mcp-server/internal/resources/alias.go index c2596e4b15..ae9ce9fea2 100644 --- a/tools/mcp-server/internal/resources/resource_alias.go +++ b/tools/mcp-server/internal/resources/alias.go @@ -3,8 +3,6 @@ package resources import ( "encoding/json" "fmt" - "net/url" - "strings" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/mongodb/openapi/tools/cli/pkg/apiversion" @@ -122,15 +120,3 @@ func extractVersions(spec *openapi3.T) (stable []string, hasPreview, hasUpcoming } return stable, hasPreview, hasUpcoming } - -func aliasFromURI(uri string) (string, error) { - u, err := url.Parse(uri) - if err != nil { - return "", fmt.Errorf("invalid resource URI %q: expected openapi://specs/{alias}", uri) - } - alias := strings.TrimPrefix(u.Path, "/") - if alias == "" { - return "", fmt.Errorf("invalid resource URI %q: expected openapi://specs/{alias}", uri) - } - return alias, nil -} diff --git a/tools/mcp-server/internal/resources/resource_alias_test.go b/tools/mcp-server/internal/resources/alias_test.go similarity index 94% rename from tools/mcp-server/internal/resources/resource_alias_test.go rename to tools/mcp-server/internal/resources/alias_test.go index ff1e74bbbb..628a93468c 100644 --- a/tools/mcp-server/internal/resources/resource_alias_test.go +++ b/tools/mcp-server/internal/resources/alias_test.go @@ -7,7 +7,7 @@ import ( "github.com/mongodb/openapi/tools/mcp-server/internal/registry" ) -// TestHandleAlias_Overview verifies that the spec overview contains title, stats, and source info. +// TestHandleAlias_Overview verifies that the spec overview contains title, stats, and version info. func TestHandleAlias_Overview(t *testing.T) { reg := newTestRegistry(t) @@ -31,8 +31,8 @@ func TestHandleAlias_Overview(t *testing.T) { if body.Stats.Paths != 4 { t.Errorf("stats.paths = %d, want 4", body.Stats.Paths) } - if body.Stats.Operations != 5 { - t.Errorf("stats.operations = %d, want 5", body.Stats.Operations) + if body.Stats.Operations != 6 { + t.Errorf("stats.operations = %d, want 6", body.Stats.Operations) } if body.Stats.Tags != 2 { t.Errorf("stats.tags = %d, want 2", body.Stats.Tags) diff --git a/tools/mcp-server/internal/resources/resource_specs.go b/tools/mcp-server/internal/resources/specs.go similarity index 100% rename from tools/mcp-server/internal/resources/resource_specs.go rename to tools/mcp-server/internal/resources/specs.go diff --git a/tools/mcp-server/internal/resources/resource_specs_test.go b/tools/mcp-server/internal/resources/specs_test.go similarity index 100% rename from tools/mcp-server/internal/resources/resource_specs_test.go rename to tools/mcp-server/internal/resources/specs_test.go diff --git a/tools/mcp-server/internal/resources/testhelper_test.go b/tools/mcp-server/internal/resources/testhelper_test.go index 9a5e44ffff..220271aaad 100644 --- a/tools/mcp-server/internal/resources/testhelper_test.go +++ b/tools/mcp-server/internal/resources/testhelper_test.go @@ -23,24 +23,23 @@ func newTestRegistry(t *testing.T) *registry.Registry { return reg } -// newTestSpec creates a comprehensive OpenAPI spec shared across all resource tests. -// It includes multiple paths, tags, schemas, and versioned media types. +// 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", - Version: "2.0", Description: "A test API", }, Paths: &openapi3.Paths{}, Tags: openapi3.Tags{ - {Name: "Users", Description: "User operations"}, - {Name: "Clusters", Description: "Cluster operations"}, + {Name: "Clusters"}, + {Name: "Flex Clusters"}, // space in name → percent-encoded as "Flex%20Clusters" in URIs }, Components: &openapi3.Components{ Schemas: map[string]*openapi3.SchemaRef{ - "User": {Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}}, - "Cluster": {Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}}, + "Cluster": {Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}}, + "FlexCluster": {Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}}, }, }, } @@ -78,44 +77,51 @@ func newTestSpec() *openapi3.T { })) } - spec.Paths.Set("/users", &openapi3.PathItem{ + spec.Paths.Set("/api/atlas/v2/clusters", &openapi3.PathItem{ Get: &openapi3.Operation{ - OperationID: "getUsers", - Summary: "List users", - Tags: []string{"Users"}, - Responses: newStableResp(), - }, - Post: &openapi3.Operation{ - OperationID: "createUser", - Summary: "Create a user", - Tags: []string{"Users"}, + OperationID: "listClusterDetails", + Summary: "Return All Authorized Clusters in All Projects", + Tags: []string{"Clusters"}, Responses: newStableResp(), }, }) - spec.Paths.Set("/users/{userId}", &openapi3.PathItem{ + spec.Paths.Set("/api/atlas/v2/groups/{groupId}/clusters", &openapi3.PathItem{ Get: &openapi3.Operation{ - OperationID: "getUser", - Summary: "Get a user", - Tags: []string{"Users"}, + 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("/clusters", &openapi3.PathItem{ - Get: &openapi3.Operation{ - OperationID: "listClusters", - Summary: "List clusters", + 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(), }, }) - spec.Paths.Set("/clusters/{clusterId}/upcoming-feature", &openapi3.PathItem{ + // 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: "getUpcomingFeature", - Summary: "Get upcoming feature", - Tags: []string{"Clusters"}, + 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(), }, }) diff --git a/tools/mcp-server/internal/resources/uri.go b/tools/mcp-server/internal/resources/uri.go new file mode 100644 index 0000000000..ddc61be11b --- /dev/null +++ b/tools/mcp-server/internal/resources/uri.go @@ -0,0 +1,20 @@ +package resources + +import ( + "fmt" + "net/url" + "strings" +) + +// aliasFromURI extracts the alias from openapi://specs/{alias}. +func aliasFromURI(uri string) (string, error) { + u, err := url.Parse(uri) + if err != nil { + return "", fmt.Errorf("invalid resource URI %q: expected openapi://specs/{alias}", uri) + } + alias := strings.TrimPrefix(u.Path, "/") + if alias == "" { + return "", fmt.Errorf("invalid resource URI %q: expected openapi://specs/{alias}", uri) + } + return alias, nil +} From 3c271bdee9bd64515c748e9087bba7908411038c Mon Sep 17 00:00:00 2001 From: Yeliz Henden Date: Tue, 14 Apr 2026 15:07:21 +0100 Subject: [PATCH 4/6] fix --- tools/mcp-server/internal/resources/alias.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tools/mcp-server/internal/resources/alias.go b/tools/mcp-server/internal/resources/alias.go index ae9ce9fea2..7c3e475c17 100644 --- a/tools/mcp-server/internal/resources/alias.go +++ b/tools/mcp-server/internal/resources/alias.go @@ -26,10 +26,10 @@ type SpecOverview struct { Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` Stats SpecStats `json:"stats"` - LatestStableVersion string `json:"latestStableVersion,omitempty"` - AvailableVersions []string `json:"availableVersions,omitempty"` - HasPreview bool `json:"hasPreview,omitempty"` - HasUpcoming bool `json:"hasUpcoming,omitempty"` + 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) { @@ -86,9 +86,9 @@ func buildSpecOverview(entry *registry.Entry) SpecOverview { 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 { - // ExtractVersions returns versions sorted ascending by date string (YYYY-MM-DD). - overview.AvailableVersions = stable overview.LatestStableVersion = stable[len(stable)-1] } @@ -104,9 +104,10 @@ func countOperations(spec *openapi3.T) int { } func extractVersions(spec *openapi3.T) (stable []string, hasPreview, hasUpcoming bool) { + stable = []string{} all, err := openapi.ExtractVersions(spec) if err != nil || len(all) == 0 { - return nil, false, false + return stable, false, false } for _, v := range all { switch { From 044f88df67efb45f876869fd6c046e703d8fde1e Mon Sep 17 00:00:00 2001 From: Yeliz Henden Date: Wed, 15 Apr 2026 15:37:13 +0100 Subject: [PATCH 5/6] address the comments --- tools/mcp-server/go.mod | 1 + tools/mcp-server/internal/resources/alias.go | 2 +- .../internal/resources/alias_test.go | 72 +++++------- .../internal/resources/resources.go | 21 +++- .../internal/resources/specs_test.go | 66 ++++------- tools/mcp-server/internal/resources/tags.go | 105 ++++++++++++++++++ .../internal/resources/tags_test.go | 101 +++++++++++++++++ tools/mcp-server/internal/resources/uri.go | 30 ++++- .../mcp-server/internal/resources/uri_test.go | 65 +++++++++++ 9 files changed, 366 insertions(+), 97 deletions(-) create mode 100644 tools/mcp-server/internal/resources/tags.go create mode 100644 tools/mcp-server/internal/resources/tags_test.go create mode 100644 tools/mcp-server/internal/resources/uri_test.go diff --git a/tools/mcp-server/go.mod b/tools/mcp-server/go.mod index 35e3bd4c33..597dcf5873 100644 --- a/tools/mcp-server/go.mod +++ b/tools/mcp-server/go.mod @@ -25,6 +25,7 @@ require ( 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/stretchr/testify v1.11.1 // 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 diff --git a/tools/mcp-server/internal/resources/alias.go b/tools/mcp-server/internal/resources/alias.go index 7c3e475c17..4979446a8e 100644 --- a/tools/mcp-server/internal/resources/alias.go +++ b/tools/mcp-server/internal/resources/alias.go @@ -19,7 +19,7 @@ type SpecStats struct { Tags int `json:"tags"` } -// SpecOverview is the response body for the openapi://{alias} resource. +// SpecOverview is the response body for the openapi://specs/{alias} resource. type SpecOverview struct { Alias string `json:"alias"` SourceType registry.SourceType `json:"sourceType"` diff --git a/tools/mcp-server/internal/resources/alias_test.go b/tools/mcp-server/internal/resources/alias_test.go index 628a93468c..5b3c2a9274 100644 --- a/tools/mcp-server/internal/resources/alias_test.go +++ b/tools/mcp-server/internal/resources/alias_test.go @@ -5,67 +5,45 @@ import ( "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) { - reg := newTestRegistry(t) + result, err := handleAlias(newTestRegistry(t), makeRequest("openapi://specs/test-api")) + require.NoError(t, err) - result, err := handleAlias(reg, makeRequest("openapi://specs/test-api")) - if err != nil { - t.Fatalf("handleAlias() returned unexpected error: %v", err) - } var body SpecOverview - if err := json.Unmarshal([]byte(result.Contents[0].Text), &body); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - if body.Alias != "test-api" { - t.Errorf("alias = %q, want %q", body.Alias, "test-api") - } - if body.Title != "Test API" { - t.Errorf("title = %q, want %q", body.Title, "Test API") - } - if body.SourceType != registry.SourceTypeFile { - t.Errorf("sourceType = %q, want %q", body.SourceType, registry.SourceTypeFile) - } - if body.Stats.Paths != 4 { - t.Errorf("stats.paths = %d, want 4", body.Stats.Paths) - } - if body.Stats.Operations != 6 { - t.Errorf("stats.operations = %d, want 6", body.Stats.Operations) - } - if body.Stats.Tags != 2 { - t.Errorf("stats.tags = %d, want 2", body.Stats.Tags) - } - if body.Stats.Schemas != 2 { - t.Errorf("stats.schemas = %d, want 2", body.Stats.Schemas) - } - if body.LatestStableVersion != "2025-01-01" { - t.Errorf("latestStableVersion = %q, want %q", body.LatestStableVersion, "2025-01-01") - } - if len(body.AvailableVersions) != 2 { - t.Errorf("availableVersions = %v, want [2024-01-01 2025-01-01]", body.AvailableVersions) - } - if !body.HasPreview { - t.Error("hasPreview = false, want true") - } - if !body.HasUpcoming { - t.Error("hasUpcoming = false, want true") - } + 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")) - if err == nil { - t.Error("expected error for non-existent alias, got nil") - } + 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")) - if err == nil { - t.Error("expected error for URI with no alias, got nil") - } + 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 index a5e4af70ab..52cab746ec 100644 --- a/tools/mcp-server/internal/resources/resources.go +++ b/tools/mcp-server/internal/resources/resources.go @@ -35,6 +35,18 @@ func Register(server *mcp.Server, reg *registry.Registry) { "Use this to understand the scope of a spec before searching or slicing it.", MIMEType: mimeTypeJSON, }, makeAliasHandler(reg)) + + server.AddResourceTemplate(&mcp.ResourceTemplate{ + URITemplate: "openapi://specs/{alias}/tags/{tagName}", + Name: "spec-tag-operations", + Description: "Lists all operations for a specific tag in the spec identified by {alias}. " + + "{tagName} is case-sensitive and must match a tag name exactly as defined in the spec. " + + "Use the tag name as it appears in the spec — URI encoding is handled automatically. " + + "Each operation includes operationId, HTTP method, path, and summary. " + + "Results are sorted by path then method. " + + "Returns an error if the alias or tag does not exist.", + MIMEType: mimeTypeJSON, + }, makeTagsHandler(reg)) } // makeSpecsHandler creates the handler for the openapi://specs resource. @@ -44,9 +56,16 @@ func makeSpecsHandler(reg *registry.Registry) mcp.ResourceHandler { } } -// makeAliasHandler creates the handler for the openapi://{alias} resource template. +// 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) } } + +// makeTagsHandler creates the handler for the openapi://specs/{alias}/tags/{tagName} resource template. +func makeTagsHandler(reg *registry.Registry) mcp.ResourceHandler { + return func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return handleTags(reg, req) + } +} diff --git a/tools/mcp-server/internal/resources/specs_test.go b/tools/mcp-server/internal/resources/specs_test.go index a51b7f6c5e..80d35fa3f9 100644 --- a/tools/mcp-server/internal/resources/specs_test.go +++ b/tools/mcp-server/internal/resources/specs_test.go @@ -6,68 +6,46 @@ import ( "github.com/mongodb/openapi/tools/mcp-server/internal/registry" "github.com/oasdiff/kin-openapi/openapi3" + "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")) - if err != nil { - t.Fatalf("handleSpecs() returned unexpected error: %v", err) - } + require.NoError(t, err) + var body SpecsResource - if err := json.Unmarshal([]byte(result.Contents[0].Text), &body); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - if body.Total != 0 || len(body.Specs) != 0 { - t.Errorf("expected empty specs list, got total=%d", body.Total) - } + 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) { - reg := newTestRegistry(t) + result, err := handleSpecs(newTestRegistry(t), makeRequest("openapi://specs")) + require.NoError(t, err) - result, err := handleSpecs(reg, makeRequest("openapi://specs")) - if err != nil { - t.Fatalf("handleSpecs() returned unexpected error: %v", err) - } var body SpecsResource - if err := json.Unmarshal([]byte(result.Contents[0].Text), &body); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - if body.Total != 1 { - t.Fatalf("expected total=1, got %d", body.Total) - } + require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body)) + require.Equal(t, 1, body.Total) + s := body.Specs[0] - if s.Alias != "test-api" { - t.Errorf("alias = %q, want %q", s.Alias, "test-api") - } - if s.SourceType != registry.SourceTypeFile { - t.Errorf("sourceType = %q, want %q", s.SourceType, registry.SourceTypeFile) - } - if s.FilePath != "/test/api.yaml" { - t.Errorf("filePath = %q, want %q", s.FilePath, "/test/api.yaml") - } + 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() - if err := reg.Add("virtual-api", "", &openapi3.T{Info: &openapi3.Info{Title: "Virtual"}}, nil); err != nil { - t.Fatalf("failed to add virtual spec: %v", err) - } + require.NoError(t, reg.Add("virtual-api", "", &openapi3.T{Info: &openapi3.Info{Title: "Virtual"}}, nil)) + result, err := handleSpecs(reg, makeRequest("openapi://specs")) - if err != nil { - t.Fatalf("handleSpecs() returned unexpected error: %v", err) - } + require.NoError(t, err) + var body SpecsResource - if err := json.Unmarshal([]byte(result.Contents[0].Text), &body); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - if body.Specs[0].FilePath != "" { - t.Errorf("expected empty filePath for virtual spec, got %q", body.Specs[0].FilePath) - } - if body.Specs[0].SourceType != registry.SourceTypeVirtual { - t.Errorf("sourceType = %q, want %q", body.Specs[0].SourceType, registry.SourceTypeVirtual) - } + 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 new file mode 100644 index 0000000000..79af3f7f1b --- /dev/null +++ b/tools/mcp-server/internal/resources/tags.go @@ -0,0 +1,105 @@ +package resources + +import ( + "encoding/json" + "fmt" + "sort" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/mongodb/openapi/tools/mcp-server/internal/registry" + "github.com/oasdiff/kin-openapi/openapi3" +) + +// 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.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 new file mode 100644 index 0000000000..e8541fdd74 --- /dev/null +++ b/tools/mcp-server/internal/resources/tags_test.go @@ -0,0 +1,101 @@ +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" +) + +// TestHandleTags_Clusters verifies Clusters operations are returned sorted by path then method. +// Operation IDs, paths, and summaries mirror the real Atlas v2 spec. +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) + assert.Equal(t, 4, body.Total) + require.Len(t, body.Operations, 4) + + // Sorted by path then method: + // 1. GET /api/atlas/v2/clusters + // 2. GET /api/atlas/v2/groups/{groupId}/clusters + // 3. POST /api/atlas/v2/groups/{groupId}/clusters + // 4. DELETE /api/atlas/v2/groups/{groupId}/clusters/{clusterName} + assert.Equal(t, "listClusterDetails", body.Operations[0].OperationID) + assert.Equal(t, "GET", body.Operations[0].Method) + assert.Equal(t, "listGroupClusters", body.Operations[1].OperationID) + assert.Equal(t, "GET", body.Operations[1].Method) + assert.Equal(t, "createGroupCluster", body.Operations[2].OperationID) + assert.Equal(t, "POST", body.Operations[2].Method) + assert.Equal(t, "deleteGroupCluster", body.Operations[3].OperationID) + assert.Equal(t, "DELETE", body.Operations[3].Method) +} + +// TestHandleTags_OperationFields verifies all fields are populated correctly. +// Uses listClusterDetails from the real Atlas v2 spec as the reference operation. +func TestHandleTags_OperationFields(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)) + + var op *TagOperation + for i := range body.Operations { + if body.Operations[i].OperationID == "listClusterDetails" { + op = &body.Operations[i] + break + } + } + require.NotNil(t, op, "operation listClusterDetails not found") + assert.Equal(t, "GET", op.Method) + assert.Equal(t, "/api/atlas/v2/clusters", op.Path) + assert.Equal(t, "Return All Authorized Clusters in All Projects", op.Summary) +} + +// 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) + assert.Equal(t, 2, body.Total) + require.Len(t, body.Operations, 2) + // Sorted: GET before POST on the same path + assert.Equal(t, "listGroupFlexClusters", body.Operations[0].OperationID) + assert.Equal(t, "createGroupFlexCluster", body.Operations[1].OperationID) +} + +// 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/uri.go b/tools/mcp-server/internal/resources/uri.go index ddc61be11b..706facaa8a 100644 --- a/tools/mcp-server/internal/resources/uri.go +++ b/tools/mcp-server/internal/resources/uri.go @@ -7,14 +7,36 @@ import ( ) // aliasFromURI extracts the alias from openapi://specs/{alias}. +// Returns an error if the scheme, host, or path structure does not match exactly. func aliasFromURI(uri string) (string, error) { u, err := url.Parse(uri) - if err != nil { + if err != nil || u.Scheme != "openapi" || u.Host != "specs" { return "", fmt.Errorf("invalid resource URI %q: expected openapi://specs/{alias}", uri) } - alias := strings.TrimPrefix(u.Path, "/") - if alias == "" { + 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 alias, nil + return parts[0], nil +} + +// aliasAndTagFromURI extracts the alias and tag name from openapi://specs/{alias}/tags/{tagName}. +// The tag name is percent-decoded to handle names with spaces or special characters. +func aliasAndTagFromURI(uri string) (alias, tagName string, err error) { + u, parseErr := url.Parse(uri) + if parseErr != nil || u.Scheme != "openapi" || u.Host != "specs" { + 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 new file mode 100644 index 0000000000..5f28569580 --- /dev/null +++ b/tools/mcp-server/internal/resources/uri_test.go @@ -0,0 +1,65 @@ +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) + }) + } +} From 7ce6e1a6991fb07825fe733305cebd7f92fb3611 Mon Sep 17 00:00:00 2001 From: Yeliz Henden Date: Wed, 15 Apr 2026 15:50:26 +0100 Subject: [PATCH 6/6] remove irrelevant commits --- .../internal/resources/alias_test.go | 10 +- .../internal/resources/resources.go | 19 ---- tools/mcp-server/internal/resources/tags.go | 105 ------------------ .../internal/resources/tags_test.go | 101 ----------------- .../internal/resources/testhelper_test.go | 30 +---- tools/mcp-server/internal/resources/uri.go | 21 ---- 6 files changed, 6 insertions(+), 280 deletions(-) delete mode 100644 tools/mcp-server/internal/resources/tags.go delete mode 100644 tools/mcp-server/internal/resources/tags_test.go diff --git a/tools/mcp-server/internal/resources/alias_test.go b/tools/mcp-server/internal/resources/alias_test.go index 5b3c2a9274..fbb699e972 100644 --- a/tools/mcp-server/internal/resources/alias_test.go +++ b/tools/mcp-server/internal/resources/alias_test.go @@ -20,14 +20,14 @@ func TestHandleAlias_Overview(t *testing.T) { 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, 3, body.Stats.Paths) + assert.Equal(t, 4, body.Stats.Operations) + assert.Equal(t, 1, body.Stats.Tags) + assert.Equal(t, 1, 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) + assert.False(t, body.HasUpcoming) } // TestHandleAlias_NotFound verifies that reading a non-existent alias returns an error. diff --git a/tools/mcp-server/internal/resources/resources.go b/tools/mcp-server/internal/resources/resources.go index 52cab746ec..a26f49d213 100644 --- a/tools/mcp-server/internal/resources/resources.go +++ b/tools/mcp-server/internal/resources/resources.go @@ -35,18 +35,6 @@ func Register(server *mcp.Server, reg *registry.Registry) { "Use this to understand the scope of a spec before searching or slicing it.", MIMEType: mimeTypeJSON, }, makeAliasHandler(reg)) - - server.AddResourceTemplate(&mcp.ResourceTemplate{ - URITemplate: "openapi://specs/{alias}/tags/{tagName}", - Name: "spec-tag-operations", - Description: "Lists all operations for a specific tag in the spec identified by {alias}. " + - "{tagName} is case-sensitive and must match a tag name exactly as defined in the spec. " + - "Use the tag name as it appears in the spec — URI encoding is handled automatically. " + - "Each operation includes operationId, HTTP method, path, and summary. " + - "Results are sorted by path then method. " + - "Returns an error if the alias or tag does not exist.", - MIMEType: mimeTypeJSON, - }, makeTagsHandler(reg)) } // makeSpecsHandler creates the handler for the openapi://specs resource. @@ -62,10 +50,3 @@ func makeAliasHandler(reg *registry.Registry) mcp.ResourceHandler { return handleAlias(reg, req) } } - -// makeTagsHandler creates the handler for the openapi://specs/{alias}/tags/{tagName} resource template. -func makeTagsHandler(reg *registry.Registry) mcp.ResourceHandler { - return func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { - return handleTags(reg, req) - } -} diff --git a/tools/mcp-server/internal/resources/tags.go b/tools/mcp-server/internal/resources/tags.go deleted file mode 100644 index 79af3f7f1b..0000000000 --- a/tools/mcp-server/internal/resources/tags.go +++ /dev/null @@ -1,105 +0,0 @@ -package resources - -import ( - "encoding/json" - "fmt" - "sort" - - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/mongodb/openapi/tools/mcp-server/internal/registry" - "github.com/oasdiff/kin-openapi/openapi3" -) - -// 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.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 e8541fdd74..0000000000 --- a/tools/mcp-server/internal/resources/tags_test.go +++ /dev/null @@ -1,101 +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" -) - -// TestHandleTags_Clusters verifies Clusters operations are returned sorted by path then method. -// Operation IDs, paths, and summaries mirror the real Atlas v2 spec. -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) - assert.Equal(t, 4, body.Total) - require.Len(t, body.Operations, 4) - - // Sorted by path then method: - // 1. GET /api/atlas/v2/clusters - // 2. GET /api/atlas/v2/groups/{groupId}/clusters - // 3. POST /api/atlas/v2/groups/{groupId}/clusters - // 4. DELETE /api/atlas/v2/groups/{groupId}/clusters/{clusterName} - assert.Equal(t, "listClusterDetails", body.Operations[0].OperationID) - assert.Equal(t, "GET", body.Operations[0].Method) - assert.Equal(t, "listGroupClusters", body.Operations[1].OperationID) - assert.Equal(t, "GET", body.Operations[1].Method) - assert.Equal(t, "createGroupCluster", body.Operations[2].OperationID) - assert.Equal(t, "POST", body.Operations[2].Method) - assert.Equal(t, "deleteGroupCluster", body.Operations[3].OperationID) - assert.Equal(t, "DELETE", body.Operations[3].Method) -} - -// TestHandleTags_OperationFields verifies all fields are populated correctly. -// Uses listClusterDetails from the real Atlas v2 spec as the reference operation. -func TestHandleTags_OperationFields(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)) - - var op *TagOperation - for i := range body.Operations { - if body.Operations[i].OperationID == "listClusterDetails" { - op = &body.Operations[i] - break - } - } - require.NotNil(t, op, "operation listClusterDetails not found") - assert.Equal(t, "GET", op.Method) - assert.Equal(t, "/api/atlas/v2/clusters", op.Path) - assert.Equal(t, "Return All Authorized Clusters in All Projects", op.Summary) -} - -// 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) - assert.Equal(t, 2, body.Total) - require.Len(t, body.Operations, 2) - // Sorted: GET before POST on the same path - assert.Equal(t, "listGroupFlexClusters", body.Operations[0].OperationID) - assert.Equal(t, "createGroupFlexCluster", body.Operations[1].OperationID) -} - -// 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 index 220271aaad..99c6410ee2 100644 --- a/tools/mcp-server/internal/resources/testhelper_test.go +++ b/tools/mcp-server/internal/resources/testhelper_test.go @@ -34,12 +34,10 @@ func newTestSpec() *openapi3.T { 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"}}}, + "Cluster": {Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}}, }, }, } @@ -67,16 +65,6 @@ func newTestSpec() *openapi3.T { }, })) } - 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", @@ -110,21 +98,5 @@ func newTestSpec() *openapi3.T { }, }) - // 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 index 706facaa8a..09cf7579cf 100644 --- a/tools/mcp-server/internal/resources/uri.go +++ b/tools/mcp-server/internal/resources/uri.go @@ -19,24 +19,3 @@ func aliasFromURI(uri string) (string, error) { } return parts[0], nil } - -// aliasAndTagFromURI extracts the alias and tag name from openapi://specs/{alias}/tags/{tagName}. -// The tag name is percent-decoded to handle names with spaces or special characters. -func aliasAndTagFromURI(uri string) (alias, tagName string, err error) { - u, parseErr := url.Parse(uri) - if parseErr != nil || u.Scheme != "openapi" || u.Host != "specs" { - 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 -}