-
Notifications
You must be signed in to change notification settings - Fork 16
feat: add operations resource to mcp server #1233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| package resources | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "fmt" | ||
|
|
||
| "github.com/getkin/kin-openapi/openapi3" | ||
| "github.com/modelcontextprotocol/go-sdk/mcp" | ||
| "github.com/mongodb/openapi/tools/mcp-server/internal/registry" | ||
| ) | ||
|
|
||
| // OperationParam represents a single parameter (path, query, header, or cookie). | ||
| type OperationParam struct { | ||
| Name string `json:"name"` | ||
| In string `json:"in"` | ||
| Description string `json:"description,omitempty"` | ||
| Required bool `json:"required"` | ||
| } | ||
|
|
||
| // ContentEntry represents a single versioned content type present in a request body or response. | ||
| type ContentEntry struct { | ||
| ContentType string `json:"contentType"` | ||
| Version string `json:"version"` | ||
| Schema *openapi3.SchemaRef `json:"schema,omitempty"` | ||
| } | ||
|
|
||
| // OperationResponseDetail describes a single HTTP response with its versioned content types. | ||
| type OperationResponseDetail struct { | ||
| StatusCode string `json:"statusCode"` | ||
| Description string `json:"description,omitempty"` | ||
| Content []ContentEntry `json:"content"` | ||
| } | ||
|
|
||
| // RequestBodyDetail describes the request body with its versioned content types. | ||
| type RequestBodyDetail struct { | ||
| Required bool `json:"required"` | ||
| Content []ContentEntry `json:"content"` | ||
| } | ||
|
|
||
| // OperationDetail is the response body for the openapi://specs/{alias}/operations/{operationId} resource. | ||
| type OperationDetail struct { | ||
| OperationID string `json:"operationId"` | ||
| Method string `json:"method"` | ||
| Path string `json:"path"` | ||
| Summary string `json:"summary"` | ||
| Description string `json:"description,omitempty"` | ||
| LatestStableVersion string `json:"latestStableVersion"` | ||
| AvailableVersions []string `json:"availableVersions"` | ||
| HasPreview bool `json:"hasPreview"` | ||
| HasUpcoming bool `json:"hasUpcoming"` | ||
| Parameters []OperationParam `json:"parameters"` | ||
| RequestBody *RequestBodyDetail `json:"requestBody,omitempty"` | ||
| Responses []OperationResponseDetail `json:"responses"` | ||
| } | ||
|
|
||
| func handleOperation(reg *registry.Registry, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { | ||
| alias, operationID, versionFilter, err := aliasAndOperationFromURI(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) | ||
|
yelizhenden-mdb marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| method, path, op, err := findOperation(entry.Spec, operationID) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| detail, err := buildOperationDetail(op, method, path, versionFilter) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| data, err := json.Marshal(detail) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return &mcp.ReadResourceResult{ | ||
| Contents: []*mcp.ResourceContents{ | ||
| {URI: req.Params.URI, MIMEType: mimeTypeJSON, Text: string(data)}, | ||
| }, | ||
| }, nil | ||
| } | ||
|
|
||
| // findOperation searches all paths in the spec for an operation matching operationID. | ||
| // Returns the HTTP method (uppercase), path string, and operation pointer. | ||
| func findOperation(spec *openapi3.T, operationID string) (method, path string, op *openapi3.Operation, err error) { | ||
| if spec == nil || spec.Paths == nil { | ||
| return "", "", nil, fmt.Errorf("operation %q not found", operationID) | ||
| } | ||
|
|
||
| for p, item := range spec.Paths.Map() { | ||
| if item == nil { | ||
| continue | ||
| } | ||
| for m, o := range item.Operations() { | ||
| if o != nil && o.OperationID == operationID { | ||
| return m, p, o, nil | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return "", "", nil, fmt.Errorf("operation %q not found", operationID) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| package resources | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "sort" | ||
| "strings" | ||
|
|
||
| "github.com/getkin/kin-openapi/openapi3" | ||
| "github.com/mongodb/openapi/tools/cli/pkg/apiversion" | ||
| ) | ||
|
|
||
| // availableVersions classifies content types from the given content maps into stable versions, | ||
| // preview/upcoming flags, and a parsed version map reused by filterVersionedContent. | ||
| func availableVersions(contents ...openapi3.Content) ( | ||
| stable []string, hasPreview, hasUpcoming bool, parsedVersions map[string]*apiversion.APIVersion, | ||
| ) { | ||
| stable = []string{} | ||
| parsedVersions = make(map[string]*apiversion.APIVersion) | ||
| seen := make(map[string]bool) | ||
| for _, content := range contents { | ||
| for contentType, mediaType := range content { | ||
| version, err := apiversion.ParseVersionFromContentType(contentType, mediaType) | ||
| if err != nil { | ||
| continue | ||
| } | ||
| parsedVersions[contentType] = version | ||
| switch { | ||
| case version.IsPreview(): | ||
| hasPreview = true | ||
| case version.IsUpcoming(): | ||
| hasUpcoming = true | ||
| default: | ||
| versionStr := version.String() | ||
| if !seen[versionStr] { | ||
| seen[versionStr] = true | ||
| stable = append(stable, versionStr) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| sort.Strings(stable) | ||
| return | ||
| } | ||
|
|
||
| // filterVersionedContent filters a content map to entries matching versionFilter, sorted by content type. | ||
| func filterVersionedContent( | ||
| content openapi3.Content, parsedVersions map[string]*apiversion.APIVersion, versionFilter, targetVersion string, | ||
| ) []ContentEntry { | ||
| result := []ContentEntry{} | ||
| for contentType, mediaType := range content { | ||
| version, ok := parsedVersions[contentType] | ||
| if !ok { | ||
| continue | ||
| } | ||
| var matched bool | ||
| switch versionFilter { | ||
| case "all": | ||
| matched = true | ||
| case apiversion.PreviewStabilityLevel: | ||
| matched = version.IsPreview() | ||
| case apiversion.UpcomingStabilityLevel: | ||
| matched = version.IsUpcoming() | ||
| default: | ||
| matched = targetVersion == "" || version.String() == targetVersion | ||
| } | ||
| if matched { | ||
| result = append(result, ContentEntry{ContentType: contentType, Version: version.String(), Schema: mediaType.Schema}) | ||
| } | ||
| } | ||
| sort.Slice(result, func(i, j int) bool { return result[i].ContentType < result[j].ContentType }) | ||
| return result | ||
| } | ||
|
|
||
| // resolveTargetVersion resolves versionFilter to the exact date string used for content filtering. | ||
| // Returns an error if versionFilter is a specific date not present in stable. | ||
| func resolveTargetVersion(versionFilter string, stable []string, latestStable string) (string, error) { | ||
| switch versionFilter { | ||
| case "latest", "": | ||
| return latestStable, nil | ||
| case "all", apiversion.PreviewStabilityLevel, apiversion.UpcomingStabilityLevel: | ||
| return "", nil | ||
| default: | ||
| i := sort.SearchStrings(stable, versionFilter) | ||
| if i >= len(stable) || stable[i] != versionFilter { | ||
| return "", fmt.Errorf("version %q not found: available versions are [%s]", versionFilter, strings.Join(stable, ", ")) | ||
| } | ||
| return versionFilter, nil | ||
| } | ||
| } | ||
|
|
||
| func buildParameters(op *openapi3.Operation) []OperationParam { | ||
| params := []OperationParam{} | ||
| for _, pRef := range op.Parameters { | ||
| if pRef == nil || pRef.Value == nil { | ||
| continue | ||
| } | ||
| p := pRef.Value | ||
| params = append(params, OperationParam{ | ||
| Name: p.Name, | ||
| In: p.In, | ||
| Description: p.Description, | ||
| Required: p.Required, | ||
| }) | ||
| } | ||
| return params | ||
| } | ||
|
|
||
| // rawContentEntries returns content entries as-is, without version filtering. | ||
| // Used for non-200 responses that do not participate in versioning. | ||
| func rawContentEntries(content openapi3.Content) []ContentEntry { | ||
| result := make([]ContentEntry, 0, len(content)) | ||
| for contentType, mediaType := range content { | ||
| result = append(result, ContentEntry{ContentType: contentType, Schema: mediaType.Schema}) | ||
| } | ||
| sort.Slice(result, func(i, j int) bool { return result[i].ContentType < result[j].ContentType }) | ||
| return result | ||
| } | ||
|
|
||
| // buildResponses returns all responses sorted by status code. | ||
| // The 200 response is version-filtered; all others are included as-is. | ||
| func buildResponses( | ||
| op *openapi3.Operation, successContent openapi3.Content, | ||
| parsedVersions map[string]*apiversion.APIVersion, versionFilter, targetVersion string, | ||
| ) []OperationResponseDetail { | ||
| responses := []OperationResponseDetail{} | ||
| if op.Responses == nil { | ||
| return responses | ||
| } | ||
| codes := make([]string, 0, op.Responses.Len()) | ||
| for code := range op.Responses.Map() { | ||
| codes = append(codes, code) | ||
| } | ||
| sort.Strings(codes) | ||
| for _, code := range codes { | ||
| ref := op.Responses.Value(code) | ||
| if ref == nil || ref.Value == nil { | ||
| continue | ||
| } | ||
| desc := "" | ||
| if ref.Value.Description != nil { | ||
| desc = *ref.Value.Description | ||
| } | ||
| var content []ContentEntry | ||
| if code == "200" { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we only apply filters on 200?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually I assumed that the versioning only applies to Nice catch! I will be looking around to understand the situation |
||
| content = filterVersionedContent(successContent, parsedVersions, versionFilter, targetVersion) | ||
| } else { | ||
| content = rawContentEntries(ref.Value.Content) | ||
| } | ||
| responses = append(responses, OperationResponseDetail{StatusCode: code, Description: desc, Content: content}) | ||
| } | ||
| return responses | ||
| } | ||
|
|
||
| func buildOperationDetail(op *openapi3.Operation, method, path, versionFilter string) (OperationDetail, error) { | ||
| var successContent openapi3.Content | ||
| if op.Responses != nil { | ||
| if ref := op.Responses.Value("200"); ref != nil && ref.Value != nil { | ||
| successContent = ref.Value.Content | ||
| } | ||
| } | ||
| var rbContent openapi3.Content | ||
| if op.RequestBody != nil && op.RequestBody.Value != nil { | ||
| rbContent = op.RequestBody.Value.Content | ||
| } | ||
|
|
||
| stable, hasPreview, hasUpcoming, parsedVersions := availableVersions(successContent, rbContent) | ||
| latestStable := "" | ||
| if len(stable) > 0 { | ||
| latestStable = stable[len(stable)-1] | ||
| } | ||
|
|
||
| targetVersion, err := resolveTargetVersion(versionFilter, stable, latestStable) | ||
| if err != nil { | ||
| return OperationDetail{}, err | ||
| } | ||
|
|
||
| var reqBody *RequestBodyDetail | ||
| if rbContent != nil { | ||
| reqBody = &RequestBodyDetail{ | ||
| Required: op.RequestBody.Value.Required, | ||
| Content: filterVersionedContent(rbContent, parsedVersions, versionFilter, targetVersion), | ||
| } | ||
| } | ||
| return OperationDetail{ | ||
| OperationID: op.OperationID, | ||
| Method: method, | ||
| Path: path, | ||
| Summary: op.Summary, | ||
| Description: op.Description, | ||
| LatestStableVersion: latestStable, | ||
| AvailableVersions: stable, | ||
| HasPreview: hasPreview, | ||
| HasUpcoming: hasUpcoming, | ||
| Parameters: buildParameters(op), | ||
| RequestBody: reqBody, | ||
| Responses: buildResponses(op, successContent, parsedVersions, versionFilter, targetVersion), | ||
| }, nil | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.