Skip to content

Commit 979076e

Browse files
feat: add resources for listing specs in the registry and get a summary for a specific one (#1205)
1 parent 0b657b7 commit 979076e

13 files changed

Lines changed: 544 additions & 1 deletion

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Package apiversion exposes API version parsing utilities for use outside the cli module.
2+
package apiversion
3+
4+
import (
5+
"github.com/mongodb/openapi/tools/cli/internal/apiversion"
6+
)
7+
8+
// Parse extracts the API version string from a versioned media type
9+
// (e.g. "application/vnd.atlas.2024-01-01+json" → "2024-01-01").
10+
// Returns an error if the media type does not match the expected pattern.
11+
func Parse(contentType string) (string, error) {
12+
return apiversion.Parse(contentType)
13+
}
14+
15+
// IsPreviewStabilityLevel reports whether the given version string represents a preview release.
16+
func IsPreviewStabilityLevel(version string) bool {
17+
return apiversion.IsPreviewStabilityLevel(version)
18+
}
19+
20+
// IsUpcomingStabilityLevel reports whether the given version string represents an upcoming release.
21+
func IsUpcomingStabilityLevel(version string) bool {
22+
return apiversion.IsUpcomingStabilityLevel(version)
23+
}

tools/cli/pkg/openapi/openapi.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ import (
2424
"github.com/spf13/afero"
2525
)
2626

27+
// ExtractVersions returns all API version strings present in the spec,
28+
// including stable date versions (e.g. "2024-01-01"), preview names
29+
// (e.g. "preview", "public-preview"), and upcoming versions
30+
// (e.g. "2024-01-01.upcoming") when no stable counterpart exists.
31+
// The returned slice is sorted.
32+
func ExtractVersions(spec *openapi3.T) ([]string, error) {
33+
return openapi.ExtractVersionsWithEnv(spec, "")
34+
}
35+
2736
// SliceCriteria defines the selection criteria for slicing an OpenAPI spec.
2837
// Operations matching ANY of the specified criteria will be included (OR logic).
2938
type SliceCriteria = slice.Criteria

tools/mcp-server/cmd/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/modelcontextprotocol/go-sdk/mcp"
99
"github.com/mongodb/openapi/tools/mcp-server/internal/registry"
10+
"github.com/mongodb/openapi/tools/mcp-server/internal/resources"
1011
"github.com/mongodb/openapi/tools/mcp-server/internal/tools"
1112
)
1213

@@ -31,6 +32,7 @@ func run() error {
3132
server := mcp.NewServer(impl, nil)
3233

3334
tools.Register(server, reg)
35+
resources.Register(server, reg)
3436

3537
// Log to stderr (stdout is reserved for MCP protocol)
3638
log.SetOutput(os.Stderr)

tools/mcp-server/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ require (
2525
github.com/perimeterx/marshmallow v1.1.5 // indirect
2626
github.com/segmentio/asm v1.1.3 // indirect
2727
github.com/segmentio/encoding v0.5.4 // indirect
28+
github.com/stretchr/testify v1.11.1 // indirect
2829
github.com/tidwall/gjson v1.18.0 // indirect
2930
github.com/tidwall/match v1.2.0 // indirect
3031
github.com/tidwall/pretty v1.2.1 // indirect
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package resources
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
7+
"github.com/modelcontextprotocol/go-sdk/mcp"
8+
"github.com/mongodb/openapi/tools/cli/pkg/apiversion"
9+
"github.com/mongodb/openapi/tools/cli/pkg/openapi"
10+
"github.com/mongodb/openapi/tools/mcp-server/internal/registry"
11+
"github.com/oasdiff/kin-openapi/openapi3"
12+
)
13+
14+
// SpecStats holds counts of the spec's top-level components.
15+
type SpecStats struct {
16+
Paths int `json:"paths"`
17+
Operations int `json:"operations"`
18+
Schemas int `json:"schemas"`
19+
Tags int `json:"tags"`
20+
}
21+
22+
// SpecOverview is the response body for the openapi://specs/{alias} resource.
23+
type SpecOverview struct {
24+
Alias string `json:"alias"`
25+
SourceType registry.SourceType `json:"sourceType"`
26+
Title string `json:"title,omitempty"`
27+
Description string `json:"description,omitempty"`
28+
Stats SpecStats `json:"stats"`
29+
LatestStableVersion string `json:"latestStableVersion"`
30+
AvailableVersions []string `json:"availableVersions"`
31+
HasPreview bool `json:"hasPreview"`
32+
HasUpcoming bool `json:"hasUpcoming"`
33+
}
34+
35+
func handleAlias(reg *registry.Registry, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
36+
alias, err := aliasFromURI(req.Params.URI)
37+
if err != nil {
38+
return nil, err
39+
}
40+
41+
entry, err := reg.GetByAlias(alias)
42+
if err != nil {
43+
return nil, fmt.Errorf("spec with alias %q not found", alias)
44+
}
45+
46+
overview := buildSpecOverview(entry)
47+
48+
data, err := json.Marshal(overview)
49+
if err != nil {
50+
return nil, err
51+
}
52+
53+
return &mcp.ReadResourceResult{
54+
Contents: []*mcp.ResourceContents{
55+
{URI: req.Params.URI, MIMEType: mimeTypeJSON, Text: string(data)},
56+
},
57+
}, nil
58+
}
59+
60+
func buildSpecOverview(entry *registry.Entry) SpecOverview {
61+
overview := SpecOverview{
62+
Alias: entry.Alias,
63+
SourceType: entry.SourceType,
64+
}
65+
66+
if entry.Spec == nil {
67+
return overview
68+
}
69+
70+
if entry.Spec.Info != nil {
71+
overview.Title = entry.Spec.Info.Title
72+
overview.Description = entry.Spec.Info.Description
73+
}
74+
75+
if entry.Spec.Paths != nil {
76+
overview.Stats.Paths = len(entry.Spec.Paths.Map())
77+
overview.Stats.Operations = countOperations(entry.Spec)
78+
}
79+
80+
if entry.Spec.Components != nil {
81+
overview.Stats.Schemas = len(entry.Spec.Components.Schemas)
82+
}
83+
84+
overview.Stats.Tags = len(entry.Spec.Tags)
85+
86+
stable, hasPreview, hasUpcoming := extractVersions(entry.Spec)
87+
overview.HasPreview = hasPreview
88+
overview.HasUpcoming = hasUpcoming
89+
// ExtractVersions returns versions sorted ascending by date string (YYYY-MM-DD).
90+
overview.AvailableVersions = stable
91+
if len(stable) > 0 {
92+
overview.LatestStableVersion = stable[len(stable)-1]
93+
}
94+
95+
return overview
96+
}
97+
98+
func countOperations(spec *openapi3.T) int {
99+
count := 0
100+
for _, item := range spec.Paths.Map() {
101+
count += len(item.Operations())
102+
}
103+
return count
104+
}
105+
106+
func extractVersions(spec *openapi3.T) (stable []string, hasPreview, hasUpcoming bool) {
107+
stable = []string{}
108+
all, err := openapi.ExtractVersions(spec)
109+
if err != nil || len(all) == 0 {
110+
return stable, false, false
111+
}
112+
for _, v := range all {
113+
switch {
114+
case apiversion.IsPreviewStabilityLevel(v):
115+
hasPreview = true
116+
case apiversion.IsUpcomingStabilityLevel(v):
117+
hasUpcoming = true
118+
default:
119+
stable = append(stable, v)
120+
}
121+
}
122+
return stable, hasPreview, hasUpcoming
123+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package resources
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/mongodb/openapi/tools/mcp-server/internal/registry"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// TestHandleAlias_Overview verifies that the spec overview contains title, stats, and version info.
13+
func TestHandleAlias_Overview(t *testing.T) {
14+
result, err := handleAlias(newTestRegistry(t), makeRequest("openapi://specs/test-api"))
15+
require.NoError(t, err)
16+
17+
var body SpecOverview
18+
require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body))
19+
20+
assert.Equal(t, "test-api", body.Alias)
21+
assert.Equal(t, "Test API", body.Title)
22+
assert.Equal(t, registry.SourceTypeFile, body.SourceType)
23+
assert.Equal(t, 3, body.Stats.Paths)
24+
assert.Equal(t, 4, body.Stats.Operations)
25+
assert.Equal(t, 1, body.Stats.Tags)
26+
assert.Equal(t, 1, body.Stats.Schemas)
27+
assert.Equal(t, "2025-01-01", body.LatestStableVersion)
28+
assert.Equal(t, []string{"2024-01-01", "2025-01-01"}, body.AvailableVersions)
29+
assert.True(t, body.HasPreview)
30+
assert.False(t, body.HasUpcoming)
31+
}
32+
33+
// TestHandleAlias_NotFound verifies that reading a non-existent alias returns an error.
34+
func TestHandleAlias_NotFound(t *testing.T) {
35+
_, err := handleAlias(registry.New(), makeRequest("openapi://specs/nonexistent"))
36+
require.Error(t, err)
37+
}
38+
39+
// TestHandleAlias_URIMissingAlias verifies that a URI without an alias segment returns an error.
40+
func TestHandleAlias_URIMissingAlias(t *testing.T) {
41+
_, err := handleAlias(registry.New(), makeRequest("not-a-valid-uri"))
42+
require.Error(t, err)
43+
}
44+
45+
// TestHandleAlias_URIExtraSegments verifies that a URI with extra path segments is rejected.
46+
func TestHandleAlias_URIExtraSegments(t *testing.T) {
47+
_, err := handleAlias(registry.New(), makeRequest("openapi://specs/test-api/tags/Clusters"))
48+
require.Error(t, err)
49+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package resources
2+
3+
import (
4+
"context"
5+
6+
"github.com/modelcontextprotocol/go-sdk/mcp"
7+
"github.com/mongodb/openapi/tools/mcp-server/internal/registry"
8+
)
9+
10+
const mimeTypeJSON = "application/json"
11+
12+
// Register registers all static resources and resource template handlers with the server.
13+
func Register(server *mcp.Server, reg *registry.Registry) {
14+
server.AddResource(&mcp.Resource{
15+
URI: "openapi://specs",
16+
Name: "specs",
17+
Description: "Start here. Lists all OpenAPI specifications currently loaded in the registry. " +
18+
"Each entry includes the alias (used to reference the spec in all other resources and tools), " +
19+
"sourceType ('file' for specs loaded from disk, 'virtual' for sliced subsets), " +
20+
"and filePath (empty for virtual specs). " +
21+
"Read this resource first to discover what aliases are available before using other resources or tools.",
22+
MIMEType: mimeTypeJSON,
23+
}, makeSpecsHandler(reg))
24+
25+
server.AddResourceTemplate(&mcp.ResourceTemplate{
26+
URITemplate: "openapi://specs/{alias}",
27+
Name: "spec-overview",
28+
Description: "Returns a structural overview of a single loaded spec identified by {alias}. " +
29+
"Includes title, description, and stats (path count, operation count, schema count, tag count). " +
30+
"For versioned APIs, also returns: " +
31+
"latestStableVersion (the most recent stable YYYY-MM-DD version), " +
32+
"availableVersions (all stable date-based versions in ascending order), " +
33+
"hasPreview (true if any preview operations exist), " +
34+
"hasUpcoming (true if any upcoming operations exist). " +
35+
"Use this to understand the scope of a spec before searching or slicing it.",
36+
MIMEType: mimeTypeJSON,
37+
}, makeAliasHandler(reg))
38+
}
39+
40+
// makeSpecsHandler creates the handler for the openapi://specs resource.
41+
func makeSpecsHandler(reg *registry.Registry) mcp.ResourceHandler {
42+
return func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
43+
return handleSpecs(reg, req)
44+
}
45+
}
46+
47+
// makeAliasHandler creates the handler for the openapi://specs/{alias} resource template.
48+
func makeAliasHandler(reg *registry.Registry) mcp.ResourceHandler {
49+
return func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
50+
return handleAlias(reg, req)
51+
}
52+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package resources
2+
3+
import (
4+
"encoding/json"
5+
6+
"github.com/modelcontextprotocol/go-sdk/mcp"
7+
"github.com/mongodb/openapi/tools/mcp-server/internal/registry"
8+
)
9+
10+
// SpecSummary is a summary of a single spec returned by the openapi://specs resource.
11+
type SpecSummary struct {
12+
Alias string `json:"alias"`
13+
SourceType registry.SourceType `json:"sourceType"`
14+
FilePath string `json:"filePath,omitempty"`
15+
}
16+
17+
// SpecsResource is the response body for the openapi://specs resource.
18+
type SpecsResource struct {
19+
Specs []SpecSummary `json:"specs"`
20+
Total int `json:"total"`
21+
}
22+
23+
func handleSpecs(reg *registry.Registry, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
24+
entries := reg.List()
25+
26+
summaries := make([]SpecSummary, len(entries))
27+
for i, entry := range entries {
28+
summaries[i] = SpecSummary{
29+
Alias: entry.Alias,
30+
SourceType: entry.SourceType,
31+
FilePath: entry.FilePath,
32+
}
33+
}
34+
35+
data, err := json.Marshal(SpecsResource{Specs: summaries, Total: len(summaries)})
36+
if err != nil {
37+
return nil, err
38+
}
39+
40+
return &mcp.ReadResourceResult{
41+
Contents: []*mcp.ResourceContents{
42+
{URI: req.Params.URI, MIMEType: mimeTypeJSON, Text: string(data)},
43+
},
44+
}, nil
45+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package resources
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/mongodb/openapi/tools/mcp-server/internal/registry"
8+
"github.com/oasdiff/kin-openapi/openapi3"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// TestHandleSpecs_EmptyRegistry verifies that an empty registry returns an empty list.
14+
func TestHandleSpecs_EmptyRegistry(t *testing.T) {
15+
result, err := handleSpecs(registry.New(), makeRequest("openapi://specs"))
16+
require.NoError(t, err)
17+
18+
var body SpecsResource
19+
require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body))
20+
assert.Equal(t, 0, body.Total)
21+
assert.Empty(t, body.Specs)
22+
}
23+
24+
// TestHandleSpecs_WithEntries verifies that loaded specs are returned with alias, sourceType, and filePath.
25+
func TestHandleSpecs_WithEntries(t *testing.T) {
26+
result, err := handleSpecs(newTestRegistry(t), makeRequest("openapi://specs"))
27+
require.NoError(t, err)
28+
29+
var body SpecsResource
30+
require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body))
31+
require.Equal(t, 1, body.Total)
32+
33+
s := body.Specs[0]
34+
assert.Equal(t, "test-api", s.Alias)
35+
assert.Equal(t, registry.SourceTypeFile, s.SourceType)
36+
assert.Equal(t, "/test/api.yaml", s.FilePath)
37+
}
38+
39+
// TestHandleSpecs_VirtualSpecHasNoFilePath verifies that virtual specs omit filePath.
40+
func TestHandleSpecs_VirtualSpecHasNoFilePath(t *testing.T) {
41+
reg := registry.New()
42+
require.NoError(t, reg.Add("virtual-api", "", &openapi3.T{Info: &openapi3.Info{Title: "Virtual"}}, nil))
43+
44+
result, err := handleSpecs(reg, makeRequest("openapi://specs"))
45+
require.NoError(t, err)
46+
47+
var body SpecsResource
48+
require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body))
49+
assert.Empty(t, body.Specs[0].FilePath)
50+
assert.Equal(t, registry.SourceTypeVirtual, body.Specs[0].SourceType)
51+
}

0 commit comments

Comments
 (0)