Compare commits

..

3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
d943231672 Implement BYO OpenAPIV3.Document support in getToolsFromOpenApi
Co-authored-by: harsha-iiiv <31560965+harsha-iiiv@users.noreply.github.com>
2025-08-18 04:33:51 +00:00
copilot-swe-agent[bot]
99d3ba15d2 Initial plan for BYO OpenAPIV3.Document support
Co-authored-by: harsha-iiiv <31560965+harsha-iiiv@users.noreply.github.com>
2025-08-18 04:29:37 +00:00
copilot-swe-agent[bot]
8949a0290c Initial plan 2025-08-18 04:26:56 +00:00
12 changed files with 200 additions and 483 deletions

View File

@ -1,44 +0,0 @@
# Add optional MCPcat analytics scaffolding (`--with-mcpcat`) to generated MCP servers
> **TL;DR**: This PR adds an **opt-in** flag to scaffold privacy-safe analytics wiring for MCPcat in projects generated by `openapi-mcp-generator`.
## Summary
This PR introduces a `--with-mcpcat` CLI flag that scaffolds:
- A tiny analytics shim to emit initialize/tool-call events.
- A default **local redaction** helper to scrub sensitive data before export.
- Minimal config via environment variables.
No behavior changes unless the flag and env vars are set.
## Motivation
- Make freshly generated MCP servers **observable in minutes**.
- Encourage **privacy-by-default** analytics patterns.
- Reduce copy/paste wiring; standardize event shape (operationId, path, duration, status).
## Changes
### CLI
- `generate` accepts `--with-mcpcat` (default: off).
### Template files (added conditionally)
- `src/analytics/mcpcat.ts` lazy import + safe no-op if SDK absent.
- `src/analytics/redact.ts` OpenAPI-aware heuristics (e.g., `*token*`, `password`, `apiKey`, `authorization`, `email`).
- `src/analytics/config.ts` reads env:
- `MCPCAT_ENABLED=true|false` (default `false`)
- `MCPCAT_PROJECT_ID=<id>`
- `MCPCAT_ENDPOINT=<optional override>`
- `MCPCAT_SAMPLE_RATE=1.0` (01)
### Server wiring
- Hooks server `.initialize` and each tool invocation to record:
- `operationId`, HTTP `method`, `path`
- redacted `args`
- `outcome` (`ok`/`error`) + truncated error message
- `duration_ms`
### Docs
- Adds a “Enable analytics (MCPcat)” section to generated README with privacy notes and quickstart.
## Implementation Notes
- **Compile-time optional**: no imports unless flag is used.
- **Runtime safe**: try/catch around SDK import → graceful no-op if not installed.
- **Transport-agnostic**: compatible with stdio, SSE/web, and StreamableHTTP templates.
- **Edge-friendly**: avoids Node-only APIs in scaffolding to support edge runtimes (e.g., Workers).

View File

@ -5,41 +5,6 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.2.0] - 2025-08-24
### Added
- Endpoint filtering using `x-mcp` OpenAPI extension to control which operations are exposed as MCP tools
- CLI option `--default-include` to change default behavior for endpoint inclusion
- Precedence rules for `x-mcp` extension (operation > path > root level)
- Enhanced programmatic API with `defaultInclude` option in `getToolsFromOpenApi`
### Changed
- Improved documentation with examples for endpoint filtering and OpenAPI extensions.
- Version bump to next minor release
- Updated package version to reflect accumulated features and improvements
## [3.1.4] - 2025-06-18
### Chores
- Updated the application version to 3.1.4 and ensured the CLI displays the version dynamically.
### Style
- Improved code formatting for better readability.
### Bug Fixes
- Tool names now retain their original casing during extraction.
## [3.1.3] - 2025-06-12
### Fixed
- Cannot find the package after building and the problem during the building.
## [3.1.2] - 2025-06-08 ## [3.1.2] - 2025-06-08
### Fixed ### Fixed

View File

@ -22,7 +22,7 @@ This function extracts an array of tools from an OpenAPI specification.
**Parameters:** **Parameters:**
- `specPathOrUrl`: Path to a local OpenAPI spec file or URL to a remote spec - `specPathOrUrl`: Path to a local OpenAPI spec file, URL to a remote spec, or a pre-parsed OpenAPIV3.Document
- `options`: (Optional) Configuration options - `options`: (Optional) Configuration options
**Options:** **Options:**
@ -62,18 +62,15 @@ for (const tool of filteredTools) {
console.log(` Method: ${tool.method.toUpperCase()} ${tool.pathTemplate}`); console.log(` Method: ${tool.method.toUpperCase()} ${tool.pathTemplate}`);
console.log(` OperationId: ${tool.operationId}`); console.log(` OperationId: ${tool.operationId}`);
} }
```
you can also provide a `OpenAPIV3.Document` to the parser: // Using a pre-parsed OpenAPI document (helpful when @apidevtools/swagger-parser fails)
import { parse } from '@readme/openapi-parser';
import { OpenAPIV3 } from 'openapi-types';
```typescript const api = await parse<OpenAPIV3.Document>(
import { parser } from '@readme/openapi-parser'; 'https://problematic-api-spec.com/openapi.json',
{ dereference: { circular: true } }
const api = await parser('https://petstore3.swagger.io/api/v3/openapi.json', { );
dereference: {
circular: true,
},
});
const tools = await getToolsFromOpenApi(api); const tools = await getToolsFromOpenApi(api);
``` ```

View File

@ -49,7 +49,7 @@ openapi-mcp-generator --input path/to/openapi.json --output path/to/output/dir -
### CLI Options ### CLI Options
| Option | Alias | Description | Default | | Option | Alias | Description | Default |
| ------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | | ------------------ | ----- | ------------------------------------------------------------------------------ | --------------------------------- |
| `--input` | `-i` | Path or URL to OpenAPI specification (YAML or JSON) | **Required** | | `--input` | `-i` | Path or URL to OpenAPI specification (YAML or JSON) | **Required** |
| `--output` | `-o` | Directory to output the generated MCP project | **Required** | | `--output` | `-o` | Directory to output the generated MCP project | **Required** |
| `--server-name` | `-n` | Name of the MCP server (`package.json:name`) | OpenAPI title or `mcp-api-server` | | `--server-name` | `-n` | Name of the MCP server (`package.json:name`) | OpenAPI title or `mcp-api-server` |
@ -57,7 +57,6 @@ openapi-mcp-generator --input path/to/openapi.json --output path/to/output/dir -
| `--base-url` | `-b` | Base URL for API requests. Required if OpenAPI `servers` missing or ambiguous. | Auto-detected if possible | | `--base-url` | `-b` | Base URL for API requests. Required if OpenAPI `servers` missing or ambiguous. | Auto-detected if possible |
| `--transport` | `-t` | Transport mode: `"stdio"` (default), `"web"`, or `"streamable-http"` | `"stdio"` | | `--transport` | `-t` | Transport mode: `"stdio"` (default), `"web"`, or `"streamable-http"` | `"stdio"` |
| `--port` | `-p` | Port for web-based transports | `3000` | | `--port` | `-p` | Port for web-based transports | `3000` |
| `--default-include` | | Default behavior for x-mcp filtering. Accepts `true` or `false` (case-insensitive). `true` = include by default, `false` = exclude by default. | `true` |
| `--force` | | Overwrite existing files in the output directory without confirmation | `false` | | `--force` | | Overwrite existing files in the output directory without confirmation | `false` |
## 📦 Programmatic API ## 📦 Programmatic API
@ -168,38 +167,6 @@ Configure auth credentials in your environment:
--- ---
## 🔎 Filtering Endpoints with OpenAPI Extensions
You can control which operations are exposed as MCP tools using a vendor extension flag `x-mcp`. This extension is supported at the root, path, and operation levels. By default, endpoints are included unless explicitly excluded.
- Extension: `x-mcp: true | false`
- Default: `true` (include by default)
- Precedence: operation > path > root (first non-undefined wins)
- CLI option: `--default-include false` to change default to exclude by default
Examples:
```yaml
# Optional root-level default
x-mcp: true
paths:
/pets:
x-mcp: false # exclude all ops under /pets
get:
x-mcp: true # include this operation anyway
/users/{id}:
get:
# no x-mcp -> included by default
```
This uses standard OpenAPI extensions (x-… fields). See the [OpenAPI Extensions guide](https://swagger.io/docs/specification/v3_0/openapi-extensions/) for details.
Note: `x-mcp` must be a boolean or the strings `"true"`/`"false"` (case-insensitive). Other values are ignored in favor of higher-precedence or default behavior.
---
## ▶️ Running the Generated Server ## ▶️ Running the Generated Server
```bash ```bash

351
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "openapi-mcp-generator", "name": "openapi-mcp-generator",
"version": "3.2.0", "version": "3.1.4",
"description": "Generates MCP server code from OpenAPI specifications", "description": "Generates MCP server code from OpenAPI specifications",
"license": "MIT", "license": "MIT",
"author": "Harsha", "author": "Harsha",
@ -25,7 +25,7 @@
"format.check": "prettier --check .", "format.check": "prettier --check .",
"format.write": "prettier --write .", "format.write": "prettier --write .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "tsc && chmod 755 dist/index.js bin/openapi-mcp-generator.js", "build": "tsc && chmod 755 dist/index.js && chmod 755 bin/openapi-mcp-generator.js",
"version:patch": "npm version patch", "version:patch": "npm version patch",
"version:minor": "npm version minor", "version:minor": "npm version minor",
"version:major": "npm version major" "version:major": "npm version major"
@ -53,16 +53,16 @@
"openapi-types": "^12.1.3" "openapi-types": "^12.1.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.17.2", "@types/node": "^22.15.2",
"@typescript-eslint/eslint-plugin": "^8.39.1", "@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.39.1", "@typescript-eslint/parser": "^8.31.0",
"eslint": "^9.33.0", "eslint": "^9.25.1",
"prettier": "^3.6.2", "prettier": "^3.5.3",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"typescript": "^5.9.2" "typescript": "^5.8.3"
}, },
"peerDependencies": { "peerDependencies": {
"@modelcontextprotocol/sdk": "^1.10.2", "@modelcontextprotocol/sdk": "^1.10.0",
"json-schema-to-zod": "^2.6.1", "json-schema-to-zod": "^2.6.1",
"zod": "^3.24.3" "zod": "^3.24.3"
} }

View File

@ -9,6 +9,16 @@ import { extractToolsFromApi } from './parser/extract-tools.js';
import { McpToolDefinition } from './types/index.js'; import { McpToolDefinition } from './types/index.js';
import { determineBaseUrl } from './utils/url.js'; import { determineBaseUrl } from './utils/url.js';
/**
* Type guard to check if the input is an OpenAPI document
*
* @param spec Input that could be a string or OpenAPIV3.Document
* @returns True if input is an OpenAPIV3.Document, false otherwise
*/
function isOpenApiDocument(spec: string | OpenAPIV3.Document): spec is OpenAPIV3.Document {
return typeof spec === 'object' && spec !== null && 'openapi' in spec;
}
/** /**
* Options for generating the MCP tools * Options for generating the MCP tools
*/ */
@ -24,19 +34,12 @@ export interface GetToolsOptions {
/** Optional filter function to exclude tools based on custom criteria */ /** Optional filter function to exclude tools based on custom criteria */
filterFn?: (tool: McpToolDefinition) => boolean; filterFn?: (tool: McpToolDefinition) => boolean;
/** Default behavior for x-mcp filtering (default: true = include by default) */
defaultInclude?: boolean;
}
function isOpenApiDocument(spec: string | OpenAPIV3.Document): spec is OpenAPIV3.Document {
return typeof spec === 'object' && spec !== null && 'openapi' in spec;
} }
/** /**
* Get a list of tools from an OpenAPI specification * Get a list of tools from an OpenAPI specification
* *
* @param specPathOrUrl Path or URL to the OpenAPI specification * @param specPathOrUrl Path or URL to the OpenAPI specification, or a pre-parsed OpenAPI document
* @param options Options for generating the tools * @param options Options for generating the tools
* @returns Promise that resolves to an array of tool definitions * @returns Promise that resolves to an array of tool definitions
*/ */
@ -45,15 +48,26 @@ export async function getToolsFromOpenApi(
options: GetToolsOptions = {} options: GetToolsOptions = {}
): Promise<McpToolDefinition[]> { ): Promise<McpToolDefinition[]> {
try { try {
// Parse the OpenAPI spec // Parse the OpenAPI spec or use the provided document
const api = isOpenApiDocument(specPathOrUrl) let api: OpenAPIV3.Document;
? specPathOrUrl
: options.dereference if (isOpenApiDocument(specPathOrUrl)) {
// Input is already a parsed OpenAPI document
api = specPathOrUrl;
// If dereference option is requested, apply it to the document
if (options.dereference) {
api = (await SwaggerParser.dereference(api)) as OpenAPIV3.Document;
}
} else {
// Input is a string path or URL, parse it
api = options.dereference
? ((await SwaggerParser.dereference(specPathOrUrl)) as OpenAPIV3.Document) ? ((await SwaggerParser.dereference(specPathOrUrl)) as OpenAPIV3.Document)
: ((await SwaggerParser.parse(specPathOrUrl)) as OpenAPIV3.Document); : ((await SwaggerParser.parse(specPathOrUrl)) as OpenAPIV3.Document);
}
// Extract tools from the API // Extract tools from the API
const allTools = extractToolsFromApi(api, options.defaultInclude ?? true); const allTools = extractToolsFromApi(api);
// Add base URL to each tool // Add base URL to each tool
const baseUrl = determineBaseUrl(api, options.baseUrl); const baseUrl = determineBaseUrl(api, options.baseUrl);
@ -80,8 +94,7 @@ export async function getToolsFromOpenApi(
} catch (error) { } catch (error) {
// Provide more context for the error // Provide more context for the error
if (error instanceof Error) { if (error instanceof Error) {
// Preserve original stack/context throw new Error(`Failed to extract tools from OpenAPI: ${error.message}`);
throw new Error(`Failed to extract tools from OpenAPI: ${error.message}`, { cause: error });
} }
throw error; throw error;
} }

View File

@ -25,7 +25,7 @@ export function generateMcpServerCode(
serverVersion: string serverVersion: string
): string { ): string {
// Extract tools from API // Extract tools from API
const tools = extractToolsFromApi(api, options.defaultInclude ?? true); const tools = extractToolsFromApi(api);
// Determine base URL // Determine base URL
const determinedBaseUrl = determineBaseUrl(api, options.baseUrl); const determinedBaseUrl = determineBaseUrl(api, options.baseUrl);

View File

@ -30,7 +30,6 @@ import {
// Import types // Import types
import { CliOptions, TransportType } from './types/index.js'; import { CliOptions, TransportType } from './types/index.js';
import { normalizeBoolean } from './utils/helpers.js';
import pkg from '../package.json' with { type: 'json' }; import pkg from '../package.json' with { type: 'json' };
// Export programmatic API // Export programmatic API
@ -73,19 +72,6 @@ program
'Port for web or streamable-http transport (default: 3000)', 'Port for web or streamable-http transport (default: 3000)',
(val) => parseInt(val, 10) (val) => parseInt(val, 10)
) )
.option(
'--default-include <boolean>',
'Default behavior for x-mcp filtering (true|false, case-insensitive). Default: true (include by default), false = exclude by default',
(val) => {
const parsed = normalizeBoolean(val);
if (typeof parsed === 'boolean') return parsed;
console.warn(
`Invalid value for --default-include: "${val}". Expected true/false (case-insensitive). Using default: true.`
);
return true;
},
true
)
.option('--force', 'Overwrite existing files without prompting') .option('--force', 'Overwrite existing files without prompting')
.version(pkg.version) // Match package.json version .version(pkg.version) // Match package.json version
.action((options) => { .action((options) => {

View File

@ -5,7 +5,6 @@ import { OpenAPIV3 } from 'openapi-types';
import type { JSONSchema7, JSONSchema7TypeName } from 'json-schema'; import type { JSONSchema7, JSONSchema7TypeName } from 'json-schema';
import { generateOperationId } from '../utils/code-gen.js'; import { generateOperationId } from '../utils/code-gen.js';
import { McpToolDefinition } from '../types/index.js'; import { McpToolDefinition } from '../types/index.js';
import { shouldIncludeOperationForMcp } from '../utils/helpers.js';
/** /**
* Extracts tool definitions from an OpenAPI document * Extracts tool definitions from an OpenAPI document
@ -13,10 +12,7 @@ import { shouldIncludeOperationForMcp } from '../utils/helpers.js';
* @param api OpenAPI document * @param api OpenAPI document
* @returns Array of MCP tool definitions * @returns Array of MCP tool definitions
*/ */
export function extractToolsFromApi( export function extractToolsFromApi(api: OpenAPIV3.Document): McpToolDefinition[] {
api: OpenAPIV3.Document,
defaultInclude: boolean = true
): McpToolDefinition[] {
const tools: McpToolDefinition[] = []; const tools: McpToolDefinition[] = [];
const usedNames = new Set<string>(); const usedNames = new Set<string>();
const globalSecurity = api.security || []; const globalSecurity = api.security || [];
@ -30,37 +26,6 @@ export function extractToolsFromApi(
const operation = pathItem[method]; const operation = pathItem[method];
if (!operation) continue; if (!operation) continue;
// Apply x-mcp filtering, precedence: operation > path > root
try {
if (
!shouldIncludeOperationForMcp(
api,
pathItem as OpenAPIV3.PathItemObject,
operation,
defaultInclude
)
) {
continue;
}
} catch (error) {
const loc = operation.operationId || `${method} ${path}`;
const extVal =
(operation as any)['x-mcp'] ?? (pathItem as any)['x-mcp'] ?? (api as any)['x-mcp'];
let extPreview: string;
try {
extPreview = JSON.stringify(extVal);
} catch {
extPreview = String(extVal);
}
console.warn(
`Error evaluating x-mcp extension for operation ${loc} (x-mcp=${extPreview}):`,
error
);
if (!defaultInclude) {
continue;
}
}
// Generate a unique name for the tool // Generate a unique name for the tool
let baseName = operation.operationId || generateOperationId(method, path); let baseName = operation.operationId || generateOperationId(method, path);
if (!baseName) continue; if (!baseName) continue;

View File

@ -29,12 +29,6 @@ export interface CliOptions {
transport?: TransportType; transport?: TransportType;
/** Server port (for web and streamable-http transports) */ /** Server port (for web and streamable-http transports) */
port?: number; port?: number;
/**
* Default behavior for x-mcp filtering.
* true (default) = include by default when x-mcp is missing or invalid;
* false = exclude by default unless x-mcp explicitly enables.
*/
defaultInclude?: boolean;
} }
/** /**

View File

@ -1,7 +1,6 @@
/** /**
* General helper utilities for OpenAPI to MCP generator * General helper utilities for OpenAPI to MCP generator
*/ */
import { OpenAPIV3 } from 'openapi-types';
/** /**
* Safely stringify a JSON object with proper error handling * Safely stringify a JSON object with proper error handling
@ -111,63 +110,3 @@ export function formatComment(str: string, maxLineLength: number = 80): string {
return lines.join('\n * '); return lines.join('\n * ');
} }
/**
* Normalize a value to boolean if it looks like a boolean; otherwise undefined.
*/
export function normalizeBoolean(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
return undefined;
}
return undefined;
}
/**
* Determine if an operation should be included in MCP generation based on x-mcp.
* Precedence: operation > path > root; uses provided default when all undefined.
*/
export function shouldIncludeOperationForMcp(
api: OpenAPIV3.Document,
pathItem: OpenAPIV3.PathItemObject,
operation: OpenAPIV3.OperationObject,
defaultInclude: boolean = true
): boolean {
const opRaw = (operation as any)['x-mcp'];
const opVal = normalizeBoolean(opRaw);
if (typeof opVal !== 'undefined') return opVal;
if (typeof opRaw !== 'undefined') {
console.warn(
`Invalid x-mcp value on operation '${operation.operationId ?? '[no operationId]'}':`,
opRaw,
`-> expected boolean or 'true'/'false'. Falling back to path/root/default.`
);
}
const pathRaw = (pathItem as any)['x-mcp'];
const pathVal = normalizeBoolean(pathRaw);
if (typeof pathVal !== 'undefined') return pathVal;
if (typeof pathRaw !== 'undefined') {
console.warn(
`Invalid x-mcp value on path item:`,
pathRaw,
`-> expected boolean or 'true'/'false'. Falling back to root/default.`
);
}
const rootRaw = (api as any)['x-mcp'];
const rootVal = normalizeBoolean(rootRaw);
if (typeof rootVal !== 'undefined') return rootVal;
if (typeof rootRaw !== 'undefined') {
console.warn(
`Invalid x-mcp value at API root:`,
rootRaw,
`-> expected boolean or 'true'/'false'. Falling back to defaultInclude=${defaultInclude}.`
);
}
return defaultInclude;
}