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/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 new file mode 100644 index 0000000000..4979446a8e --- /dev/null +++ b/tools/mcp-server/internal/resources/alias.go @@ -0,0 +1,123 @@ +package resources + +import ( + "encoding/json" + "fmt" + + "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://specs/{alias} resource. +type SpecOverview struct { + Alias string `json:"alias"` + SourceType registry.SourceType `json:"sourceType"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Stats SpecStats `json:"stats"` + LatestStableVersion string `json:"latestStableVersion"` + AvailableVersions []string `json:"availableVersions"` + HasPreview bool `json:"hasPreview"` + HasUpcoming bool `json:"hasUpcoming"` +} + +func handleAlias(reg *registry.Registry, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + alias, err := aliasFromURI(req.Params.URI) + if err != nil { + return nil, err + } + + entry, err := reg.GetByAlias(alias) + if err != nil { + return nil, fmt.Errorf("spec with alias %q not found", alias) + } + + overview := buildSpecOverview(entry) + + data, err := json.Marshal(overview) + if err != nil { + return nil, err + } + + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + {URI: req.Params.URI, MIMEType: mimeTypeJSON, Text: string(data)}, + }, + }, nil +} + +func buildSpecOverview(entry *registry.Entry) SpecOverview { + overview := SpecOverview{ + Alias: entry.Alias, + SourceType: entry.SourceType, + } + + if entry.Spec == nil { + return overview + } + + if entry.Spec.Info != nil { + overview.Title = entry.Spec.Info.Title + overview.Description = entry.Spec.Info.Description + } + + if entry.Spec.Paths != nil { + overview.Stats.Paths = len(entry.Spec.Paths.Map()) + overview.Stats.Operations = countOperations(entry.Spec) + } + + if entry.Spec.Components != nil { + overview.Stats.Schemas = len(entry.Spec.Components.Schemas) + } + + overview.Stats.Tags = len(entry.Spec.Tags) + + stable, hasPreview, hasUpcoming := extractVersions(entry.Spec) + overview.HasPreview = hasPreview + overview.HasUpcoming = hasUpcoming + // ExtractVersions returns versions sorted ascending by date string (YYYY-MM-DD). + overview.AvailableVersions = stable + if len(stable) > 0 { + overview.LatestStableVersion = stable[len(stable)-1] + } + + return overview +} + +func countOperations(spec *openapi3.T) int { + count := 0 + for _, item := range spec.Paths.Map() { + count += len(item.Operations()) + } + return count +} + +func extractVersions(spec *openapi3.T) (stable []string, hasPreview, hasUpcoming bool) { + stable = []string{} + all, err := openapi.ExtractVersions(spec) + if err != nil || len(all) == 0 { + return stable, false, false + } + for _, v := range all { + switch { + case apiversion.IsPreviewStabilityLevel(v): + hasPreview = true + case apiversion.IsUpcomingStabilityLevel(v): + hasUpcoming = true + default: + stable = append(stable, v) + } + } + return stable, hasPreview, hasUpcoming +} diff --git a/tools/mcp-server/internal/resources/alias_test.go b/tools/mcp-server/internal/resources/alias_test.go new file mode 100644 index 0000000000..fbb699e972 --- /dev/null +++ b/tools/mcp-server/internal/resources/alias_test.go @@ -0,0 +1,49 @@ +package resources + +import ( + "encoding/json" + "testing" + + "github.com/mongodb/openapi/tools/mcp-server/internal/registry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHandleAlias_Overview verifies that the spec overview contains title, stats, and version info. +func TestHandleAlias_Overview(t *testing.T) { + result, err := handleAlias(newTestRegistry(t), makeRequest("openapi://specs/test-api")) + require.NoError(t, err) + + var body SpecOverview + require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body)) + + assert.Equal(t, "test-api", body.Alias) + assert.Equal(t, "Test API", body.Title) + assert.Equal(t, registry.SourceTypeFile, body.SourceType) + assert.Equal(t, 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.False(t, body.HasUpcoming) +} + +// TestHandleAlias_NotFound verifies that reading a non-existent alias returns an error. +func TestHandleAlias_NotFound(t *testing.T) { + _, err := handleAlias(registry.New(), makeRequest("openapi://specs/nonexistent")) + require.Error(t, err) +} + +// TestHandleAlias_URIMissingAlias verifies that a URI without an alias segment returns an error. +func TestHandleAlias_URIMissingAlias(t *testing.T) { + _, err := handleAlias(registry.New(), makeRequest("not-a-valid-uri")) + require.Error(t, err) +} + +// TestHandleAlias_URIExtraSegments verifies that a URI with extra path segments is rejected. +func TestHandleAlias_URIExtraSegments(t *testing.T) { + _, err := handleAlias(registry.New(), makeRequest("openapi://specs/test-api/tags/Clusters")) + require.Error(t, err) +} diff --git a/tools/mcp-server/internal/resources/resources.go b/tools/mcp-server/internal/resources/resources.go new file mode 100644 index 0000000000..a26f49d213 --- /dev/null +++ b/tools/mcp-server/internal/resources/resources.go @@ -0,0 +1,52 @@ +package resources + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/mongodb/openapi/tools/mcp-server/internal/registry" +) + +const mimeTypeJSON = "application/json" + +// Register registers all static resources and resource template handlers with the server. +func Register(server *mcp.Server, reg *registry.Registry) { + server.AddResource(&mcp.Resource{ + URI: "openapi://specs", + Name: "specs", + Description: "Start here. Lists all OpenAPI specifications currently loaded in the registry. " + + "Each entry includes the alias (used to reference the spec in all other resources and tools), " + + "sourceType ('file' for specs loaded from disk, 'virtual' for sliced subsets), " + + "and filePath (empty for virtual specs). " + + "Read this resource first to discover what aliases are available before using other resources or tools.", + MIMEType: mimeTypeJSON, + }, makeSpecsHandler(reg)) + + server.AddResourceTemplate(&mcp.ResourceTemplate{ + URITemplate: "openapi://specs/{alias}", + Name: "spec-overview", + Description: "Returns a structural overview of a single loaded spec identified by {alias}. " + + "Includes title, description, and stats (path count, operation count, schema count, tag count). " + + "For versioned APIs, also returns: " + + "latestStableVersion (the most recent stable YYYY-MM-DD version), " + + "availableVersions (all stable date-based versions in ascending order), " + + "hasPreview (true if any preview operations exist), " + + "hasUpcoming (true if any upcoming operations exist). " + + "Use this to understand the scope of a spec before searching or slicing it.", + MIMEType: mimeTypeJSON, + }, makeAliasHandler(reg)) +} + +// makeSpecsHandler creates the handler for the openapi://specs resource. +func makeSpecsHandler(reg *registry.Registry) mcp.ResourceHandler { + return func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return handleSpecs(reg, req) + } +} + +// makeAliasHandler creates the handler for the openapi://specs/{alias} resource template. +func makeAliasHandler(reg *registry.Registry) mcp.ResourceHandler { + return func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return handleAlias(reg, req) + } +} diff --git a/tools/mcp-server/internal/resources/specs.go b/tools/mcp-server/internal/resources/specs.go new file mode 100644 index 0000000000..8f87c46bdf --- /dev/null +++ b/tools/mcp-server/internal/resources/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/specs_test.go b/tools/mcp-server/internal/resources/specs_test.go new file mode 100644 index 0000000000..80d35fa3f9 --- /dev/null +++ b/tools/mcp-server/internal/resources/specs_test.go @@ -0,0 +1,51 @@ +package resources + +import ( + "encoding/json" + "testing" + + "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")) + require.NoError(t, err) + + var body SpecsResource + require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body)) + assert.Equal(t, 0, body.Total) + assert.Empty(t, body.Specs) +} + +// TestHandleSpecs_WithEntries verifies that loaded specs are returned with alias, sourceType, and filePath. +func TestHandleSpecs_WithEntries(t *testing.T) { + result, err := handleSpecs(newTestRegistry(t), makeRequest("openapi://specs")) + require.NoError(t, err) + + var body SpecsResource + require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body)) + require.Equal(t, 1, body.Total) + + s := body.Specs[0] + assert.Equal(t, "test-api", s.Alias) + assert.Equal(t, registry.SourceTypeFile, s.SourceType) + assert.Equal(t, "/test/api.yaml", s.FilePath) +} + +// TestHandleSpecs_VirtualSpecHasNoFilePath verifies that virtual specs omit filePath. +func TestHandleSpecs_VirtualSpecHasNoFilePath(t *testing.T) { + reg := registry.New() + require.NoError(t, reg.Add("virtual-api", "", &openapi3.T{Info: &openapi3.Info{Title: "Virtual"}}, nil)) + + result, err := handleSpecs(reg, makeRequest("openapi://specs")) + require.NoError(t, err) + + var body SpecsResource + require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body)) + assert.Empty(t, body.Specs[0].FilePath) + assert.Equal(t, registry.SourceTypeVirtual, body.Specs[0].SourceType) +} diff --git a/tools/mcp-server/internal/resources/testhelper_test.go b/tools/mcp-server/internal/resources/testhelper_test.go new file mode 100644 index 0000000000..99c6410ee2 --- /dev/null +++ b/tools/mcp-server/internal/resources/testhelper_test.go @@ -0,0 +1,102 @@ +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 builds a synthetic OpenAPI spec whose paths, operation IDs, summaries, and tag names +// are modeled after the real Atlas v2 spec so that test assertions reflect realistic API data. +func newTestSpec() *openapi3.T { + spec := &openapi3.T{ + Info: &openapi3.Info{ + Title: "Test API", + Description: "A test API", + }, + Paths: &openapi3.Paths{}, + Tags: openapi3.Tags{ + {Name: "Clusters"}, + }, + Components: &openapi3.Components{ + Schemas: map[string]*openapi3.SchemaRef{ + "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"}, + }, + }, + }, + }, + })) + } + spec.Paths.Set("/api/atlas/v2/clusters", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "listClusterDetails", + Summary: "Return All Authorized Clusters in All Projects", + Tags: []string{"Clusters"}, + Responses: newStableResp(), + }, + }) + + spec.Paths.Set("/api/atlas/v2/groups/{groupId}/clusters", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "listGroupClusters", + Summary: "Return All Clusters in One Project", + Tags: []string{"Clusters"}, + Responses: newStableResp(), + }, + Post: &openapi3.Operation{ + OperationID: "createGroupCluster", + Summary: "Create One Cluster in One Project", + Tags: []string{"Clusters"}, + Responses: newStableResp(), + }, + }) + + spec.Paths.Set("/api/atlas/v2/groups/{groupId}/clusters/{clusterName}", &openapi3.PathItem{ + Delete: &openapi3.Operation{ + OperationID: "deleteGroupCluster", + Summary: "Remove One Cluster from One Project", + Tags: []string{"Clusters"}, + Responses: newPreviewResp(), + }, + }) + + return spec +} diff --git a/tools/mcp-server/internal/resources/uri.go b/tools/mcp-server/internal/resources/uri.go new file mode 100644 index 0000000000..09cf7579cf --- /dev/null +++ b/tools/mcp-server/internal/resources/uri.go @@ -0,0 +1,21 @@ +package resources + +import ( + "fmt" + "net/url" + "strings" +) + +// 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 || u.Scheme != "openapi" || u.Host != "specs" { + return "", fmt.Errorf("invalid resource URI %q: expected openapi://specs/{alias}", uri) + } + parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/") + if len(parts) != 1 || parts[0] == "" { + return "", fmt.Errorf("invalid resource URI %q: expected openapi://specs/{alias}", uri) + } + return parts[0], nil +} 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) + }) + } +} 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{