Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
946 changes: 946 additions & 0 deletions scripts/endpoint-parity-output.json

Large diffs are not rendered by default.

242 changes: 242 additions & 0 deletions scripts/endpoint-parity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
#!/usr/bin/env node

/**
* Script to find endpoint parity between v1/v1.5 and v2 API versions.
*
* Parity mappings:
* - v1 (v1.0) endpoints map 1-to-1 to v2 endpoints for version 2023-01-01
* - v1.5 endpoints map 1-to-1 to v2 endpoints for version 2023-02-01
*/

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// File paths
const V1_SPEC_PATH = path.join(__dirname, '../openapi/v1-deprecated/v1.json');
const V2_SPEC_PATH = path.join(__dirname, '../openapi/.raw/v2.json');
const V2_2023_01_01_PATH = path.join(__dirname, '../openapi/v2/openapi-2023-01-01.json');
const V2_2023_02_01_PATH = path.join(__dirname, '../openapi/v2/openapi-2023-02-01.json');

/**
* Extract endpoints from an OpenAPI spec
* @param {object} spec - The OpenAPI specification object
* @param {string} versionFilter - Optional filter for path version (e.g., 'v1.0', 'v1.5', 'v2')
* @param {boolean} onlyWithSunset - If true, only include endpoints with x-sunset set
* @returns {Map<string, object>} - Map of normalized path -> endpoint details
*/
function extractEndpoints(spec, versionFilter = null, onlyWithSunset = false) {
const endpoints = new Map();

if (!spec.paths) {
return endpoints;
}

for (const [pathKey, pathItem] of Object.entries(spec.paths)) {
// Apply version filter if specified
if (versionFilter) {
const versionPattern = `/api/atlas/${versionFilter}`;
if (!pathKey.startsWith(versionPattern)) {
continue;
}
}

const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];

for (const method of methods) {
if (pathItem[method]) {
const operation = pathItem[method];
const sunset = operation['x-sunset'] || null;

// Skip if we only want sunset endpoints and this one doesn't have sunset
if (onlyWithSunset && !sunset) {
continue;
}

const key = `${method.toUpperCase()} ${pathKey}`;
endpoints.set(key, {
path: pathKey,
method: method.toUpperCase(),
operationId: operation.operationId || 'N/A',
sunset: sunset,
deprecated: operation.deprecated || false
});
}
}
}

return endpoints;
}

/**
* Find the owning team for a given path and method
* @param {string} pathStr - The API path
* @param {string} method - The HTTP method
* @param {object} spec - The OpenAPI specification object
* @returns {string|null} - The owning team or null if not found
*/
function findTeam(pathStr, method, spec) {
const pathItem = spec.paths[pathStr];
if (!pathItem) return null;
const operation = pathItem[method];
if (!operation) return null;
return operation['x-xgen-owner-team'] || null;
}

/**
* Normalize a path by removing the version prefix
* @param {string} pathStr - The API path
* @returns {string} - The normalized path without version prefix
*/
function normalizePath(pathStr) {
// Remove /api/atlas/v1.0/, /api/atlas/v1.5/, or /api/atlas/v2/ prefix
return pathStr.replace(/^\/api\/atlas\/v[12](\.[05])?/, '');
}

/**
* Get versioned path
* @param {string} pathStr - The API path
* @param {string} version - The version to use
* @returns {string} - The versioned path
*/
function getVersionedPath(pathStr, version) {
return `/api/atlas/${version}${normalizePath(pathStr)}`;
}

/**
* Find pairings between two sets of endpoints
* @param {Map} sourceEndpoints - Source endpoints (v1 or v1.5)
* @param {Map} targetEndpoints - Target endpoints (v2)
* @param {string} sourceVersion - Source version label
* @param {string} targetVersion - Target version label
* @returns {Array} - Array of pairing objects
*/
function findPairings(sourceEndpoints, targetEndpoints, sourceVersion, targetVersion) {
const pairings = [];

for (const [, sourceEndpoint] of sourceEndpoints) {
const normalizedSourcePath = normalizePath(sourceEndpoint.path);

// Find matching v2 endpoint
for (const [, targetEndpoint] of targetEndpoints) {
const normalizedTargetPath = normalizePath(targetEndpoint.path);

if (normalizedSourcePath === normalizedTargetPath &&
sourceEndpoint.method === targetEndpoint.method) {
pairings.push({
sourceVersion,
targetVersion,
method: sourceEndpoint.method,
sourcePath: sourceEndpoint.path,
targetPath: targetEndpoint.path,
sourceOperationId: sourceEndpoint.operationId,
targetOperationId: targetEndpoint.operationId,
sunset: targetEndpoint.sunset,
deprecated: targetEndpoint.deprecated
});
break;
}
}
}

return pairings;
}

function aggregateByTeam(pairings, spec) {
const teamAggregation = {};

for (const pairing of pairings) {
const team = findTeam(getVersionedPath(pairing.targetPath, 'v2'), pairing.method.toLowerCase(), spec) || 'Unknown';

if (!teamAggregation[team]) {
teamAggregation[team] = { count: 0, endpoints: [] };
}

teamAggregation[team].count += 1;
teamAggregation[team].endpoints.push(pairing);
}

return teamAggregation;
}

// Minimum sunset date filter - only show endpoints with sunset >= this date
const MIN_SUNSET_DATE = '2026-01-01';

/**
* Filter pairings to only include those with sunset >= minDate
*/
function filterBySunsetDate(pairings, minDate) {
return pairings.filter(p => p.sunset && p.sunset >= minDate);
}

/**
* Main execution
*/
function main() {
console.log('='.repeat(80));
console.log('Endpoint Parity Analysis: v1/v1.5 to v2 Mappings (with Sunset)');
console.log('='.repeat(80));
console.log();
console.log(`NOTE: Only showing v2 endpoints with x-sunset >= ${MIN_SUNSET_DATE}`);
console.log();

const v1Spec = JSON.parse(fs.readFileSync(V1_SPEC_PATH, 'utf8'));
const v2Spec = JSON.parse(fs.readFileSync(V2_SPEC_PATH, 'utf8'));
const v2_2023_01_01_Spec = JSON.parse(fs.readFileSync(V2_2023_01_01_PATH, 'utf8'));
const v2_2023_02_01_Spec = JSON.parse(fs.readFileSync(V2_2023_02_01_PATH, 'utf8'));

// Extract endpoints - v1.0 and v1.5 are both in v1.json
const v1_0_Endpoints = extractEndpoints(v1Spec, 'v1.0');
const v1_5_Endpoints = extractEndpoints(v1Spec, 'v1.5');

// Extract ALL v2 endpoints for reference counts
const v2_2023_01_01_AllEndpoints = extractEndpoints(v2_2023_01_01_Spec, 'v2', false);
const v2_2023_02_01_AllEndpoints = extractEndpoints(v2_2023_02_01_Spec, 'v2', false);

// Extract only v2 endpoints with sunset
const v2_2023_01_01_SunsetEndpoints = extractEndpoints(v2_2023_01_01_Spec, 'v2', true);
const v2_2023_02_01_SunsetEndpoints = extractEndpoints(v2_2023_02_01_Spec, 'v2', true);

const v1_0_AllPairings = findPairings(v1_0_Endpoints, v2_2023_01_01_SunsetEndpoints, 'v1.0', '2023-01-01');
const v1_0_Pairings = filterBySunsetDate(v1_0_AllPairings, MIN_SUNSET_DATE);
console.log(`Found ${v1_0_Pairings.length} paired endpoints with sunset >= ${MIN_SUNSET_DATE}`);

const v1_5_AllPairings = findPairings(v1_5_Endpoints, v2_2023_02_01_SunsetEndpoints, 'v1.5', '2023-02-01');
const v1_5_Pairings = filterBySunsetDate(v1_5_AllPairings, MIN_SUNSET_DATE);
console.log(`Found ${v1_5_Pairings.length} paired endpoints with sunset >= ${MIN_SUNSET_DATE}`);

const v1_0_TeamAggregation = aggregateByTeam(v1_0_Pairings, v2Spec);
const v1_5_TeamAggregation = aggregateByTeam(v1_5_Pairings, v2Spec);

const teams = new Set([
...Object.keys(v1_0_TeamAggregation),
...Object.keys(v1_5_TeamAggregation)
]);

// Output JSON
const output = {
"v1.0" : v1_0_TeamAggregation,
"v1.5" : v1_5_TeamAggregation,
summary: {
"v1.0 count": v1_0_Endpoints.size,
"v1.5 count": v1_5_Endpoints.size,
"v2 2023-01-01 count": v2_2023_01_01_AllEndpoints.size,
"v2 2023-01-01 endpoints with sunset": v2_2023_01_01_SunsetEndpoints.size,
"v2 2023-02-01 count": v2_2023_02_01_AllEndpoints.size,
"v2 2023-02-01 endpoints with sunset": v2_2023_02_01_SunsetEndpoints.size,
"v1.0 pairings with sunset": v1_0_Pairings.length,
"v1.5 pairings with sunset": v1_5_Pairings.length,
teams: Array.from(teams)
}
};

const outputPath = path.join(__dirname, 'endpoint-parity-output.json');
fs.writeFileSync(outputPath, JSON.stringify(output, null, 2));
console.log(`\nJSON output written to: ${outputPath}`);
}

main();

13 changes: 12 additions & 1 deletion tools/cli/Makefile
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
# A Self-Documenting Makefile: http://marmelab.com/blog/2016/02/29/auto-documented-makefile.html

GOLANGCI_VERSION=v2.1.0
SOURCE_FILES?=./cmd
SOURCE_FILES?=./cmd/foascli.go
BINARY_NAME=foascli
MCP_SOURCE_FILES?=./cmd/mcp
MCP_BINARY_NAME=openapi-mcp
MCP_DESTINATION=./bin/$(MCP_BINARY_NAME)
VERSION=v0.0.1
GIT_SHA?=$(shell git rev-parse HEAD)
DESTINATION=./bin/$(BINARY_NAME)
Expand Down Expand Up @@ -56,6 +59,14 @@ build-debug:
@echo "==> Building foascli binary for debugging"
go build -gcflags="$(DEBUG_FLAGS)" -ldflags "$(LINKER_FLAGS)" -o $(DESTINATION) $(SOURCE_FILES)

.PHONY: build-mcp
build-mcp: ## Build the OpenAPI MCP server binary
@echo "==> Building openapi-mcp binary"
go build -ldflags "$(LINKER_FLAGS)" -o $(MCP_DESTINATION) $(MCP_SOURCE_FILES)

.PHONY: build-all
build-all: build build-mcp ## Build all binaries

.PHONY: lint
lint: ## Run linter
golangci-lint run
Expand Down
82 changes: 82 additions & 0 deletions tools/cli/cmd/mcp/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright 2025 MongoDB Inc
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package main provides the entry point for the OpenAPI Filter MCP Server.
package main

import (
"context"
"log"
"os"
"os/signal"
"syscall"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mongodb/openapi/tools/cli/internal/mcp/registry"
"github.com/mongodb/openapi/tools/cli/internal/mcp/resources"
"github.com/mongodb/openapi/tools/cli/internal/mcp/tools"
"github.com/mongodb/openapi/tools/cli/internal/version"
)

const (
serverName = "openapi-filter-mcp"
)

func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Handle graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
log.Println("Shutting down MCP server...")
cancel()
}()

// Create the spec registry
reg := registry.New()

// Create the MCP server
server := mcp.NewServer(
&mcp.Implementation{
Name: serverName,
Version: version.Version,
},
&mcp.ServerOptions{
Capabilities: &mcp.ServerCapabilities{
Tools: &mcp.ToolCapabilities{
ListChanged: true,
},
Resources: &mcp.ResourceCapabilities{
ListChanged: true,
},
},
},
)

// Register tools
tools.RegisterTools(server, reg)

// Register resources
resources.RegisterResources(server, reg)

log.Printf("Starting %s v%s", serverName, version.Version)

// Run the server over stdio
if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil {
log.Fatalf("Server error: %v", err)

Check failure on line 80 in tools/cli/cmd/mcp/main.go

View workflow job for this annotation

GitHub Actions / lint

exitAfterDefer: log.Fatalf will exit, and `defer cancel()` will not run (gocritic)
}
}
4 changes: 4 additions & 0 deletions tools/cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ toolchain go1.24.0
require (
github.com/getkin/kin-openapi v0.132.0
github.com/iancoleman/strcase v0.3.0
github.com/modelcontextprotocol/go-sdk v1.2.0
github.com/oasdiff/oasdiff v1.11.4
github.com/spf13/afero v1.14.0
github.com/spf13/cobra v1.9.1
Expand All @@ -22,6 +23,7 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
Expand All @@ -37,6 +39,8 @@ require (
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wI2L/jsondiff v0.6.1 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.7.11 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
)
Loading
Loading