Skip to content

Commit 2bc3f62

Browse files
feat: add tags resource to mcp server (#1230)
1 parent 76d92c7 commit 2bc3f62

5 files changed

Lines changed: 260 additions & 9 deletions

File tree

tools/mcp-server/internal/resources/alias_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ func TestHandleAlias_Overview(t *testing.T) {
2020
assert.Equal(t, "test-api", body.Alias)
2121
assert.Equal(t, "Test API", body.Title)
2222
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)
23+
assert.Equal(t, 4, body.Stats.Paths)
24+
assert.Equal(t, 6, body.Stats.Operations)
25+
assert.Equal(t, 2, body.Stats.Tags)
26+
assert.Equal(t, 2, body.Stats.Schemas)
2727
assert.Equal(t, "2025-01-01", body.LatestStableVersion)
2828
assert.Equal(t, []string{"2024-01-01", "2025-01-01"}, body.AvailableVersions)
2929
assert.True(t, body.HasPreview)
30-
assert.False(t, body.HasUpcoming)
30+
assert.True(t, body.HasUpcoming)
3131
}
3232

3333
// TestHandleAlias_NotFound verifies that reading a non-existent alias returns an error.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package resources
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"sort"
7+
8+
"github.com/getkin/kin-openapi/openapi3"
9+
"github.com/modelcontextprotocol/go-sdk/mcp"
10+
"github.com/mongodb/openapi/tools/mcp-server/internal/registry"
11+
)
12+
13+
// TagOperation represents a single operation belonging to a tag.
14+
type TagOperation struct {
15+
OperationID string `json:"operationId"`
16+
Method string `json:"method"`
17+
Path string `json:"path"`
18+
Summary string `json:"summary"`
19+
}
20+
21+
// TagsResource is the response body for the openapi://specs/{alias}/tags/{tagName} resource.
22+
type TagsResource struct {
23+
Tag string `json:"tag"`
24+
Total int `json:"total"`
25+
Operations []TagOperation `json:"operations"`
26+
}
27+
28+
func handleTags(reg *registry.Registry, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
29+
alias, tagName, err := aliasAndTagFromURI(req.Params.URI)
30+
if err != nil {
31+
return nil, err
32+
}
33+
34+
entry, err := reg.GetByAlias(alias)
35+
if err != nil {
36+
return nil, fmt.Errorf("spec with alias %q not found", alias)
37+
}
38+
39+
ops, err := operationsByTag(entry.Spec, tagName)
40+
if err != nil {
41+
return nil, err
42+
}
43+
44+
resource := TagsResource{
45+
Tag: tagName,
46+
Total: len(ops),
47+
Operations: ops,
48+
}
49+
50+
data, err := json.Marshal(resource)
51+
if err != nil {
52+
return nil, err
53+
}
54+
55+
return &mcp.ReadResourceResult{
56+
Contents: []*mcp.ResourceContents{
57+
{URI: req.Params.URI, MIMEType: mimeTypeJSON, Text: string(data)},
58+
},
59+
}, nil
60+
}
61+
62+
// operationsByTag returns all operations in the spec tagged with tagName,
63+
// sorted by path then method for deterministic output.
64+
// Returns an error if no operations are found for the given tag.
65+
func operationsByTag(spec *openapi3.T, tagName string) ([]TagOperation, error) {
66+
if spec == nil || spec.Paths == nil {
67+
return nil, fmt.Errorf("tag %q not found in spec", tagName)
68+
}
69+
70+
var ops []TagOperation
71+
for path, item := range spec.Paths.Map() {
72+
if item == nil {
73+
continue
74+
}
75+
for method, op := range item.Operations() {
76+
if op == nil {
77+
continue
78+
}
79+
for _, t := range op.Tags {
80+
if t == tagName {
81+
ops = append(ops, TagOperation{
82+
OperationID: op.OperationID,
83+
Method: method,
84+
Path: path,
85+
Summary: op.Summary,
86+
})
87+
break
88+
}
89+
}
90+
}
91+
}
92+
93+
if len(ops) == 0 {
94+
return nil, fmt.Errorf("tag %q not found in spec", tagName)
95+
}
96+
97+
sort.Slice(ops, func(i, j int) bool {
98+
if ops[i].Path != ops[j].Path {
99+
return ops[i].Path < ops[j].Path
100+
}
101+
return ops[i].Method < ops[j].Method
102+
})
103+
104+
return ops, nil
105+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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+
type wantOp struct {
13+
operationID string
14+
method string
15+
path string
16+
summary string
17+
}
18+
19+
func assertOp(t *testing.T, got TagOperation, want wantOp) {
20+
t.Helper()
21+
assert.Equal(t, want.operationID, got.OperationID)
22+
assert.Equal(t, want.method, got.Method)
23+
assert.Equal(t, want.path, got.Path)
24+
assert.Equal(t, want.summary, got.Summary)
25+
}
26+
27+
// TestHandleTags_Clusters verifies Clusters operations are returned sorted by path then method.
28+
func TestHandleTags_Clusters(t *testing.T) {
29+
result, err := handleTags(newTestRegistry(t), makeRequest("openapi://specs/test-api/tags/Clusters"))
30+
require.NoError(t, err)
31+
32+
var body TagsResource
33+
require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body))
34+
assert.Equal(t, "Clusters", body.Tag)
35+
require.Equal(t, 4, body.Total)
36+
37+
assertOp(t, body.Operations[0], wantOp{"listClusterDetails", "GET",
38+
"/api/atlas/v2/clusters", "Return All Authorized Clusters in All Projects"})
39+
assertOp(t, body.Operations[1], wantOp{"listGroupClusters", "GET",
40+
"/api/atlas/v2/groups/{groupId}/clusters", "Return All Clusters in One Project"})
41+
assertOp(t, body.Operations[2], wantOp{"createGroupCluster", "POST",
42+
"/api/atlas/v2/groups/{groupId}/clusters", "Create One Cluster in One Project"})
43+
assertOp(t, body.Operations[3], wantOp{"deleteGroupCluster", "DELETE",
44+
"/api/atlas/v2/groups/{groupId}/clusters/{clusterName}", "Remove One Cluster from One Project"})
45+
}
46+
47+
// TestHandleTags_FlexClusters verifies that tag names containing spaces are resolved correctly.
48+
// The server decodes the URI automatically so agents can use tag names as they appear in the spec.
49+
func TestHandleTags_FlexClusters(t *testing.T) {
50+
result, err := handleTags(newTestRegistry(t), makeRequest("openapi://specs/test-api/tags/Flex%20Clusters"))
51+
require.NoError(t, err)
52+
53+
var body TagsResource
54+
require.NoError(t, json.Unmarshal([]byte(result.Contents[0].Text), &body))
55+
assert.Equal(t, "Flex Clusters", body.Tag)
56+
require.Equal(t, 2, body.Total)
57+
58+
// Sorted: GET before POST on the same path.
59+
assertOp(t, body.Operations[0], wantOp{"listGroupFlexClusters", "GET",
60+
"/api/atlas/v2/groups/{groupId}/flexClusters", "Return All Flex Clusters from One Project"})
61+
assertOp(t, body.Operations[1], wantOp{"createGroupFlexCluster", "POST",
62+
"/api/atlas/v2/groups/{groupId}/flexClusters", "Create One Flex Cluster in One Project"})
63+
}
64+
65+
// TestHandleTags_TagNotFound verifies that a non-existent tag returns an error.
66+
func TestHandleTags_TagNotFound(t *testing.T) {
67+
_, err := handleTags(newTestRegistry(t), makeRequest("openapi://specs/test-api/tags/NonExistent"))
68+
require.Error(t, err)
69+
}
70+
71+
// TestHandleTags_TagCaseSensitive verifies that tag matching is case-sensitive.
72+
func TestHandleTags_TagCaseSensitive(t *testing.T) {
73+
_, err := handleTags(newTestRegistry(t), makeRequest("openapi://specs/test-api/tags/clusters"))
74+
require.Error(t, err)
75+
}
76+
77+
// TestHandleTags_AliasNotFound verifies that a non-existent alias returns an error.
78+
func TestHandleTags_AliasNotFound(t *testing.T) {
79+
_, err := handleTags(registry.New(), makeRequest("openapi://specs/nonexistent/tags/Clusters"))
80+
require.Error(t, err)
81+
}
82+
83+
// TestHandleTags_URIInvalid verifies that a URI missing the tag segment returns an error.
84+
func TestHandleTags_URIInvalid(t *testing.T) {
85+
_, err := handleTags(registry.New(), makeRequest("openapi://specs/test-api"))
86+
require.Error(t, err)
87+
}

tools/mcp-server/internal/resources/testhelper_test.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@ func newTestSpec() *openapi3.T {
3434
Paths: &openapi3.Paths{},
3535
Tags: openapi3.Tags{
3636
{Name: "Clusters"},
37+
{Name: "Flex Clusters"}, // space in name → percent-encoded as "Flex%20Clusters" in URIs
3738
},
3839
Components: &openapi3.Components{
3940
Schemas: map[string]*openapi3.SchemaRef{
40-
"Cluster": {Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}},
41+
"Cluster": {Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}},
42+
"FlexCluster": {Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}},
4143
},
4244
},
4345
}
@@ -65,6 +67,16 @@ func newTestSpec() *openapi3.T {
6567
},
6668
}))
6769
}
70+
newUpcomingResp := func() *openapi3.Responses {
71+
return openapi3.NewResponses(openapi3.WithStatus(200, &openapi3.ResponseRef{
72+
Value: &openapi3.Response{
73+
Content: openapi3.Content{
74+
"application/vnd.atlas.2026-01-01.upcoming+json": &openapi3.MediaType{},
75+
},
76+
},
77+
}))
78+
}
79+
6880
spec.Paths.Set("/api/atlas/v2/clusters", &openapi3.PathItem{
6981
Get: &openapi3.Operation{
7082
OperationID: "listClusterDetails",
@@ -98,5 +110,21 @@ func newTestSpec() *openapi3.T {
98110
},
99111
})
100112

113+
// Flex Clusters: tag name has a space, exercising percent-encoding in URIs.
114+
spec.Paths.Set("/api/atlas/v2/groups/{groupId}/flexClusters", &openapi3.PathItem{
115+
Get: &openapi3.Operation{
116+
OperationID: "listGroupFlexClusters",
117+
Summary: "Return All Flex Clusters from One Project",
118+
Tags: []string{"Flex Clusters"},
119+
Responses: newStableResp(),
120+
},
121+
Post: &openapi3.Operation{
122+
OperationID: "createGroupFlexCluster",
123+
Summary: "Create One Flex Cluster in One Project",
124+
Tags: []string{"Flex Clusters"},
125+
Responses: newUpcomingResp(),
126+
},
127+
})
128+
101129
return spec
102130
}

tools/mcp-server/internal/resources/uri.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,21 @@ import (
66
"strings"
77
)
88

9-
// aliasFromURI extracts the alias from openapi://specs/{alias}.
10-
// Returns an error if the scheme, host, or path structure does not match exactly.
11-
func aliasFromURI(uri string) (string, error) {
9+
// parseSpecURI parses a resource URI and validates that it uses the openapi://specs/ base.
10+
// It is the shared entry point for all URI parsing in this package.
11+
func parseSpecURI(uri string) (*url.URL, error) {
1212
u, err := url.Parse(uri)
1313
if err != nil || u.Scheme != "openapi" || u.Host != "specs" {
14+
return nil, fmt.Errorf("invalid resource URI %q: must use openapi://specs/ scheme", uri)
15+
}
16+
return u, nil
17+
}
18+
19+
// aliasFromURI extracts the alias from openapi://specs/{alias}.
20+
// Returns an error if the path has extra segments or the alias is empty.
21+
func aliasFromURI(uri string) (string, error) {
22+
u, err := parseSpecURI(uri)
23+
if err != nil {
1424
return "", fmt.Errorf("invalid resource URI %q: expected openapi://specs/{alias}", uri)
1525
}
1626
parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
@@ -19,3 +29,24 @@ func aliasFromURI(uri string) (string, error) {
1929
}
2030
return parts[0], nil
2131
}
32+
33+
// aliasAndTagFromURI extracts the alias and tag name from openapi://specs/{alias}/tags/{tagName}.
34+
// The tag name is percent-decoded so agents can use tag names as they appear in the spec.
35+
func aliasAndTagFromURI(uri string) (alias, tagName string, err error) {
36+
u, err := parseSpecURI(uri)
37+
if err != nil {
38+
return "", "", fmt.Errorf("invalid resource URI %q: expected openapi://specs/{alias}/tags/{tagName}", uri)
39+
}
40+
41+
// path: /{alias}/tags/{tagName}
42+
parts := strings.SplitN(strings.TrimPrefix(u.Path, "/"), "/", 3)
43+
if len(parts) != 3 || parts[0] == "" || parts[1] != "tags" || parts[2] == "" {
44+
return "", "", fmt.Errorf("invalid resource URI %q: expected openapi://specs/{alias}/tags/{tagName}", uri)
45+
}
46+
47+
tagName, err = url.PathUnescape(parts[2])
48+
if err != nil {
49+
return "", "", fmt.Errorf("invalid tag name in URI %q: %w", uri, err)
50+
}
51+
return parts[0], tagName, nil
52+
}

0 commit comments

Comments
 (0)