Compare commits

...

34 Commits

Author SHA1 Message Date
Harsha v
8ee9fc383d
Create mcpcat.md 2025-09-30 12:15:47 -07:00
Harsha v
f29c277860
Merge pull request #39 from harsha-iiiv/copilot/fix-33b9d782-1c7b-44d1-89a9-adbd77a32aa8
Upgrade package to version 3.2.0 and address code quality improvements based on review feedback
2025-08-24 22:03:57 -07:00
copilot-swe-agent[bot]
33220c1e82 Address coderabbitai review feedback: improve docs, error handling, and boolean normalization
Co-authored-by: harsha-iiiv <31560965+harsha-iiiv@users.noreply.github.com>
2025-08-25 04:59:26 +00:00
copilot-swe-agent[bot]
eda4505a63 Update CHANGELOG.md with corrected version details and proper categorization
Co-authored-by: harsha-iiiv <31560965+harsha-iiiv@users.noreply.github.com>
2025-08-25 04:41:52 +00:00
copilot-swe-agent[bot]
4bf66d9efd Upgrade package version to 3.2.0 and update CHANGELOG.md
Co-authored-by: harsha-iiiv <31560965+harsha-iiiv@users.noreply.github.com>
2025-08-24 23:14:41 +00:00
copilot-swe-agent[bot]
7a31e1f6e9 Initial plan 2025-08-24 23:09:33 +00:00
Harsha v
82ff2b726d
Merge pull request #37 from atomicpages/main
feat: allow folks to BYO OpenAPIV3.Document harsha-iiiv/openapi-mcp-generator#35
2025-08-24 16:04:56 -07:00
Harsha v
1c806b8dab
Merge pull request #38 from FabriBorgobello/feature/endpoint-filtering
feature: Add endpoint filtering
2025-08-24 16:04:01 -07:00
Fabricio Borgobello
c9015f395e
Update src/utils/helpers.ts
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-08-22 11:58:27 +02:00
Fabricio Borgobello
af1b664653
Adjust boolean validation 2025-08-22 11:57:58 +02:00
Fabricio Borgobello
1f001bb47a
Remove comment 2025-08-22 11:04:08 +02:00
Fabricio Borgobello
b1e29c22de
feature/endpoint-filtering 2025-08-22 11:00:13 +02:00
Dennis Thompson
e6352d13b6 chore: fix clerical error on docs 2025-08-17 21:48:46 -07:00
Dennis Thompson
26307f26ad
Update src/api.ts
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-08-17 21:48:06 -07:00
Dennis Thompson
b7bc67e444 fix: addresses harsha-iiiv/openapi-mcp-generator#35 2025-08-17 21:26:27 -07:00
Harsha v
47292c89cf
Merge pull request #30 from oneWalker/dev
feat: style and ci optimization
2025-06-19 22:00:22 +05:30
Brian,Kun Liu
2cab8aeada style: format the code with the prettier config 2025-06-19 11:31:27 +08:00
Brian,Kun Liu
ea89e0498e ci: add build and check on PR before merged 2025-06-19 11:31:07 +08:00
Brian,Kun Liu
34a1f6df12 perf: keep code with the same style and format command 2025-06-19 11:28:24 +08:00
Harsha v
e17ab1a3d0
Merge pull request #29 from oneWalker/dev
feat: remove the lowercase transfer to make toolName readable and versionNo from package.json
2025-06-18 15:30:57 +05:30
Brian,Kun Liu
0ca1310c56 perf: remove the lowercase transfer to make toolName readable 2025-06-16 16:12:00 +08:00
Brian,Kun Liu
aa388035ad fix: use the versionNo in package.json 2025-06-16 16:11:10 +08:00
Harsha v
c98d652933
Merge pull request #27 from oneWalker/dev
fix: cannot find the package after building and the problem during th…
2025-06-15 00:19:04 +05:30
Brian,Kun Liu
9c6fed9beb
Update the version serial No 2025-06-12 19:17:09 +08:00
Brian,Kun Liu
67e06c3c34
fix: cannot find the package after building and the problem during the building 2025-06-12 19:00:34 +08:00
Harsha v
df758359e8
Update issue templates 2025-06-09 14:51:07 +05:30
Harsha v
78c3d91ec2
Merge pull request #22 from harsha-iiiv/fix/schema-cycle-detection
feat: Enhance schema mapping with cycle protection
2025-06-08 23:49:38 +05:30
Harsha v
4f7417890f
Create LICENSE 2025-06-08 23:45:58 +05:30
harsha-iiiv
aaf8613216 chore: Update changelog and enhance schema mapping error handling 2025-06-08 23:35:23 +05:30
harsha-iiiv
4c84306814 chore: Bump version to 3.1.2 and update changelog 2025-06-08 23:29:13 +05:30
harsha-iiiv
e2fd79c2ec feat: Enhance schema mapping with cycle protection
- Added cycle detection to the mapOpenApiSchemaToJsonSchema function to prevent infinite recursion when processing schemas.
- Introduced a WeakSet to track visited schema objects, ensuring robust handling of circular references.
2025-06-08 23:10:43 +05:30
Harsha v
2c6c05988e
Update npm-publish.yml 2025-05-26 09:18:29 +05:30
Harsha v
c607d9c759
Merge pull request #18 from harsha-iiiv/fix/cli-path
chore: Update version to 3.1.1 and modify CLI entry point
2025-05-26 08:55:21 +05:30
harsha-iiiv
6141d2e8ae chore: Update version to 3.1.1 and modify CLI entry point
- Bumped package version to 3.1.1 in package.json to match the new release.
- Changed the CLI entry point to use the new bin/openapi-mcp-generator.js file.
- Updated build script to ensure the new CLI file has the correct permissions.
- Refactored index.ts to streamline argument parsing and error handling.
2025-05-26 08:43:41 +05:30
41 changed files with 9505 additions and 7791 deletions

View File

@ -1,18 +1,15 @@
{ {
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"extends": [ "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"eslint:recommended", "plugins": ["@typescript-eslint"],
"plugin:@typescript-eslint/recommended" "env": {
], "node": true,
"plugins": ["@typescript-eslint"], "es2022": true
"env": { },
"node": true, "rules": {
"es2022": true "no-console": "off",
}, "@typescript-eslint/explicit-function-return-type": "off",
"rules": { "@typescript-eslint/no-explicit-any": "off",
"no-console": "off", "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
"@typescript-eslint/explicit-function-return-type": "off", }
"@typescript-eslint/no-explicit-any": "off", }
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
}

40
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,40 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

7
.github/ISSUE_TEMPLATE/custom.md vendored Normal file
View File

@ -0,0 +1,7 @@
---
name: Custom issue template
about: Describe this issue template's purpose here.
title: ''
labels: ''
assignees: ''
---

View File

@ -0,0 +1,19 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

44
.github/PULL_REQUEST_TEMPLATE/mcpcat.md vendored Normal file
View File

@ -0,0 +1,44 @@
# 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).

30
.github/workflows/check.yml vendored Normal file
View File

@ -0,0 +1,30 @@
name: check
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
format-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install dependencies
run: npm ci
- name: Format check
run: npm run format.check
- name: Build
run: npm run build

View File

@ -23,7 +23,7 @@ jobs:
with: with:
node-version: '20' node-version: '20'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
cache: 'npm' # Enables npm dependency caching cache: 'npm' # Enables npm dependency caching
cache-dependency-path: '**/package-lock.json' # Cache key based on lockfile cache-dependency-path: '**/package-lock.json' # Cache key based on lockfile
- name: Install dependencies - name: Install dependencies
@ -35,9 +35,8 @@ jobs:
- name: Check version change - name: Check version change
id: check_version id: check_version
run: | run: |
git fetch origin main
CURRENT_VERSION=$(node -p "require('./package.json').version") CURRENT_VERSION=$(node -p "require('./package.json').version")
PREV_VERSION=$(git show origin/main:package.json | grep '"version":' | sed -E 's/.*"version": *"([^"]+)".*/\1/') PREV_VERSION=$(git show HEAD^:package.json | grep '"version":' | sed -E 's/.*"version": *"([^"]+)".*/\1/')
if [ "$CURRENT_VERSION" != "$PREV_VERSION" ]; then if [ "$CURRENT_VERSION" != "$PREV_VERSION" ]; then
echo "Version changed from $PREV_VERSION to $CURRENT_VERSION" echo "Version changed from $PREV_VERSION to $CURRENT_VERSION"
echo "version_changed=true" >> $GITHUB_OUTPUT echo "version_changed=true" >> $GITHUB_OUTPUT

View File

@ -1,7 +1,7 @@
{ {
"semi": true, "semi": true,
"trailingComma": "es5", "trailingComma": "es5",
"singleQuote": true, "singleQuote": true,
"printWidth": 100, "printWidth": 100,
"tabWidth": 2 "tabWidth": 2
} }

View File

@ -5,9 +5,64 @@ 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
### Fixed
- Prevent stack overflow (RangeError: Maximum call stack size exceeded) when processing recursive or cyclic OpenAPI schemas (e.g., self-referencing objects).
- Added cycle detection to schema mapping, ensuring robust handling of recursive structures.
## [3.1.1] - 2025-05-26
### Added
- Introduced a new executable command-line script for easier usage in Unix-like environments.
### Changed
- Use new CLI entry point to use the new `bin/openapi-mcp-generator.js` file.
- Updated build script to ensure the new CLI file has the correct permissions.
- Refactored `index.ts` to streamline argument parsing and error handling.
## [3.1.0] - 2025-05-18 ## [3.1.0] - 2025-05-18
### Added ### Added
- Programmatic API to extract MCP tool definitions from OpenAPI specs - Programmatic API to extract MCP tool definitions from OpenAPI specs
- New exportable `getToolsFromOpenApi` function for direct integration in code - New exportable `getToolsFromOpenApi` function for direct integration in code
- Advanced filtering capabilities for programmatic tool extraction - Advanced filtering capabilities for programmatic tool extraction
@ -15,12 +70,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Updated README with programmatic API usage examples - Updated README with programmatic API usage examples
### Changed ### Changed
- Improved module structure with better exports - Improved module structure with better exports
- Enhanced detection of module execution context - Enhanced detection of module execution context
## [3.0.0] - 2025-04-26
### Added
- Streamable HTTP support for OpenAPI MCP generator, enabling efficient handling of large payloads and real-time data transfer.
- Major architectural refactor to support streaming responses and requests.
### Fixed
- Multiple bugs related to HTTP/HTTPS connection handling, stream closure, and error propagation in streaming scenarios.
- Fixed resource leak issues on server aborts and client disconnects during streaming.
### Changed
- Major version bump due to breaking changes in API and internal structures to support streaming.
- Updated documentation to reflect new streaming capabilities and usage instructions.
- Enhanced performance and robustness of HTTP/HTTPS transport layers.
## [2.0.0] - 2025-04-12 ## [2.0.0] - 2025-04-12
### Added ### Added
- Runtime argument validation using Zod - Runtime argument validation using Zod
- JSON Schema to Zod schema conversion - JSON Schema to Zod schema conversion
- Improved error handling and formatting - Improved error handling and formatting
@ -31,6 +106,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Support for multiple content types - Support for multiple content types
### Changed ### Changed
- Simplified transport layer to only support stdio transport - Simplified transport layer to only support stdio transport
- Removed support for WebSocket and HTTP transports - Removed support for WebSocket and HTTP transports
- Updated to use @modelcontextprotocol/sdk v1.9.0 - Updated to use @modelcontextprotocol/sdk v1.9.0
@ -40,6 +116,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- More robust OpenAPI schema processing - More robust OpenAPI schema processing
### Fixed ### Fixed
- Path parameter resolution in URLs - Path parameter resolution in URLs
- Content-Type header handling - Content-Type header handling
- Response processing for different content types - Response processing for different content types
@ -49,8 +126,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.0.0] - Initial Release ## [1.0.0] - Initial Release
### Added ### Added
- Basic OpenAPI to MCP server generation - Basic OpenAPI to MCP server generation
- Support for GET, POST, PUT, DELETE methods - Support for GET, POST, PUT, DELETE methods
- Basic error handling - Basic error handling
- Simple CLI interface - Simple CLI interface
- Basic TypeScript support - Basic TypeScript support

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 harsha-iiiv
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -21,16 +21,19 @@ import { getToolsFromOpenApi } from 'openapi-mcp-generator';
This function extracts an array of tools from an OpenAPI specification. 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 or URL to a remote spec
- `options`: (Optional) Configuration options - `options`: (Optional) Configuration options
**Options:** **Options:**
- `baseUrl`: Override the base URL in the OpenAPI spec - `baseUrl`: Override the base URL in the OpenAPI spec
- `dereference`: Whether to resolve $refs (default: false) - `dereference`: Whether to resolve $refs (default: false)
- `excludeOperationIds`: Array of operation IDs to exclude from the results - `excludeOperationIds`: Array of operation IDs to exclude from the results
- `filterFn`: Custom function to filter tools (receives tool, returns boolean) - `filterFn`: Custom function to filter tools (receives tool, returns boolean)
**Returns:** **Returns:**
- Promise that resolves to an array of McpToolDefinition objects - Promise that resolves to an array of McpToolDefinition objects
**Example:** **Example:**
@ -42,12 +45,15 @@ import { getToolsFromOpenApi } from 'openapi-mcp-generator';
const tools = await getToolsFromOpenApi('./petstore.json'); const tools = await getToolsFromOpenApi('./petstore.json');
// With options // With options
const filteredTools = await getToolsFromOpenApi('https://petstore3.swagger.io/api/v3/openapi.json', { const filteredTools = await getToolsFromOpenApi(
baseUrl: 'https://petstore3.swagger.io/api/v3', 'https://petstore3.swagger.io/api/v3/openapi.json',
dereference: true, {
excludeOperationIds: ['addPet', 'updatePet'], baseUrl: 'https://petstore3.swagger.io/api/v3',
filterFn: (tool) => tool.method.toLowerCase() === 'get' dereference: true,
}); excludeOperationIds: ['addPet', 'updatePet'],
filterFn: (tool) => tool.method.toLowerCase() === 'get',
}
);
// Process the results // Process the results
for (const tool of filteredTools) { for (const tool of filteredTools) {
@ -58,6 +64,20 @@ for (const tool of filteredTools) {
} }
``` ```
you can also provide a `OpenAPIV3.Document` to the parser:
```typescript
import { parser } from '@readme/openapi-parser';
const api = await parser('https://petstore3.swagger.io/api/v3/openapi.json', {
dereference: {
circular: true,
},
});
const tools = await getToolsFromOpenApi(api);
```
## Tool Definition Structure ## Tool Definition Structure
Each tool definition (`McpToolDefinition`) has the following properties: Each tool definition (`McpToolDefinition`) has the following properties:
@ -66,34 +86,34 @@ Each tool definition (`McpToolDefinition`) has the following properties:
interface McpToolDefinition { interface McpToolDefinition {
/** Name of the tool, must be unique */ /** Name of the tool, must be unique */
name: string; name: string;
/** Human-readable description of the tool */ /** Human-readable description of the tool */
description: string; description: string;
/** JSON Schema that defines the input parameters */ /** JSON Schema that defines the input parameters */
inputSchema: JSONSchema7 | boolean; inputSchema: JSONSchema7 | boolean;
/** HTTP method for the operation (get, post, etc.) */ /** HTTP method for the operation (get, post, etc.) */
method: string; method: string;
/** URL path template with parameter placeholders */ /** URL path template with parameter placeholders */
pathTemplate: string; pathTemplate: string;
/** OpenAPI parameter objects for this operation */ /** OpenAPI parameter objects for this operation */
parameters: OpenAPIV3.ParameterObject[]; parameters: OpenAPIV3.ParameterObject[];
/** Parameter names and locations for execution */ /** Parameter names and locations for execution */
executionParameters: { name: string; in: string }[]; executionParameters: { name: string; in: string }[];
/** Content type for request body, if applicable */ /** Content type for request body, if applicable */
requestBodyContentType?: string; requestBodyContentType?: string;
/** Security requirements for this operation */ /** Security requirements for this operation */
securityRequirements: OpenAPIV3.SecurityRequirementObject[]; securityRequirements: OpenAPIV3.SecurityRequirementObject[];
/** Original operation ID from the OpenAPI spec */ /** Original operation ID from the OpenAPI spec */
operationId: string; operationId: string;
/** Base URL for the API (if available) */ /** Base URL for the API (if available) */
baseUrl?: string; baseUrl?: string;
} }
@ -105,7 +125,7 @@ interface McpToolDefinition {
```typescript ```typescript
const getTools = await getToolsFromOpenApi(specUrl, { const getTools = await getToolsFromOpenApi(specUrl, {
filterFn: (tool) => tool.method.toLowerCase() === 'get' filterFn: (tool) => tool.method.toLowerCase() === 'get',
}); });
``` ```
@ -113,7 +133,7 @@ const getTools = await getToolsFromOpenApi(specUrl, {
```typescript ```typescript
const secureTools = await getToolsFromOpenApi(specUrl, { const secureTools = await getToolsFromOpenApi(specUrl, {
filterFn: (tool) => tool.securityRequirements.length > 0 filterFn: (tool) => tool.securityRequirements.length > 0,
}); });
``` ```
@ -121,7 +141,7 @@ const secureTools = await getToolsFromOpenApi(specUrl, {
```typescript ```typescript
const userTools = await getToolsFromOpenApi(specUrl, { const userTools = await getToolsFromOpenApi(specUrl, {
filterFn: (tool) => tool.pathTemplate.includes('/user') filterFn: (tool) => tool.pathTemplate.includes('/user'),
}); });
``` ```
@ -130,8 +150,6 @@ const userTools = await getToolsFromOpenApi(specUrl, {
```typescript ```typescript
const safeUserTools = await getToolsFromOpenApi(specUrl, { const safeUserTools = await getToolsFromOpenApi(specUrl, {
excludeOperationIds: ['deleteUser', 'updateUser'], excludeOperationIds: ['deleteUser', 'updateUser'],
filterFn: (tool) => filterFn: (tool) => tool.pathTemplate.includes('/user') && tool.method.toLowerCase() === 'get',
tool.pathTemplate.includes('/user') &&
tool.method.toLowerCase() === 'get'
}); });
``` ```

View File

@ -48,16 +48,17 @@ 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` |
| `--server-version` | `-v` | Version of the MCP server (`package.json:version`) | OpenAPI version or `1.0.0` | | `--server-version` | `-v` | Version of the MCP server (`package.json:version`) | OpenAPI version or `1.0.0` |
| `--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` |
| `--force` | | Overwrite existing files in the output directory without confirmation | `false` | | `--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` |
## 📦 Programmatic API ## 📦 Programmatic API
@ -74,7 +75,7 @@ const filteredTools = await getToolsFromOpenApi('https://example.com/api-spec.js
baseUrl: 'https://api.example.com', baseUrl: 'https://api.example.com',
dereference: true, dereference: true,
excludeOperationIds: ['deletePet'], excludeOperationIds: ['deletePet'],
filterFn: (tool) => tool.method.toLowerCase() === 'get' filterFn: (tool) => tool.method.toLowerCase() === 'get',
}); });
``` ```
@ -100,6 +101,7 @@ The generated project includes:
``` ```
Core dependencies: Core dependencies:
- `@modelcontextprotocol/sdk` - MCP protocol implementation - `@modelcontextprotocol/sdk` - MCP protocol implementation
- `axios` - HTTP client for API requests - `axios` - HTTP client for API requests
- `zod` - Runtime validation - `zod` - Runtime validation
@ -138,18 +140,18 @@ Implements the MCP StreamableHTTP transport which offers:
### Transport Comparison ### Transport Comparison
| Feature | stdio | web (SSE) | streamable-http | | Feature | stdio | web (SSE) | streamable-http |
|---------|-------|-----------|----------------| | ------------------ | ------------------- | ----------------- | ------------------ |
| Protocol | JSON-RPC over stdio | JSON-RPC over SSE | JSON-RPC over HTTP | | Protocol | JSON-RPC over stdio | JSON-RPC over SSE | JSON-RPC over HTTP |
| Connection | Persistent | Persistent | Request/response | | Connection | Persistent | Persistent | Request/response |
| Bidirectional | Yes | Yes | Yes (stateful) | | Bidirectional | Yes | Yes | Yes (stateful) |
| Multiple clients | No | Yes | Yes | | Multiple clients | No | Yes | Yes |
| Browser compatible | No | Yes | Yes | | Browser compatible | No | Yes | Yes |
| Firewall friendly | No | Yes | Yes | | Firewall friendly | No | Yes | Yes |
| Load balancing | No | Limited | Yes | | Load balancing | No | Limited | Yes |
| Status codes | No | Limited | Full HTTP codes | | Status codes | No | Limited | Full HTTP codes |
| Headers | No | Limited | Full HTTP headers | | Headers | No | Limited | Full HTTP headers |
| Test client | No | Yes | Yes | | Test client | No | Yes | Yes |
--- ---
@ -157,12 +159,44 @@ Implements the MCP StreamableHTTP transport which offers:
Configure auth credentials in your environment: Configure auth credentials in your environment:
| Auth Type | Variable Format | | Auth Type | Variable Format |
|-------------|----------------------------------------------------------| | ---------- | -------------------------------------------------------------------------------------------------- |
| API Key | `API_KEY_<SCHEME_NAME>` | | API Key | `API_KEY_<SCHEME_NAME>` |
| Bearer | `BEARER_TOKEN_<SCHEME_NAME>` | | Bearer | `BEARER_TOKEN_<SCHEME_NAME>` |
| Basic Auth | `BASIC_USERNAME_<SCHEME_NAME>`, `BASIC_PASSWORD_<SCHEME_NAME>` | | Basic Auth | `BASIC_USERNAME_<SCHEME_NAME>`, `BASIC_PASSWORD_<SCHEME_NAME>` |
| OAuth2 | `OAUTH_CLIENT_ID_<SCHEME_NAME>`, `OAUTH_CLIENT_SECRET_<SCHEME_NAME>`, `OAUTH_SCOPES_<SCHEME_NAME>` | | OAuth2 | `OAUTH_CLIENT_ID_<SCHEME_NAME>`, `OAUTH_CLIENT_SECRET_<SCHEME_NAME>`, `OAUTH_SCOPES_<SCHEME_NAME>` |
---
## 🔎 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.
--- ---
@ -214,8 +248,9 @@ Contributions are welcome!
1. Fork the repo 1. Fork the repo
2. Create a feature branch: `git checkout -b feature/amazing-feature` 2. Create a feature branch: `git checkout -b feature/amazing-feature`
3. Commit your changes: `git commit -m "Add amazing feature"` 3. Run `npm run format.write` to format your code
4. Push and open a PR 4. Commit your changes: `git commit -m "Add amazing feature"`
5. Push and open a PR
📌 Repository: [github.com/harsha-iiiv/openapi-mcp-generator](https://github.com/harsha-iiiv/openapi-mcp-generator) 📌 Repository: [github.com/harsha-iiiv/openapi-mcp-generator](https://github.com/harsha-iiiv/openapi-mcp-generator)

6
bin/openapi-mcp-generator.js Executable file
View File

@ -0,0 +1,6 @@
#!/usr/bin/env node
import { program } from '../dist/index.js';
// Parse CLI arguments and run the program
program.parse(process.argv);

View File

@ -1,12 +1,7 @@
{ {
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"extends": [ "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"eslint:recommended", "plugins": ["@typescript-eslint"],
"plugin:@typescript-eslint/recommended"
],
"plugins": [
"@typescript-eslint"
],
"env": { "env": {
"node": true, "node": true,
"es2022": true "es2022": true
@ -15,10 +10,7 @@
"no-console": [ "no-console": [
"error", "error",
{ {
"allow": [ "allow": ["error", "warn"]
"error",
"warn"
]
} }
], ],
"@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-function-return-type": "off",
@ -30,4 +22,4 @@
} }
] ]
} }
} }

View File

@ -4,4 +4,4 @@
"singleQuote": true, "singleQuote": true,
"printWidth": 100, "printWidth": 100,
"tabWidth": 2 "tabWidth": 2
} }

View File

@ -13,11 +13,13 @@ This API uses OAuth2 for authentication. The MCP server can handle OAuth2 authen
- `OAUTH_CLIENT_ID_PETSTORE_AUTH`: Your OAuth client ID - `OAUTH_CLIENT_ID_PETSTORE_AUTH`: Your OAuth client ID
- `OAUTH_CLIENT_SECRET_PETSTORE_AUTH`: Your OAuth client secret - `OAUTH_CLIENT_SECRET_PETSTORE_AUTH`: Your OAuth client secret
## Token Caching ## Token Caching
The MCP server automatically caches OAuth tokens obtained via client credentials flow. Tokens are cached for their lifetime (as specified by the `expires_in` parameter in the token response) minus 60 seconds as a safety margin. The MCP server automatically caches OAuth tokens obtained via client credentials flow. Tokens are cached for their lifetime (as specified by the `expires_in` parameter in the token response) minus 60 seconds as a safety margin.
When making API requests, the server will: When making API requests, the server will:
1. Check for a cached token that's still valid 1. Check for a cached token that's still valid
2. Use the cached token if available 2. Use the cached token if available
3. Request a new token if no valid cached token exists 3. Request a new token if no valid cached token exists

View File

@ -34,4 +34,4 @@
"typescript": "^5.8.3", "typescript": "^5.8.3",
"@types/uuid": "^10.0.0" "@types/uuid": "^10.0.0"
} }
} }

View File

@ -1,393 +1,406 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>swagger-petstore---openapi-3-0 MCP Test Client</title> <title>swagger-petstore---openapi-3-0 MCP Test Client</title>
<style> <style>
body { body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family:
max-width: 800px; system-ui,
margin: 0 auto; -apple-system,
padding: 20px; BlinkMacSystemFont,
line-height: 1.5; 'Segoe UI',
} Roboto,
h1 { margin-bottom: 10px; } sans-serif;
.container { max-width: 800px;
display: flex; margin: 0 auto;
flex-direction: column; padding: 20px;
height: calc(100vh - 150px); line-height: 1.5;
}
#conversation {
flex: 1;
border: 1px solid #ccc;
overflow-y: auto;
margin-bottom: 10px;
padding: 10px;
border-radius: 5px;
}
.input-area {
display: flex;
margin-bottom: 20px;
}
#userInput {
flex: 1;
padding: 8px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 5px 0 0 5px;
}
#sendButton {
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
cursor: pointer;
border-radius: 0 5px 5px 0;
}
#sendButton:hover { background-color: #45a049; }
.message {
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 5px;
}
.user {
background-color: #e7f4ff;
align-self: flex-end;
}
.server {
background-color: #f1f1f1;
}
.system {
background-color: #fffde7;
color: #795548;
font-style: italic;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
code {
background-color: #f8f8f8;
padding: 2px 4px;
border-radius: 3px;
}
.status {
color: #666;
font-style: italic;
margin-bottom: 10px;
}
#debug {
margin-top: 20px;
background-color: #f8f8f8;
padding: 10px;
border-radius: 5px;
display: none;
}
.debug-controls {
display: flex;
justify-content: space-between;
align-items: center;
}
#showDebug {
margin-top: 10px;
padding: 5px 10px;
cursor: pointer;
background-color: #f1f1f1;
border: 1px solid #ccc;
border-radius: 3px;
}
#debugLog {
max-height: 200px;
overflow-y: auto;
background-color: #111;
color: #0f0;
font-family: monospace;
padding: 5px;
margin-top: 10px;
}
.clear-debug {
padding: 3px 8px;
background-color: #f44336;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>swagger-petstore---openapi-3-0 MCP Test Client</h1>
<p class="status" id="status">Disconnected</p>
<div class="container">
<div id="conversation"></div>
<div class="input-area">
<input type="text" id="userInput" placeholder="Type a message..." disabled>
<button id="sendButton" disabled>Send</button>
</div>
</div>
<button id="showDebug">Show Debug Console</button>
<div id="debug">
<div class="debug-controls">
<h3>Debug Console</h3>
<button class="clear-debug" id="clearDebug">Clear</button>
</div>
<div id="debugLog"></div>
</div>
<script>
const conversation = document.getElementById('conversation');
const userInput = document.getElementById('userInput');
const sendButton = document.getElementById('sendButton');
const statusEl = document.getElementById('status');
const showDebugBtn = document.getElementById('showDebug');
const debugDiv = document.getElementById('debug');
const debugLog = document.getElementById('debugLog');
const clearDebugBtn = document.getElementById('clearDebug');
let sessionId = null;
let messageId = 1;
let eventSource = null;
let apiEndpoint = 'http://localhost:3000/api/messages'; // default endpoint
// Debug logging
function log(type, message) {
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1);
const entry = document.createElement('div');
entry.innerHTML = `<span style="color:#aaa;">${timestamp}</span> <span style="color:#58a6ff;">${type}:</span> ${message}`;
debugLog.appendChild(entry);
debugLog.scrollTop = debugLog.scrollHeight;
console.log(`${type}: ${message}`);
}
// Toggle debug console
showDebugBtn.addEventListener('click', () => {
if (debugDiv.style.display === 'block') {
debugDiv.style.display = 'none';
showDebugBtn.textContent = 'Show Debug Console';
} else {
debugDiv.style.display = 'block';
showDebugBtn.textContent = 'Hide Debug Console';
}
});
// Clear debug logs
clearDebugBtn.addEventListener('click', () => {
debugLog.innerHTML = '';
});
// Connect to SSE endpoint
function connect() {
statusEl.textContent = 'Connecting...';
log('INFO', 'Connecting to SSE endpoint...');
// Close existing connection if any
if (eventSource) {
eventSource.close();
log('INFO', 'Closed existing connection');
}
eventSource = new EventSource('http://localhost:3000/sse');
eventSource.onopen = () => {
log('INFO', 'SSE connection opened');
statusEl.textContent = 'Connected, waiting for session ID...';
};
eventSource.onerror = (error) => {
log('ERROR', `SSE connection error: ${error}`);
statusEl.textContent = 'Connection error. Reconnecting in 3s...';
setTimeout(connect, 3000);
};
// Listen for the endpoint event
eventSource.addEventListener('endpoint', (event) => {
apiEndpoint = event.data;
log('INFO', `API endpoint received: ${apiEndpoint}`);
});
// Listen for the session event
eventSource.addEventListener('session', (event) => {
log('INFO', `Session data received: ${event.data}`);
try {
const data = JSON.parse(event.data);
if (data.type === 'session_id') {
sessionId = data.session_id;
statusEl.textContent = `Connected (Session ID: ${sessionId})`;
userInput.disabled = false;
sendButton.disabled = false;
userInput.focus();
appendMessage('system', `Connected with session ID: ${sessionId}`);
log('INFO', `Received session ID: ${sessionId}`);
}
} catch (error) {
log('ERROR', `Error parsing session data: ${error.message}`);
} }
}); h1 {
margin-bottom: 10px;
// Listen for regular messages }
eventSource.addEventListener('message', (event) => { .container {
log('RAW', event.data); display: flex;
flex-direction: column;
try { height: calc(100vh - 150px);
const data = JSON.parse(event.data); }
#conversation {
// The MCP SSE transport sends messages in jsonrpc format flex: 1;
// Check if this is a notification with clientInfo containing sessionId border: 1px solid #ccc;
if (data.method === 'notification' && data.params?.clientInfo?.sessionId) { overflow-y: auto;
if (!sessionId) { margin-bottom: 10px;
sessionId = data.params.clientInfo.sessionId; padding: 10px;
statusEl.textContent = `Connected (Session ID: ${sessionId})`; border-radius: 5px;
userInput.disabled = false; }
sendButton.disabled = false; .input-area {
userInput.focus(); display: flex;
appendMessage('system', `Connected with session ID: ${sessionId}`); margin-bottom: 20px;
log('INFO', `Received session ID from MCP notification: ${sessionId}`); }
} #userInput {
return; flex: 1;
} padding: 8px;
font-size: 16px;
// Handle jsonrpc responses border: 1px solid #ccc;
if (data.jsonrpc === '2.0' && data.result) { border-radius: 5px 0 0 5px;
appendMessage('server', JSON.stringify(data.result, null, 2)); }
userInput.focus(); #sendButton {
return; padding: 8px 16px;
} background-color: #4caf50;
color: white;
// Handle normal server messages with content border: none;
if (data.content) { cursor: pointer;
appendMessage('server', JSON.stringify(data, null, 2)); border-radius: 0 5px 5px 0;
userInput.focus(); }
#sendButton:hover {
background-color: #45a049;
}
.message {
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 5px;
}
.user {
background-color: #e7f4ff;
align-self: flex-end;
}
.server {
background-color: #f1f1f1;
}
.system {
background-color: #fffde7;
color: #795548;
font-style: italic;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
code {
background-color: #f8f8f8;
padding: 2px 4px;
border-radius: 3px;
}
.status {
color: #666;
font-style: italic;
margin-bottom: 10px;
}
#debug {
margin-top: 20px;
background-color: #f8f8f8;
padding: 10px;
border-radius: 5px;
display: none;
}
.debug-controls {
display: flex;
justify-content: space-between;
align-items: center;
}
#showDebug {
margin-top: 10px;
padding: 5px 10px;
cursor: pointer;
background-color: #f1f1f1;
border: 1px solid #ccc;
border-radius: 3px;
}
#debugLog {
max-height: 200px;
overflow-y: auto;
background-color: #111;
color: #0f0;
font-family: monospace;
padding: 5px;
margin-top: 10px;
}
.clear-debug {
padding: 3px 8px;
background-color: #f44336;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>swagger-petstore---openapi-3-0 MCP Test Client</h1>
<p class="status" id="status">Disconnected</p>
<div class="container">
<div id="conversation"></div>
<div class="input-area">
<input type="text" id="userInput" placeholder="Type a message..." disabled />
<button id="sendButton" disabled>Send</button>
</div>
</div>
<button id="showDebug">Show Debug Console</button>
<div id="debug">
<div class="debug-controls">
<h3>Debug Console</h3>
<button class="clear-debug" id="clearDebug">Clear</button>
</div>
<div id="debugLog"></div>
</div>
<script>
const conversation = document.getElementById('conversation');
const userInput = document.getElementById('userInput');
const sendButton = document.getElementById('sendButton');
const statusEl = document.getElementById('status');
const showDebugBtn = document.getElementById('showDebug');
const debugDiv = document.getElementById('debug');
const debugLog = document.getElementById('debugLog');
const clearDebugBtn = document.getElementById('clearDebug');
let sessionId = null;
let messageId = 1;
let eventSource = null;
let apiEndpoint = 'http://localhost:3000/api/messages'; // default endpoint
// Debug logging
function log(type, message) {
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1);
const entry = document.createElement('div');
entry.innerHTML = `<span style="color:#aaa;">${timestamp}</span> <span style="color:#58a6ff;">${type}:</span> ${message}`;
debugLog.appendChild(entry);
debugLog.scrollTop = debugLog.scrollHeight;
console.log(`${type}: ${message}`);
}
// Toggle debug console
showDebugBtn.addEventListener('click', () => {
if (debugDiv.style.display === 'block') {
debugDiv.style.display = 'none';
showDebugBtn.textContent = 'Show Debug Console';
} else { } else {
log('INFO', `Received other message: ${JSON.stringify(data)}`); debugDiv.style.display = 'block';
showDebugBtn.textContent = 'Hide Debug Console';
} }
} catch (error) {
log('ERROR', `Error parsing SSE message: ${error.message}`);
appendMessage('system', `Error parsing message: ${event.data}`);
}
});
return eventSource;
}
// Send a message to the server
async function sendMessage() {
const text = userInput.value.trim();
if (!text || !sessionId) return;
appendMessage('user', text);
userInput.value = '';
log('INFO', `Sending message: ${text}`);
try {
const parts = text.split(' ');
const toolName = parts[0];
const requestBody = {
jsonrpc: '2.0',
id: messageId++,
method: 'callTool',
params: {
name: toolName,
arguments: parseArguments(text)
}
};
log('REQUEST', JSON.stringify(requestBody));
// Use the endpoint provided by the server, or fall back to the default
const fullEndpoint = `http://localhost:3000/api/messages?sessionId=${sessionId}`;
console.log('fullEndpoint', fullEndpoint);
const response = await fetch(fullEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
}); });
if (!response.ok) { // Clear debug logs
const errorText = await response.text(); clearDebugBtn.addEventListener('click', () => {
log('ERROR', `Error response: ${response.status} ${response.statusText} ${errorText}`); debugLog.innerHTML = '';
appendMessage('system', `Error: ${response.status} ${response.statusText}\n${errorText}`); });
} else {
log('INFO', `Request sent successfully`); // Connect to SSE endpoint
// Note: We don't handle the response content here because the response function connect() {
// will come through the SSE connection, not this fetch response statusEl.textContent = 'Connecting...';
log('INFO', 'Connecting to SSE endpoint...');
// Close existing connection if any
if (eventSource) {
eventSource.close();
log('INFO', 'Closed existing connection');
}
eventSource = new EventSource('http://localhost:3000/sse');
eventSource.onopen = () => {
log('INFO', 'SSE connection opened');
statusEl.textContent = 'Connected, waiting for session ID...';
};
eventSource.onerror = (error) => {
log('ERROR', `SSE connection error: ${error}`);
statusEl.textContent = 'Connection error. Reconnecting in 3s...';
setTimeout(connect, 3000);
};
// Listen for the endpoint event
eventSource.addEventListener('endpoint', (event) => {
apiEndpoint = event.data;
log('INFO', `API endpoint received: ${apiEndpoint}`);
});
// Listen for the session event
eventSource.addEventListener('session', (event) => {
log('INFO', `Session data received: ${event.data}`);
try {
const data = JSON.parse(event.data);
if (data.type === 'session_id') {
sessionId = data.session_id;
statusEl.textContent = `Connected (Session ID: ${sessionId})`;
userInput.disabled = false;
sendButton.disabled = false;
userInput.focus();
appendMessage('system', `Connected with session ID: ${sessionId}`);
log('INFO', `Received session ID: ${sessionId}`);
}
} catch (error) {
log('ERROR', `Error parsing session data: ${error.message}`);
}
});
// Listen for regular messages
eventSource.addEventListener('message', (event) => {
log('RAW', event.data);
try {
const data = JSON.parse(event.data);
// The MCP SSE transport sends messages in jsonrpc format
// Check if this is a notification with clientInfo containing sessionId
if (data.method === 'notification' && data.params?.clientInfo?.sessionId) {
if (!sessionId) {
sessionId = data.params.clientInfo.sessionId;
statusEl.textContent = `Connected (Session ID: ${sessionId})`;
userInput.disabled = false;
sendButton.disabled = false;
userInput.focus();
appendMessage('system', `Connected with session ID: ${sessionId}`);
log('INFO', `Received session ID from MCP notification: ${sessionId}`);
}
return;
}
// Handle jsonrpc responses
if (data.jsonrpc === '2.0' && data.result) {
appendMessage('server', JSON.stringify(data.result, null, 2));
userInput.focus();
return;
}
// Handle normal server messages with content
if (data.content) {
appendMessage('server', JSON.stringify(data, null, 2));
userInput.focus();
} else {
log('INFO', `Received other message: ${JSON.stringify(data)}`);
}
} catch (error) {
log('ERROR', `Error parsing SSE message: ${error.message}`);
appendMessage('system', `Error parsing message: ${event.data}`);
}
});
return eventSource;
} }
} catch (error) {
log('ERROR', `Error sending message: ${error.message}`); // Send a message to the server
appendMessage('system', `Error sending message: ${error.message}`); async function sendMessage() {
} const text = userInput.value.trim();
} if (!text || !sessionId) return;
// Try to parse arguments from user input appendMessage('user', text);
// Format: toolName param1=value1 param2=value2 userInput.value = '';
function parseArguments(text) {
const parts = text.split(' '); log('INFO', `Sending message: ${text}`);
if (parts.length <= 1) return {};
try {
const args = {}; const parts = text.split(' ');
// Skip the first part (tool name) and process the rest const toolName = parts[0];
for (let i = 1; i < parts.length; i++) {
const part = parts[i]; const requestBody = {
const equalsIndex = part.indexOf('='); jsonrpc: '2.0',
id: messageId++,
if (equalsIndex > 0) { method: 'callTool',
const key = part.substring(0, equalsIndex); params: {
const value = part.substring(equalsIndex + 1); name: toolName,
arguments: parseArguments(text),
// Try to parse as number or boolean if possible },
if (value === 'true') args[key] = true; };
else if (value === 'false') args[key] = false;
else if (!isNaN(Number(value))) args[key] = Number(value); log('REQUEST', JSON.stringify(requestBody));
else args[key] = value;
// Use the endpoint provided by the server, or fall back to the default
const fullEndpoint = `http://localhost:3000/api/messages?sessionId=${sessionId}`;
console.log('fullEndpoint', fullEndpoint);
const response = await fetch(fullEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
log('ERROR', `Error response: ${response.status} ${response.statusText} ${errorText}`);
appendMessage(
'system',
`Error: ${response.status} ${response.statusText}\n${errorText}`
);
} else {
log('INFO', `Request sent successfully`);
// Note: We don't handle the response content here because the response
// will come through the SSE connection, not this fetch response
}
} catch (error) {
log('ERROR', `Error sending message: ${error.message}`);
appendMessage('system', `Error sending message: ${error.message}`);
}
} }
}
// Try to parse arguments from user input
return args; // Format: toolName param1=value1 param2=value2
} function parseArguments(text) {
const parts = text.split(' ');
// Add a message to the conversation if (parts.length <= 1) return {};
function appendMessage(sender, text) {
const messageDiv = document.createElement('div'); const args = {};
messageDiv.className = `message ${sender}`; // Skip the first part (tool name) and process the rest
for (let i = 1; i < parts.length; i++) {
// Format as code block if it looks like JSON const part = parts[i];
if (text.trim().startsWith('{') || text.trim().startsWith('[')) { const equalsIndex = part.indexOf('=');
const pre = document.createElement('pre');
const code = document.createElement('code'); if (equalsIndex > 0) {
code.textContent = text; const key = part.substring(0, equalsIndex);
pre.appendChild(code); const value = part.substring(equalsIndex + 1);
messageDiv.appendChild(pre);
} else { // Try to parse as number or boolean if possible
messageDiv.textContent = text; if (value === 'true') args[key] = true;
} else if (value === 'false') args[key] = false;
else if (!isNaN(Number(value))) args[key] = Number(value);
conversation.appendChild(messageDiv); else args[key] = value;
conversation.scrollTop = conversation.scrollHeight; }
} }
// Event listeners return args;
sendButton.addEventListener('click', sendMessage); }
userInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage(); // Add a message to the conversation
}); function appendMessage(sender, text) {
const messageDiv = document.createElement('div');
// Connect on page load messageDiv.className = `message ${sender}`;
appendMessage('system', 'Connecting to server...');
connect(); // Format as code block if it looks like JSON
if (text.trim().startsWith('{') || text.trim().startsWith('[')) {
// Clean up on page unload const pre = document.createElement('pre');
window.addEventListener('beforeunload', () => { const code = document.createElement('code');
if (eventSource) eventSource.close(); code.textContent = text;
}); pre.appendChild(code);
</script> messageDiv.appendChild(pre);
</body> } else {
</html> messageDiv.textContent = text;
}
conversation.appendChild(messageDiv);
conversation.scrollTop = conversation.scrollHeight;
}
// Event listeners
sendButton.addEventListener('click', sendMessage);
userInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
// Connect on page load
appendMessage('system', 'Connecting to server...');
connect();
// Clean up on page unload
window.addEventListener('beforeunload', () => {
if (eventSource) eventSource.close();
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -1,286 +1,304 @@
/** /**
* Web server setup for HTTP-based MCP communication using Hono * Web server setup for HTTP-based MCP communication using Hono
*/ */
import { Hono } from 'hono'; import { Hono } from 'hono';
import { cors } from 'hono/cors'; import { cors } from 'hono/cors';
import { serve } from '@hono/node-server'; import { serve } from '@hono/node-server';
import { streamSSE } from 'hono/streaming'; import { streamSSE } from 'hono/streaming';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { JSONRPCMessage, JSONRPCMessageSchema } from "@modelcontextprotocol/sdk/types.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js';
import type { Context } from 'hono'; import type { Context } from 'hono';
import type { SSEStreamingApi } from 'hono/streaming'; import type { SSEStreamingApi } from 'hono/streaming';
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
// Import server configuration constants // Import server configuration constants
import { SERVER_NAME, SERVER_VERSION } from './index.js'; import { SERVER_NAME, SERVER_VERSION } from './index.js';
/** /**
* Custom SSE Transport implementation using Hono's streaming API * Custom SSE Transport implementation using Hono's streaming API
*/ */
class SSETransport implements Transport { class SSETransport implements Transport {
private _sessionId: string; private _sessionId: string;
private stream: SSEStreamingApi; private stream: SSEStreamingApi;
private messageUrl: string; private messageUrl: string;
onclose?: () => void; onclose?: () => void;
onerror?: (error: Error) => void; onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void; onmessage?: (message: JSONRPCMessage) => void;
constructor(messageUrl: string, stream: SSEStreamingApi) { constructor(messageUrl: string, stream: SSEStreamingApi) {
this._sessionId = uuid(); this._sessionId = uuid();
this.stream = stream; this.stream = stream;
this.messageUrl = messageUrl; this.messageUrl = messageUrl;
// Set up stream abort handler
this.stream.onAbort(() => {
console.error(`SSE connection aborted for session ${this._sessionId}`);
this.close();
});
}
get sessionId(): string { // Set up stream abort handler
return this._sessionId; this.stream.onAbort(() => {
} console.error(`SSE connection aborted for session ${this._sessionId}`);
this.close();
async start(): Promise<void> { });
if (this.stream.closed) {
throw new Error('SSE transport already closed!');
} }
// Send the endpoint information get sessionId(): string {
await this.stream.writeSSE({ return this._sessionId;
event: 'endpoint', }
data: `${this.messageUrl}?sessionId=${this._sessionId}`
}); async start(): Promise<void> {
if (this.stream.closed) {
// Send session ID and connection info in a format the client can understand throw new Error('SSE transport already closed!');
await this.stream.writeSSE({
event: 'session',
data: JSON.stringify({
type: 'session_id',
session_id: this._sessionId
})
});
// Send a welcome notification
await this.send({
jsonrpc: "2.0",
method: "notification",
params: {
type: "welcome",
clientInfo: {
sessionId: this._sessionId,
serverName: SERVER_NAME,
serverVersion: SERVER_VERSION
}
} }
});
}
async handlePostMessage(c: Context): Promise<Response> { // Send the endpoint information
if (this.stream?.closed) { await this.stream.writeSSE({
return c.text('SSE connection closed', 400); event: 'endpoint',
data: `${this.messageUrl}?sessionId=${this._sessionId}`,
});
// Send session ID and connection info in a format the client can understand
await this.stream.writeSSE({
event: 'session',
data: JSON.stringify({
type: 'session_id',
session_id: this._sessionId,
}),
});
// Send a welcome notification
await this.send({
jsonrpc: '2.0',
method: 'notification',
params: {
type: 'welcome',
clientInfo: {
sessionId: this._sessionId,
serverName: SERVER_NAME,
serverVersion: SERVER_VERSION,
},
},
});
} }
try { async handlePostMessage(c: Context): Promise<Response> {
// Parse and validate the message if (this.stream?.closed) {
const body = await c.req.json(); return c.text('SSE connection closed', 400);
}
try { try {
// Parse and validate the message // Parse and validate the message
const parsedMessage = JSONRPCMessageSchema.parse(body); const body = await c.req.json();
// Forward to the message handler try {
if (this.onmessage) { // Parse and validate the message
this.onmessage(parsedMessage); const parsedMessage = JSONRPCMessageSchema.parse(body);
return c.text('Accepted', 202);
} else { // Forward to the message handler
return c.text('No message handler defined', 500); if (this.onmessage) {
this.onmessage(parsedMessage);
return c.text('Accepted', 202);
} else {
return c.text('No message handler defined', 500);
}
} catch (error) {
if (this.onerror) {
this.onerror(error instanceof Error ? error : new Error(String(error)));
}
console.error('Error parsing message:', error);
return c.text('Invalid message format', 400);
} }
} catch (error) { } catch (error) {
if (this.onerror) { if (this.onerror) {
this.onerror(error instanceof Error ? error : new Error(String(error))); this.onerror(error instanceof Error ? error : new Error(String(error)));
} }
console.error('Error parsing message:', error); console.error('Error processing request:', error);
return c.text('Invalid message format', 400); return c.text('Error processing message', 400);
} }
} catch (error) { }
if (this.onerror) {
this.onerror(error instanceof Error ? error : new Error(String(error))); async close(): Promise<void> {
if (this.stream && !this.stream.closed) {
this.stream.abort();
} }
console.error('Error processing request:', error);
return c.text('Error processing message', 400);
}
}
async close(): Promise<void> { if (this.onclose) {
if (this.stream && !this.stream.closed) { this.onclose();
this.stream.abort(); }
} }
if (this.onclose) {
this.onclose();
}
}
async send(message: JSONRPCMessage): Promise<void> { async send(message: JSONRPCMessage): Promise<void> {
if (this.stream.closed) { if (this.stream.closed) {
throw new Error('Not connected'); throw new Error('Not connected');
}
await this.stream.writeSSE({
event: 'message',
data: JSON.stringify(message),
});
} }
await this.stream.writeSSE({
event: 'message',
data: JSON.stringify(message)
});
}
} }
/** /**
* Sets up a web server for the MCP server using Server-Sent Events (SSE) * Sets up a web server for the MCP server using Server-Sent Events (SSE)
* *
* @param server The MCP Server instance * @param server The MCP Server instance
* @param port The port to listen on (default: 3000) * @param port The port to listen on (default: 3000)
* @returns The Hono app instance * @returns The Hono app instance
*/ */
export async function setupWebServer(server: Server, port = 3000) { export async function setupWebServer(server: Server, port = 3000) {
// Create Hono app // Create Hono app
const app = new Hono(); const app = new Hono();
// Enable CORS // Enable CORS
app.use('*', cors()); app.use('*', cors());
// Store active SSE transports by session ID // Store active SSE transports by session ID
const transports: {[sessionId: string]: SSETransport} = {}; const transports: { [sessionId: string]: SSETransport } = {};
// Add a simple health check endpoint // Add a simple health check endpoint
app.get('/health', (c) => { app.get('/health', (c) => {
return c.json({ status: 'OK', server: SERVER_NAME, version: SERVER_VERSION }); return c.json({ status: 'OK', server: SERVER_NAME, version: SERVER_VERSION });
});
// SSE endpoint for clients to connect to
app.get("/sse", (c) => {
return streamSSE(c, async (stream) => {
// Create SSE transport
const transport = new SSETransport('/api/messages', stream);
const sessionId = transport.sessionId;
console.error(`New SSE connection established: ${sessionId}`);
// Store the transport
transports[sessionId] = transport;
// Set up cleanup on transport close
transport.onclose = () => {
console.error(`SSE connection closed for session ${sessionId}`);
delete transports[sessionId];
};
// Make the transport available to the MCP server
try {
transport.onmessage = async (message: JSONRPCMessage) => {
try {
// The server will automatically send a response via the transport
// if the message has an ID (i.e., it's a request, not a notification)
} catch (error) {
console.error('Error handling MCP message:', error);
}
};
// Connect to the MCP server
await server.connect(transport);
} catch (error) {
console.error(`Error connecting transport for session ${sessionId}:`, error);
}
// Keep the stream open until aborted
while (!stream.closed) {
await stream.sleep(1000);
}
}); });
});
// API endpoint for clients to send messages // SSE endpoint for clients to connect to
app.post("/api/messages", async (c) => { app.get('/sse', (c) => {
const sessionId = c.req.query('sessionId'); return streamSSE(c, async (stream) => {
// Create SSE transport
if (!sessionId) { const transport = new SSETransport('/api/messages', stream);
return c.json({ error: 'Missing sessionId query parameter' }, 400); const sessionId = transport.sessionId;
}
const transport = transports[sessionId];
if (!transport) {
return c.json({ error: 'No active session found with the provided sessionId' }, 404);
}
return transport.handlePostMessage(c);
});
// Static files for the web client (if any) console.error(`New SSE connection established: ${sessionId}`);
app.get('/*', async (c) => {
const filePath = c.req.path === '/' ? '/index.html' : c.req.path; // Store the transport
try { transports[sessionId] = transport;
// Use Node.js fs to serve static files
const fs = await import('fs'); // Set up cleanup on transport close
const path = await import('path'); transport.onclose = () => {
const { fileURLToPath } = await import('url'); console.error(`SSE connection closed for session ${sessionId}`);
delete transports[sessionId];
const __dirname = path.dirname(fileURLToPath(import.meta.url)); };
const publicPath = path.join(__dirname, '..', '..', 'public');
const fullPath = path.join(publicPath, filePath); // Make the transport available to the MCP server
try {
// Simple security check to prevent directory traversal transport.onmessage = async (message: JSONRPCMessage) => {
if (!fullPath.startsWith(publicPath)) { try {
return c.text('Forbidden', 403); // The server will automatically send a response via the transport
// if the message has an ID (i.e., it's a request, not a notification)
} catch (error) {
console.error('Error handling MCP message:', error);
}
};
// Connect to the MCP server
await server.connect(transport);
} catch (error) {
console.error(`Error connecting transport for session ${sessionId}:`, error);
}
// Keep the stream open until aborted
while (!stream.closed) {
await stream.sleep(1000);
}
});
});
// API endpoint for clients to send messages
app.post('/api/messages', async (c) => {
const sessionId = c.req.query('sessionId');
if (!sessionId) {
return c.json({ error: 'Missing sessionId query parameter' }, 400);
} }
const transport = transports[sessionId];
if (!transport) {
return c.json({ error: 'No active session found with the provided sessionId' }, 404);
}
return transport.handlePostMessage(c);
});
// Static files for the web client (if any)
app.get('/*', async (c) => {
const filePath = c.req.path === '/' ? '/index.html' : c.req.path;
try { try {
const stat = fs.statSync(fullPath); // Use Node.js fs to serve static files
if (stat.isFile()) { const fs = await import('fs');
const content = fs.readFileSync(fullPath); const path = await import('path');
const { fileURLToPath } = await import('url');
// Set content type based on file extension
const ext = path.extname(fullPath).toLowerCase(); const __dirname = path.dirname(fileURLToPath(import.meta.url));
let contentType = 'text/plain'; const publicPath = path.join(__dirname, '..', '..', 'public');
const fullPath = path.join(publicPath, filePath);
switch (ext) {
case '.html': contentType = 'text/html'; break; // Simple security check to prevent directory traversal
case '.css': contentType = 'text/css'; break; if (!fullPath.startsWith(publicPath)) {
case '.js': contentType = 'text/javascript'; break; return c.text('Forbidden', 403);
case '.json': contentType = 'application/json'; break; }
case '.png': contentType = 'image/png'; break;
case '.jpg': contentType = 'image/jpeg'; break; try {
case '.svg': contentType = 'image/svg+xml'; break; const stat = fs.statSync(fullPath);
if (stat.isFile()) {
const content = fs.readFileSync(fullPath);
// Set content type based on file extension
const ext = path.extname(fullPath).toLowerCase();
let contentType = 'text/plain';
switch (ext) {
case '.html':
contentType = 'text/html';
break;
case '.css':
contentType = 'text/css';
break;
case '.js':
contentType = 'text/javascript';
break;
case '.json':
contentType = 'application/json';
break;
case '.png':
contentType = 'image/png';
break;
case '.jpg':
contentType = 'image/jpeg';
break;
case '.svg':
contentType = 'image/svg+xml';
break;
}
return new Response(content, {
headers: { 'Content-Type': contentType },
});
} }
} catch (err) {
return new Response(content, { // File not found or other error
headers: { 'Content-Type': contentType } return c.text('Not Found', 404);
});
} }
} catch (err) { } catch (err) {
// File not found or other error console.error('Error serving static file:', err);
return c.text('Not Found', 404); return c.text('Internal Server Error', 500);
} }
} catch (err) {
console.error('Error serving static file:', err);
return c.text('Internal Server Error', 500);
}
return c.text('Not Found', 404);
});
// Start the server return c.text('Not Found', 404);
serve({ });
fetch: app.fetch,
port
}, (info) => {
console.error(`MCP Web Server running at http://localhost:${info.port}`);
console.error(`- SSE Endpoint: http://localhost:${info.port}/sse`);
console.error(`- Messages Endpoint: http://localhost:${info.port}/api/messages?sessionId=YOUR_SESSION_ID`);
console.error(`- Health Check: http://localhost:${info.port}/health`);
});
return app; // Start the server
serve(
{
fetch: app.fetch,
port,
},
(info) => {
console.error(`MCP Web Server running at http://localhost:${info.port}`);
console.error(`- SSE Endpoint: http://localhost:${info.port}/sse`);
console.error(
`- Messages Endpoint: http://localhost:${info.port}/api/messages?sessionId=YOUR_SESSION_ID`
);
console.error(`- Health Check: http://localhost:${info.port}/health`);
}
);
return app;
} }

View File

@ -17,12 +17,6 @@
"sourceMap": true, "sourceMap": true,
"forceConsistentCasingInFileNames": true "forceConsistentCasingInFileNames": true
}, },
"include": [ "include": ["src/**/*"],
"src/**/*" "exclude": ["node_modules", "build", "**/*.test.ts"]
], }
"exclude": [
"node_modules",
"build",
"**/*.test.ts"
]
}

View File

@ -1,12 +1,7 @@
{ {
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"extends": [ "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"eslint:recommended", "plugins": ["@typescript-eslint"],
"plugin:@typescript-eslint/recommended"
],
"plugins": [
"@typescript-eslint"
],
"env": { "env": {
"node": true, "node": true,
"es2022": true "es2022": true
@ -15,10 +10,7 @@
"no-console": [ "no-console": [
"error", "error",
{ {
"allow": [ "allow": ["error", "warn"]
"error",
"warn"
]
} }
], ],
"@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-function-return-type": "off",
@ -30,4 +22,4 @@
} }
] ]
} }
} }

View File

@ -4,4 +4,4 @@
"singleQuote": true, "singleQuote": true,
"printWidth": 100, "printWidth": 100,
"tabWidth": 2 "tabWidth": 2
} }

View File

@ -13,11 +13,13 @@ This API uses OAuth2 for authentication. The MCP server can handle OAuth2 authen
- `OAUTH_CLIENT_ID_PETSTORE_AUTH`: Your OAuth client ID - `OAUTH_CLIENT_ID_PETSTORE_AUTH`: Your OAuth client ID
- `OAUTH_CLIENT_SECRET_PETSTORE_AUTH`: Your OAuth client secret - `OAUTH_CLIENT_SECRET_PETSTORE_AUTH`: Your OAuth client secret
## Token Caching ## Token Caching
The MCP server automatically caches OAuth tokens obtained via client credentials flow. Tokens are cached for their lifetime (as specified by the `expires_in` parameter in the token response) minus 60 seconds as a safety margin. The MCP server automatically caches OAuth tokens obtained via client credentials flow. Tokens are cached for their lifetime (as specified by the `expires_in` parameter in the token response) minus 60 seconds as a safety margin.
When making API requests, the server will: When making API requests, the server will:
1. Check for a cached token that's still valid 1. Check for a cached token that's still valid
2. Use the cached token if available 2. Use the cached token if available
3. Request a new token if no valid cached token exists 3. Request a new token if no valid cached token exists

View File

@ -12,6 +12,8 @@
"scripts": { "scripts": {
"start": "node build/index.js", "start": "node build/index.js",
"build": "tsc && chmod 755 build/index.js", "build": "tsc && chmod 755 build/index.js",
"format.check": "prettier --check .",
"format.write": "prettier --write .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"prestart": "npm run build", "prestart": "npm run build",
"start:http": "node build/index.js --transport=streamable-http" "start:http": "node build/index.js --transport=streamable-http"
@ -35,4 +37,4 @@
"typescript": "^5.8.3", "typescript": "^5.8.3",
"@types/uuid": "^10.0.0" "@types/uuid": "^10.0.0"
} }
} }

View File

@ -1,402 +1,424 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>swagger-petstore---openapi-3-0 MCP StreamableHTTP Test Client</title> <title>swagger-petstore---openapi-3-0 MCP StreamableHTTP Test Client</title>
<style> <style>
body { body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family:
max-width: 800px; system-ui,
margin: 0 auto; -apple-system,
padding: 20px; BlinkMacSystemFont,
line-height: 1.5; 'Segoe UI',
} Roboto,
h1 { margin-bottom: 10px; } sans-serif;
.container { max-width: 800px;
display: flex; margin: 0 auto;
flex-direction: column; padding: 20px;
height: calc(100vh - 150px); line-height: 1.5;
}
#conversation {
flex: 1;
border: 1px solid #ccc;
overflow-y: auto;
margin-bottom: 10px;
padding: 10px;
border-radius: 5px;
}
.input-area {
display: flex;
margin-bottom: 20px;
}
#userInput {
flex: 1;
padding: 8px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 5px 0 0 5px;
}
#sendButton {
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
cursor: pointer;
border-radius: 0 5px 5px 0;
}
#sendButton:hover { background-color: #45a049; }
.message {
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 5px;
}
.user {
background-color: #e7f4ff;
align-self: flex-end;
}
.server {
background-color: #f1f1f1;
}
.system {
background-color: #fffde7;
color: #795548;
font-style: italic;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
code {
background-color: #f8f8f8;
padding: 2px 4px;
border-radius: 3px;
}
.status {
color: #666;
font-style: italic;
margin-bottom: 10px;
}
#debug {
margin-top: 20px;
background-color: #f8f8f8;
padding: 10px;
border-radius: 5px;
display: none;
}
.debug-controls {
display: flex;
justify-content: space-between;
align-items: center;
}
#showDebug {
margin-top: 10px;
padding: 5px 10px;
cursor: pointer;
background-color: #f1f1f1;
border: 1px solid #ccc;
border-radius: 3px;
}
#debugLog {
max-height: 200px;
overflow-y: auto;
background-color: #111;
color: #0f0;
font-family: monospace;
padding: 5px;
margin-top: 10px;
}
.clear-debug {
padding: 3px 8px;
background-color: #f44336;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>swagger-petstore---openapi-3-0 MCP StreamableHTTP Test Client</h1>
<p class="status" id="status">Disconnected</p>
<div class="container">
<div id="conversation"></div>
<div class="input-area">
<input type="text" id="userInput" placeholder="Type a message..." disabled>
<button id="sendButton" disabled>Send</button>
</div>
</div>
<button id="showDebug">Show Debug Console</button>
<div id="debug">
<div class="debug-controls">
<h3>Debug Console</h3>
<button class="clear-debug" id="clearDebug">Clear</button>
</div>
<div id="debugLog"></div>
</div>
<script>
const conversation = document.getElementById('conversation');
const userInput = document.getElementById('userInput');
const sendButton = document.getElementById('sendButton');
const statusEl = document.getElementById('status');
const showDebugBtn = document.getElementById('showDebug');
const debugDiv = document.getElementById('debug');
const debugLog = document.getElementById('debugLog');
const clearDebugBtn = document.getElementById('clearDebug');
let sessionId = null;
let messageId = 1;
// Debug logging
function log(type, message) {
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1);
const entry = document.createElement('div');
entry.innerHTML = `<span style="color:#aaa;">${timestamp}</span> <span style="color:#58a6ff;">${type}:</span> ${message}`;
debugLog.appendChild(entry);
debugLog.scrollTop = debugLog.scrollHeight;
console.log(`${type}: ${message}`);
}
// Toggle debug console
showDebugBtn.addEventListener('click', () => {
if (debugDiv.style.display === 'block') {
debugDiv.style.display = 'none';
showDebugBtn.textContent = 'Show Debug Console';
} else {
debugDiv.style.display = 'block';
showDebugBtn.textContent = 'Hide Debug Console';
} }
}); h1 {
margin-bottom: 10px;
// Clear debug logs }
clearDebugBtn.addEventListener('click', () => { .container {
debugLog.innerHTML = ''; display: flex;
}); flex-direction: column;
height: calc(100vh - 150px);
// Initialize the MCP connection }
async function initialize() { #conversation {
statusEl.textContent = 'Connecting...'; flex: 1;
log('INFO', 'Initializing MCP connection...'); border: 1px solid #ccc;
overflow-y: auto;
try { margin-bottom: 10px;
const requestBody = { padding: 10px;
jsonrpc: '2.0', border-radius: 5px;
id: messageId++, }
method: 'initialize', .input-area {
params: { display: flex;
clientName: 'MCP StreamableHTTP Test Client', margin-bottom: 20px;
clientVersion: '1.0.0', }
capabilities: {} #userInput {
flex: 1;
padding: 8px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 5px 0 0 5px;
}
#sendButton {
padding: 8px 16px;
background-color: #4caf50;
color: white;
border: none;
cursor: pointer;
border-radius: 0 5px 5px 0;
}
#sendButton:hover {
background-color: #45a049;
}
.message {
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 5px;
}
.user {
background-color: #e7f4ff;
align-self: flex-end;
}
.server {
background-color: #f1f1f1;
}
.system {
background-color: #fffde7;
color: #795548;
font-style: italic;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
code {
background-color: #f8f8f8;
padding: 2px 4px;
border-radius: 3px;
}
.status {
color: #666;
font-style: italic;
margin-bottom: 10px;
}
#debug {
margin-top: 20px;
background-color: #f8f8f8;
padding: 10px;
border-radius: 5px;
display: none;
}
.debug-controls {
display: flex;
justify-content: space-between;
align-items: center;
}
#showDebug {
margin-top: 10px;
padding: 5px 10px;
cursor: pointer;
background-color: #f1f1f1;
border: 1px solid #ccc;
border-radius: 3px;
}
#debugLog {
max-height: 200px;
overflow-y: auto;
background-color: #111;
color: #0f0;
font-family: monospace;
padding: 5px;
margin-top: 10px;
}
.clear-debug {
padding: 3px 8px;
background-color: #f44336;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>swagger-petstore---openapi-3-0 MCP StreamableHTTP Test Client</h1>
<p class="status" id="status">Disconnected</p>
<div class="container">
<div id="conversation"></div>
<div class="input-area">
<input type="text" id="userInput" placeholder="Type a message..." disabled />
<button id="sendButton" disabled>Send</button>
</div>
</div>
<button id="showDebug">Show Debug Console</button>
<div id="debug">
<div class="debug-controls">
<h3>Debug Console</h3>
<button class="clear-debug" id="clearDebug">Clear</button>
</div>
<div id="debugLog"></div>
</div>
<script>
const conversation = document.getElementById('conversation');
const userInput = document.getElementById('userInput');
const sendButton = document.getElementById('sendButton');
const statusEl = document.getElementById('status');
const showDebugBtn = document.getElementById('showDebug');
const debugDiv = document.getElementById('debug');
const debugLog = document.getElementById('debugLog');
const clearDebugBtn = document.getElementById('clearDebug');
let sessionId = null;
let messageId = 1;
// Debug logging
function log(type, message) {
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1);
const entry = document.createElement('div');
entry.innerHTML = `<span style="color:#aaa;">${timestamp}</span> <span style="color:#58a6ff;">${type}:</span> ${message}`;
debugLog.appendChild(entry);
debugLog.scrollTop = debugLog.scrollHeight;
console.log(`${type}: ${message}`);
}
// Toggle debug console
showDebugBtn.addEventListener('click', () => {
if (debugDiv.style.display === 'block') {
debugDiv.style.display = 'none';
showDebugBtn.textContent = 'Show Debug Console';
} else {
debugDiv.style.display = 'block';
showDebugBtn.textContent = 'Hide Debug Console';
}
});
// Clear debug logs
clearDebugBtn.addEventListener('click', () => {
debugLog.innerHTML = '';
});
// Initialize the MCP connection
async function initialize() {
statusEl.textContent = 'Connecting...';
log('INFO', 'Initializing MCP connection...');
try {
const requestBody = {
jsonrpc: '2.0',
id: messageId++,
method: 'initialize',
params: {
clientName: 'MCP StreamableHTTP Test Client',
clientVersion: '1.0.0',
capabilities: {},
},
};
log('REQUEST', JSON.stringify(requestBody));
const response = await fetch('/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
log('ERROR', `Error response: ${response.status} ${response.statusText} ${errorText}`);
appendMessage(
'system',
`Error: ${response.status} ${response.statusText}\n${errorText}`
);
statusEl.textContent = 'Connection error. Try again.';
return;
} }
};
// Get session ID from response headers
log('REQUEST', JSON.stringify(requestBody)); sessionId = response.headers.get('mcp-session-id');
const response = await fetch('/mcp', { if (!sessionId) {
method: 'POST', log('ERROR', 'No session ID in response headers');
headers: { appendMessage('system', 'Error: No session ID in response headers');
'Content-Type': 'application/json' statusEl.textContent = 'Connection error. Try again.';
}, return;
body: JSON.stringify(requestBody) }
});
// Process response body
if (!response.ok) { const data = await response.json();
const errorText = await response.text(); log('RESPONSE', JSON.stringify(data));
log('ERROR', `Error response: ${response.status} ${response.statusText} ${errorText}`);
appendMessage('system', `Error: ${response.status} ${response.statusText}\n${errorText}`); if (data.result) {
appendMessage('server', JSON.stringify(data.result, null, 2));
}
// Enable UI
statusEl.textContent = `Connected (Session ID: ${sessionId})`;
userInput.disabled = false;
sendButton.disabled = false;
userInput.focus();
appendMessage('system', `Connected with session ID: ${sessionId}`);
// Get list of tools
await listTools();
} catch (error) {
log('ERROR', `Error during initialization: ${error.message}`);
appendMessage('system', `Error during initialization: ${error.message}`);
statusEl.textContent = 'Connection error. Try again.'; statusEl.textContent = 'Connection error. Try again.';
return;
} }
// Get session ID from response headers
sessionId = response.headers.get('mcp-session-id');
if (!sessionId) {
log('ERROR', 'No session ID in response headers');
appendMessage('system', 'Error: No session ID in response headers');
statusEl.textContent = 'Connection error. Try again.';
return;
}
// Process response body
const data = await response.json();
log('RESPONSE', JSON.stringify(data));
if (data.result) {
appendMessage('server', JSON.stringify(data.result, null, 2));
}
// Enable UI
statusEl.textContent = `Connected (Session ID: ${sessionId})`;
userInput.disabled = false;
sendButton.disabled = false;
userInput.focus();
appendMessage('system', `Connected with session ID: ${sessionId}`);
// Get list of tools
await listTools();
} catch (error) {
log('ERROR', `Error during initialization: ${error.message}`);
appendMessage('system', `Error during initialization: ${error.message}`);
statusEl.textContent = 'Connection error. Try again.';
} }
}
// Get list of available tools
// Get list of available tools async function listTools() {
async function listTools() { try {
try { const requestBody = {
const requestBody = { jsonrpc: '2.0',
jsonrpc: '2.0', id: messageId++,
id: messageId++, method: 'listTools',
method: 'listTools', params: {},
params: {} };
};
log('REQUEST', JSON.stringify(requestBody));
log('REQUEST', JSON.stringify(requestBody));
const response = await fetch('http://localhost:3000/mcp', {
const response = await fetch('http://localhost:3000/mcp', { method: 'POST',
method: 'POST', headers: {
headers: { 'Content-Type': 'application/json',
'Content-Type': 'application/json', 'mcp-session-id': sessionId,
'mcp-session-id': sessionId },
}, body: JSON.stringify(requestBody),
body: JSON.stringify(requestBody) });
});
if (!response.ok) {
if (!response.ok) { const errorText = await response.text();
const errorText = await response.text(); log(
log('ERROR', `Error listing tools: ${response.status} ${response.statusText} ${errorText}`); 'ERROR',
return; `Error listing tools: ${response.status} ${response.statusText} ${errorText}`
);
return;
}
const data = await response.json();
log('TOOLS', JSON.stringify(data));
if (data.result?.tools && Array.isArray(data.result.tools)) {
appendMessage(
'system',
`Available tools: ${data.result.tools.map((t) => t.name).join(', ')}`
);
}
} catch (error) {
log('ERROR', `Error listing tools: ${error.message}`);
} }
const data = await response.json();
log('TOOLS', JSON.stringify(data));
if (data.result?.tools && Array.isArray(data.result.tools)) {
appendMessage('system', `Available tools: ${data.result.tools.map(t => t.name).join(', ')}`);
}
} catch (error) {
log('ERROR', `Error listing tools: ${error.message}`);
} }
}
// Send a message to the server
// Send a message to the server async function sendMessage() {
async function sendMessage() { const text = userInput.value.trim();
const text = userInput.value.trim(); if (!text || !sessionId) return;
if (!text || !sessionId) return;
appendMessage('user', text);
appendMessage('user', text); userInput.value = '';
userInput.value = '';
log('INFO', `Sending message: ${text}`);
log('INFO', `Sending message: ${text}`);
try {
try { const parts = text.split(' ');
const toolName = parts[0];
const requestBody = {
jsonrpc: '2.0',
id: messageId++,
method: 'callTool',
params: {
name: toolName,
arguments: parseArguments(text),
},
};
log('REQUEST', JSON.stringify(requestBody));
const response = await fetch('/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'mcp-session-id': sessionId,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
log('ERROR', `Error response: ${response.status} ${response.statusText} ${errorText}`);
appendMessage(
'system',
`Error: ${response.status} ${response.statusText}\n${errorText}`
);
return;
}
const data = await response.json();
log('RESPONSE', JSON.stringify(data));
if (data.error) {
appendMessage('system', `Error: ${data.error.code} - ${data.error.message}`);
} else if (data.result) {
appendMessage('server', JSON.stringify(data.result, null, 2));
}
} catch (error) {
log('ERROR', `Error sending message: ${error.message}`);
appendMessage('system', `Error sending message: ${error.message}`);
}
}
// Try to parse arguments from user input
// Format: toolName param1=value1 param2=value2
function parseArguments(text) {
const parts = text.split(' '); const parts = text.split(' ');
const toolName = parts[0]; if (parts.length <= 1) return {};
const requestBody = { const args = {};
jsonrpc: '2.0', // Skip the first part (tool name) and process the rest
id: messageId++, for (let i = 1; i < parts.length; i++) {
method: 'callTool', const part = parts[i];
params: { const equalsIndex = part.indexOf('=');
name: toolName,
arguments: parseArguments(text) if (equalsIndex > 0) {
const key = part.substring(0, equalsIndex);
const value = part.substring(equalsIndex + 1);
// Try to parse as number or boolean if possible
if (value === 'true') args[key] = true;
else if (value === 'false') args[key] = false;
else if (!isNaN(Number(value))) args[key] = Number(value);
else args[key] = value;
} }
};
log('REQUEST', JSON.stringify(requestBody));
const response = await fetch('/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'mcp-session-id': sessionId
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
log('ERROR', `Error response: ${response.status} ${response.statusText} ${errorText}`);
appendMessage('system', `Error: ${response.status} ${response.statusText}\n${errorText}`);
return;
} }
const data = await response.json(); return args;
log('RESPONSE', JSON.stringify(data)); }
if (data.error) { // Add a message to the conversation
appendMessage('system', `Error: ${data.error.code} - ${data.error.message}`); function appendMessage(sender, text) {
} else if (data.result) { const messageDiv = document.createElement('div');
appendMessage('server', JSON.stringify(data.result, null, 2)); messageDiv.className = `message ${sender}`;
// Format as code block if it looks like JSON
if (text.trim().startsWith('{') || text.trim().startsWith('[')) {
const pre = document.createElement('pre');
const code = document.createElement('code');
code.textContent = text;
pre.appendChild(code);
messageDiv.appendChild(pre);
} else {
messageDiv.textContent = text;
} }
} catch (error) {
log('ERROR', `Error sending message: ${error.message}`); conversation.appendChild(messageDiv);
appendMessage('system', `Error sending message: ${error.message}`); conversation.scrollTop = conversation.scrollHeight;
} }
}
// Event listeners
// Try to parse arguments from user input sendButton.addEventListener('click', sendMessage);
// Format: toolName param1=value1 param2=value2 userInput.addEventListener('keypress', (e) => {
function parseArguments(text) { if (e.key === 'Enter') sendMessage();
const parts = text.split(' '); });
if (parts.length <= 1) return {};
// Initialize on page load
const args = {}; appendMessage('system', 'Initializing MCP connection...');
// Skip the first part (tool name) and process the rest initialize();
for (let i = 1; i < parts.length; i++) { </script>
const part = parts[i]; </body>
const equalsIndex = part.indexOf('='); </html>
if (equalsIndex > 0) {
const key = part.substring(0, equalsIndex);
const value = part.substring(equalsIndex + 1);
// Try to parse as number or boolean if possible
if (value === 'true') args[key] = true;
else if (value === 'false') args[key] = false;
else if (!isNaN(Number(value))) args[key] = Number(value);
else args[key] = value;
}
}
return args;
}
// Add a message to the conversation
function appendMessage(sender, text) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${sender}`;
// Format as code block if it looks like JSON
if (text.trim().startsWith('{') || text.trim().startsWith('[')) {
const pre = document.createElement('pre');
const code = document.createElement('code');
code.textContent = text;
pre.appendChild(code);
messageDiv.appendChild(pre);
} else {
messageDiv.textContent = text;
}
conversation.appendChild(messageDiv);
conversation.scrollTop = conversation.scrollHeight;
}
// Event listeners
sendButton.addEventListener('click', sendMessage);
userInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
// Initialize on page load
appendMessage('system', 'Initializing MCP connection...');
initialize();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,3 @@
/** /**
* StreamableHTTP server setup for HTTP-based MCP communication using Hono * StreamableHTTP server setup for HTTP-based MCP communication using Hono
*/ */
@ -6,17 +5,17 @@ import { Hono } from 'hono';
import { cors } from 'hono/cors'; import { cors } from 'hono/cors';
import { serve } from '@hono/node-server'; import { serve } from '@hono/node-server';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { InitializeRequestSchema, JSONRPCError } from "@modelcontextprotocol/sdk/types.js"; import { InitializeRequestSchema, JSONRPCError } from '@modelcontextprotocol/sdk/types.js';
import { toReqRes, toFetchResponse } from 'fetch-to-node'; import { toReqRes, toFetchResponse } from 'fetch-to-node';
// Import server configuration constants // Import server configuration constants
import { SERVER_NAME, SERVER_VERSION } from './index.js'; import { SERVER_NAME, SERVER_VERSION } from './index.js';
// Constants // Constants
const SESSION_ID_HEADER_NAME = "mcp-session-id"; const SESSION_ID_HEADER_NAME = 'mcp-session-id';
const JSON_RPC = "2.0"; const JSON_RPC = '2.0';
/** /**
* StreamableHTTP MCP Server handler * StreamableHTTP MCP Server handler
@ -24,106 +23,102 @@ const JSON_RPC = "2.0";
class MCPStreamableHttpServer { class MCPStreamableHttpServer {
server: Server; server: Server;
// Store active transports by session ID // Store active transports by session ID
transports: {[sessionId: string]: StreamableHTTPServerTransport} = {}; transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
constructor(server: Server) { constructor(server: Server) {
this.server = server; this.server = server;
} }
/** /**
* Handle GET requests (typically used for static files) * Handle GET requests (typically used for static files)
*/ */
async handleGetRequest(c: any) { async handleGetRequest(c: any) {
console.error("GET request received - StreamableHTTP transport only supports POST"); console.error('GET request received - StreamableHTTP transport only supports POST');
return c.text('Method Not Allowed', 405, { return c.text('Method Not Allowed', 405, {
'Allow': 'POST' Allow: 'POST',
}); });
} }
/** /**
* Handle POST requests (all MCP communication) * Handle POST requests (all MCP communication)
*/ */
async handlePostRequest(c: any) { async handlePostRequest(c: any) {
const sessionId = c.req.header(SESSION_ID_HEADER_NAME); const sessionId = c.req.header(SESSION_ID_HEADER_NAME);
console.error(`POST request received ${sessionId ? 'with session ID: ' + sessionId : 'without session ID'}`); console.error(
`POST request received ${sessionId ? 'with session ID: ' + sessionId : 'without session ID'}`
);
try { try {
const body = await c.req.json(); const body = await c.req.json();
// Convert Fetch Request to Node.js req/res // Convert Fetch Request to Node.js req/res
const { req, res } = toReqRes(c.req.raw); const { req, res } = toReqRes(c.req.raw);
// Reuse existing transport if we have a session ID // Reuse existing transport if we have a session ID
if (sessionId && this.transports[sessionId]) { if (sessionId && this.transports[sessionId]) {
const transport = this.transports[sessionId]; const transport = this.transports[sessionId];
// Handle the request with the transport // Handle the request with the transport
await transport.handleRequest(req, res, body); await transport.handleRequest(req, res, body);
// Cleanup when the response ends // Cleanup when the response ends
res.on('close', () => { res.on('close', () => {
console.error(`Request closed for session ${sessionId}`); console.error(`Request closed for session ${sessionId}`);
}); });
// Convert Node.js response back to Fetch Response // Convert Node.js response back to Fetch Response
return toFetchResponse(res); return toFetchResponse(res);
} }
// Create new transport for initialize requests // Create new transport for initialize requests
if (!sessionId && this.isInitializeRequest(body)) { if (!sessionId && this.isInitializeRequest(body)) {
console.error("Creating new StreamableHTTP transport for initialize request"); console.error('Creating new StreamableHTTP transport for initialize request');
const transport = new StreamableHTTPServerTransport({ const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => uuid(), sessionIdGenerator: () => uuid(),
}); });
// Add error handler for debug purposes // Add error handler for debug purposes
transport.onerror = (err) => { transport.onerror = (err) => {
console.error('StreamableHTTP transport error:', err); console.error('StreamableHTTP transport error:', err);
}; };
// Connect the transport to the MCP server // Connect the transport to the MCP server
await this.server.connect(transport); await this.server.connect(transport);
// Handle the request with the transport // Handle the request with the transport
await transport.handleRequest(req, res, body); await transport.handleRequest(req, res, body);
// Store the transport if we have a session ID // Store the transport if we have a session ID
const newSessionId = transport.sessionId; const newSessionId = transport.sessionId;
if (newSessionId) { if (newSessionId) {
console.error(`New session established: ${newSessionId}`); console.error(`New session established: ${newSessionId}`);
this.transports[newSessionId] = transport; this.transports[newSessionId] = transport;
// Set up clean-up for when the transport is closed // Set up clean-up for when the transport is closed
transport.onclose = () => { transport.onclose = () => {
console.error(`Session closed: ${newSessionId}`); console.error(`Session closed: ${newSessionId}`);
delete this.transports[newSessionId]; delete this.transports[newSessionId];
}; };
} }
// Cleanup when the response ends // Cleanup when the response ends
res.on('close', () => { res.on('close', () => {
console.error(`Request closed for new session`); console.error(`Request closed for new session`);
}); });
// Convert Node.js response back to Fetch Response // Convert Node.js response back to Fetch Response
return toFetchResponse(res); return toFetchResponse(res);
} }
// Invalid request (no session ID and not initialize) // Invalid request (no session ID and not initialize)
return c.json( return c.json(this.createErrorResponse('Bad Request: invalid session ID or method.'), 400);
this.createErrorResponse("Bad Request: invalid session ID or method."),
400
);
} catch (error) { } catch (error) {
console.error('Error handling MCP request:', error); console.error('Error handling MCP request:', error);
return c.json( return c.json(this.createErrorResponse('Internal server error.'), 500);
this.createErrorResponse("Internal server error."),
500
);
} }
} }
/** /**
* Create a JSON-RPC error response * Create a JSON-RPC error response
*/ */
@ -137,7 +132,7 @@ class MCPStreamableHttpServer {
id: uuid(), id: uuid(),
}; };
} }
/** /**
* Check if the request is an initialize request * Check if the request is an initialize request
*/ */
@ -146,18 +141,18 @@ class MCPStreamableHttpServer {
const result = InitializeRequestSchema.safeParse(data); const result = InitializeRequestSchema.safeParse(data);
return result.success; return result.success;
}; };
if (Array.isArray(body)) { if (Array.isArray(body)) {
return body.some(request => isInitial(request)); return body.some((request) => isInitial(request));
} }
return isInitial(body); return isInitial(body);
} }
} }
/** /**
* Sets up a web server for the MCP server using StreamableHTTP transport * Sets up a web server for the MCP server using StreamableHTTP transport
* *
* @param server The MCP Server instance * @param server The MCP Server instance
* @param port The port to listen on (default: 3000) * @param port The port to listen on (default: 3000)
* @returns The Hono app instance * @returns The Hono app instance
@ -165,22 +160,22 @@ class MCPStreamableHttpServer {
export async function setupStreamableHttpServer(server: Server, port = 3000) { export async function setupStreamableHttpServer(server: Server, port = 3000) {
// Create Hono app // Create Hono app
const app = new Hono(); const app = new Hono();
// Enable CORS // Enable CORS
app.use('*', cors()); app.use('*', cors());
// Create MCP handler // Create MCP handler
const mcpHandler = new MCPStreamableHttpServer(server); const mcpHandler = new MCPStreamableHttpServer(server);
// Add a simple health check endpoint // Add a simple health check endpoint
app.get('/health', (c) => { app.get('/health', (c) => {
return c.json({ status: 'OK', server: SERVER_NAME, version: SERVER_VERSION }); return c.json({ status: 'OK', server: SERVER_NAME, version: SERVER_VERSION });
}); });
// Main MCP endpoint supporting both GET and POST // Main MCP endpoint supporting both GET and POST
app.get("/mcp", (c) => mcpHandler.handleGetRequest(c)); app.get('/mcp', (c) => mcpHandler.handleGetRequest(c));
app.post("/mcp", (c) => mcpHandler.handlePostRequest(c)); app.post('/mcp', (c) => mcpHandler.handlePostRequest(c));
// Static files for the web client (if any) // Static files for the web client (if any)
app.get('/*', async (c) => { app.get('/*', async (c) => {
const filePath = c.req.path === '/' ? '/index.html' : c.req.path; const filePath = c.req.path === '/' ? '/index.html' : c.req.path;
@ -189,37 +184,51 @@ export async function setupStreamableHttpServer(server: Server, port = 3000) {
const fs = await import('fs'); const fs = await import('fs');
const path = await import('path'); const path = await import('path');
const { fileURLToPath } = await import('url'); const { fileURLToPath } = await import('url');
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const publicPath = path.join(__dirname, '..', '..', 'public'); const publicPath = path.join(__dirname, '..', '..', 'public');
const fullPath = path.join(publicPath, filePath); const fullPath = path.join(publicPath, filePath);
// Simple security check to prevent directory traversal // Simple security check to prevent directory traversal
if (!fullPath.startsWith(publicPath)) { if (!fullPath.startsWith(publicPath)) {
return c.text('Forbidden', 403); return c.text('Forbidden', 403);
} }
try { try {
const stat = fs.statSync(fullPath); const stat = fs.statSync(fullPath);
if (stat.isFile()) { if (stat.isFile()) {
const content = fs.readFileSync(fullPath); const content = fs.readFileSync(fullPath);
// Set content type based on file extension // Set content type based on file extension
const ext = path.extname(fullPath).toLowerCase(); const ext = path.extname(fullPath).toLowerCase();
let contentType = 'text/plain'; let contentType = 'text/plain';
switch (ext) { switch (ext) {
case '.html': contentType = 'text/html'; break; case '.html':
case '.css': contentType = 'text/css'; break; contentType = 'text/html';
case '.js': contentType = 'text/javascript'; break; break;
case '.json': contentType = 'application/json'; break; case '.css':
case '.png': contentType = 'image/png'; break; contentType = 'text/css';
case '.jpg': contentType = 'image/jpeg'; break; break;
case '.svg': contentType = 'image/svg+xml'; break; case '.js':
contentType = 'text/javascript';
break;
case '.json':
contentType = 'application/json';
break;
case '.png':
contentType = 'image/png';
break;
case '.jpg':
contentType = 'image/jpeg';
break;
case '.svg':
contentType = 'image/svg+xml';
break;
} }
return new Response(content, { return new Response(content, {
headers: { 'Content-Type': contentType } headers: { 'Content-Type': contentType },
}); });
} }
} catch (err) { } catch (err) {
@ -230,19 +239,22 @@ export async function setupStreamableHttpServer(server: Server, port = 3000) {
console.error('Error serving static file:', err); console.error('Error serving static file:', err);
return c.text('Internal Server Error', 500); return c.text('Internal Server Error', 500);
} }
return c.text('Not Found', 404); return c.text('Not Found', 404);
}); });
// Start the server // Start the server
serve({ serve(
fetch: app.fetch, {
port fetch: app.fetch,
}, (info) => { port,
console.error(`MCP StreamableHTTP Server running at http://localhost:${info.port}`); },
console.error(`- MCP Endpoint: http://localhost:${info.port}/mcp`); (info) => {
console.error(`- Health Check: http://localhost:${info.port}/health`); console.error(`MCP StreamableHTTP Server running at http://localhost:${info.port}`);
}); console.error(`- MCP Endpoint: http://localhost:${info.port}/mcp`);
console.error(`- Health Check: http://localhost:${info.port}/health`);
}
);
return app; return app;
} }

View File

@ -17,12 +17,6 @@
"sourceMap": true, "sourceMap": true,
"forceConsistentCasingInFileNames": true "forceConsistentCasingInFileNames": true
}, },
"include": [ "include": ["src/**/*"],
"src/**/*" "exclude": ["node_modules", "build", "**/*.test.ts"]
], }
"exclude": [
"node_modules",
"build",
"**/*.test.ts"
]
}

File diff suppressed because it is too large Load Diff

View File

@ -17,11 +17,6 @@
"sourceMap": true, "sourceMap": true,
"forceConsistentCasingInFileNames": true "forceConsistentCasingInFileNames": true
}, },
"include": [ "include": ["src/**/*"],
"src/**/*" "exclude": ["node_modules", "build"]
], }
"exclude": [
"node_modules",
"build"
]
}

6469
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,64 +1,69 @@
{ {
"name": "openapi-mcp-generator", "name": "openapi-mcp-generator",
"version": "3.1.0", "version": "3.2.0",
"description": "Generates MCP server code from OpenAPI specifications", "description": "Generates MCP server code from OpenAPI specifications",
"license": "MIT", "license": "MIT",
"author": "Harsha", "author": "Harsha",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
"bin": { "bin": {
"openapi-mcp-generator": "./dist/index.js" "openapi-mcp-generator": "./bin/openapi-mcp-generator.js"
}, },
"files": [ "main": "dist/index.js",
"dist", "files": [
"README.md", "dist",
"LICENSE" "bin",
], "README.md",
"scripts": { "LICENSE"
"start": "node dist/index.js", ],
"clean": "rimraf dist", "types": "./dist/index.d.ts",
"typecheck": "tsc --noEmit", "scripts": {
"build": "tsc && chmod 755 dist/index.js", "start": "node dist/index.js",
"version:patch": "npm version patch", "clean": "rimraf dist",
"version:minor": "npm version minor", "format.check": "prettier --check .",
"version:major": "npm version major" "format.write": "prettier --write .",
}, "typecheck": "tsc --noEmit",
"keywords": [ "build": "tsc && chmod 755 dist/index.js bin/openapi-mcp-generator.js",
"openapi", "version:patch": "npm version patch",
"mcp", "version:minor": "npm version minor",
"model-context-protocol", "version:major": "npm version major"
"generator", },
"llm", "keywords": [
"ai", "openapi",
"api" "mcp",
], "model-context-protocol",
"repository": { "generator",
"type": "git", "llm",
"url": "git+https://github.com/harsha-iiiv/openapi-mcp-generator.git" "ai",
}, "api"
"bugs": { ],
"url": "https://github.com/harsha-iiiv/openapi-mcp-generator/issues" "repository": {
}, "type": "git",
"homepage": "https://github.com/harsha-iiiv/openapi-mcp-generator#readme", "url": "git+https://github.com/harsha-iiiv/openapi-mcp-generator.git"
"dependencies": { },
"@apidevtools/swagger-parser": "^10.1.1", "bugs": {
"commander": "^13.1.0", "url": "https://github.com/harsha-iiiv/openapi-mcp-generator/issues"
"openapi-types": "^12.1.3" },
}, "homepage": "https://github.com/harsha-iiiv/openapi-mcp-generator#readme",
"devDependencies": { "dependencies": {
"@types/node": "^22.15.2", "@apidevtools/swagger-parser": "^10.1.1",
"@typescript-eslint/eslint-plugin": "^8.31.0", "commander": "^13.1.0",
"@typescript-eslint/parser": "^8.31.0", "openapi-types": "^12.1.3"
"eslint": "^9.25.1", },
"prettier": "^3.5.3", "devDependencies": {
"rimraf": "^6.0.1", "@types/node": "^22.17.2",
"typescript": "^5.8.3" "@typescript-eslint/eslint-plugin": "^8.39.1",
}, "@typescript-eslint/parser": "^8.39.1",
"peerDependencies": { "eslint": "^9.33.0",
"@modelcontextprotocol/sdk": "^1.10.0", "prettier": "^3.6.2",
"json-schema-to-zod": "^2.6.1", "rimraf": "^6.0.1",
"zod": "^3.24.3" "typescript": "^5.9.2"
} },
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"json-schema-to-zod": "^2.6.1",
"zod": "^3.24.3"
}
} }

View File

@ -15,67 +15,77 @@ import { determineBaseUrl } from './utils/url.js';
export interface GetToolsOptions { export interface GetToolsOptions {
/** Optional base URL to override the one in the OpenAPI spec */ /** Optional base URL to override the one in the OpenAPI spec */
baseUrl?: string; baseUrl?: string;
/** Whether to dereference the OpenAPI spec */ /** Whether to dereference the OpenAPI spec */
dereference?: boolean; dereference?: boolean;
/** Array of operation IDs to exclude from the tools list */ /** Array of operation IDs to exclude from the tools list */
excludeOperationIds?: string[]; excludeOperationIds?: string[];
/** 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
* @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
*/ */
export async function getToolsFromOpenApi( export async function getToolsFromOpenApi(
specPathOrUrl: string, specPathOrUrl: string | OpenAPIV3.Document,
options: GetToolsOptions = {} options: GetToolsOptions = {}
): Promise<McpToolDefinition[]> { ): Promise<McpToolDefinition[]> {
try { try {
// Parse the OpenAPI spec // Parse the OpenAPI spec
const api = options.dereference const api = isOpenApiDocument(specPathOrUrl)
? (await SwaggerParser.dereference(specPathOrUrl)) as OpenAPIV3.Document ? specPathOrUrl
: (await SwaggerParser.parse(specPathOrUrl)) as OpenAPIV3.Document; : options.dereference
? ((await SwaggerParser.dereference(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); const allTools = extractToolsFromApi(api, options.defaultInclude ?? true);
// Add base URL to each tool // Add base URL to each tool
const baseUrl = determineBaseUrl(api, options.baseUrl); const baseUrl = determineBaseUrl(api, options.baseUrl);
// Apply filters to exclude specified operationIds and custom filter function // Apply filters to exclude specified operationIds and custom filter function
let filteredTools = allTools; let filteredTools = allTools;
// Filter by excluded operation IDs if provided // Filter by excluded operation IDs if provided
if (options.excludeOperationIds && options.excludeOperationIds.length > 0) { if (options.excludeOperationIds && options.excludeOperationIds.length > 0) {
const excludeSet = new Set(options.excludeOperationIds); const excludeSet = new Set(options.excludeOperationIds);
filteredTools = filteredTools.filter(tool => !excludeSet.has(tool.operationId)); filteredTools = filteredTools.filter((tool) => !excludeSet.has(tool.operationId));
} }
// Apply custom filter function if provided // Apply custom filter function if provided
if (options.filterFn) { if (options.filterFn) {
filteredTools = filteredTools.filter(options.filterFn); filteredTools = filteredTools.filter(options.filterFn);
} }
// Return the filtered tools with base URL added // Return the filtered tools with base URL added
return filteredTools.map(tool => ({ return filteredTools.map((tool) => ({
...tool, ...tool,
baseUrl: baseUrl || '', baseUrl: baseUrl || '',
})); }));
} catch (error) { } catch (error) {
// Provide more context for the error // Provide more context for the error
if (error instanceof Error) { if (error instanceof Error) {
throw new Error(`Failed to extract tools from OpenAPI: ${error.message}`); // Preserve original stack/context
throw new Error(`Failed to extract tools from OpenAPI: ${error.message}`, { cause: error });
} }
throw error; throw error;
} }
} }
// Export types for convenience // Export types for convenience
export { McpToolDefinition }; export { McpToolDefinition };

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); const tools = extractToolsFromApi(api, options.defaultInclude ?? true);
// Determine base URL // Determine base URL
const determinedBaseUrl = determineBaseUrl(api, options.baseUrl); const determinedBaseUrl = determineBaseUrl(api, options.baseUrl);

View File

@ -1,4 +1,3 @@
/** /**
* Generator for StreamableHTTP server code for the MCP server using Hono * Generator for StreamableHTTP server code for the MCP server using Hono
*/ */

View File

@ -30,6 +30,8 @@ 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' };
// Export programmatic API // Export programmatic API
export { getToolsFromOpenApi, McpToolDefinition, GetToolsOptions } from './api.js'; export { getToolsFromOpenApi, McpToolDefinition, GetToolsOptions } from './api.js';
@ -71,23 +73,30 @@ 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('3.1.0'); // Match package.json version .version(pkg.version) // Match package.json version
.action((options) => {
// Check if module is being run directly (not imported) runGenerator(options).catch((error) => {
const isMainModule = process.argv[1] === new URL(import.meta.url).pathname;
if (isMainModule) {
// Parse arguments explicitly from process.argv
program.parse(process.argv);
// Run with the parsed options
runGenerator(program.opts<CliOptions & { force?: boolean }>())
.catch((error) => {
console.error('Unhandled error:', error); console.error('Unhandled error:', error);
process.exit(1); process.exit(1);
}); });
} });
// Export the program object for use in bin stub
export { program };
/** /**
* Main function to run the generator * Main function to run the generator
@ -288,4 +297,4 @@ async function runGenerator(options: CliOptions & { force?: boolean }) {
} }
// Export the run function for programmatic usage // Export the run function for programmatic usage
export { runGenerator as generateMcpServer }; export { runGenerator as generateMcpServer };

View File

@ -5,6 +5,7 @@ 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
@ -12,7 +13,10 @@ import { McpToolDefinition } from '../types/index.js';
* @param api OpenAPI document * @param api OpenAPI document
* @returns Array of MCP tool definitions * @returns Array of MCP tool definitions
*/ */
export function extractToolsFromApi(api: OpenAPIV3.Document): McpToolDefinition[] { export function extractToolsFromApi(
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 || [];
@ -26,15 +30,43 @@ export function extractToolsFromApi(api: OpenAPIV3.Document): McpToolDefinition[
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;
// Sanitize the name to be MCP-compatible (only a-z, 0-9, _, -) // Sanitize the name to be MCP-compatible (only a-z, 0-9, _, -)
baseName = baseName baseName = baseName.replace(/\./g, '_').replace(/[^a-z0-9_-]/gi, '_');
.replace(/\./g, '_')
.replace(/[^a-z0-9_-]/gi, '_')
.toLowerCase();
let finalToolName = baseName; let finalToolName = baseName;
let counter = 1; let counter = 1;
@ -153,13 +185,15 @@ export function generateInputSchemaAndDetails(operation: OpenAPIV3.OperationObje
} }
/** /**
* Maps an OpenAPI schema to a JSON Schema * Maps an OpenAPI schema to a JSON Schema with cycle protection.
* *
* @param schema OpenAPI schema object or reference * @param schema OpenAPI schema object or reference
* @param seen WeakSet tracking already visited schema objects
* @returns JSON Schema representation * @returns JSON Schema representation
*/ */
export function mapOpenApiSchemaToJsonSchema( export function mapOpenApiSchemaToJsonSchema(
schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject,
seen: WeakSet<object> = new WeakSet()
): JSONSchema7 | boolean { ): JSONSchema7 | boolean {
// Handle reference objects // Handle reference objects
if ('$ref' in schema) { if ('$ref' in schema) {
@ -170,57 +204,73 @@ export function mapOpenApiSchemaToJsonSchema(
// Handle boolean schemas // Handle boolean schemas
if (typeof schema === 'boolean') return schema; if (typeof schema === 'boolean') return schema;
// Create a copy of the schema to modify // Detect cycles
const jsonSchema: JSONSchema7 = { ...schema } as any; if (seen.has(schema)) {
console.warn(
// Convert integer type to number (JSON Schema compatible) `Cycle detected in schema${schema.title ? ` "${schema.title}"` : ''}, returning generic object to break recursion.`
if (schema.type === 'integer') jsonSchema.type = 'number'; );
return { type: 'object' };
// Remove OpenAPI-specific properties that aren't in JSON Schema
delete (jsonSchema as any).nullable;
delete (jsonSchema as any).example;
delete (jsonSchema as any).xml;
delete (jsonSchema as any).externalDocs;
delete (jsonSchema as any).deprecated;
delete (jsonSchema as any).readOnly;
delete (jsonSchema as any).writeOnly;
// Handle nullable properties by adding null to the type
if (schema.nullable) {
if (Array.isArray(jsonSchema.type)) {
if (!jsonSchema.type.includes('null')) jsonSchema.type.push('null');
} else if (typeof jsonSchema.type === 'string') {
jsonSchema.type = [jsonSchema.type as JSONSchema7TypeName, 'null'];
} else if (!jsonSchema.type) {
jsonSchema.type = 'null';
}
} }
seen.add(schema);
// Recursively process object properties try {
if (jsonSchema.type === 'object' && jsonSchema.properties) { // Create a copy of the schema to modify
const mappedProps: { [key: string]: JSONSchema7 | boolean } = {}; const jsonSchema: JSONSchema7 = { ...schema } as any;
for (const [key, propSchema] of Object.entries(jsonSchema.properties)) { // Convert integer type to number (JSON Schema compatible)
if (typeof propSchema === 'object' && propSchema !== null) { if (schema.type === 'integer') jsonSchema.type = 'number';
mappedProps[key] = mapOpenApiSchemaToJsonSchema(propSchema as OpenAPIV3.SchemaObject);
} else if (typeof propSchema === 'boolean') { // Remove OpenAPI-specific properties that aren't in JSON Schema
mappedProps[key] = propSchema; delete (jsonSchema as any).nullable;
delete (jsonSchema as any).example;
delete (jsonSchema as any).xml;
delete (jsonSchema as any).externalDocs;
delete (jsonSchema as any).deprecated;
delete (jsonSchema as any).readOnly;
delete (jsonSchema as any).writeOnly;
// Handle nullable properties by adding null to the type
if (schema.nullable) {
if (Array.isArray(jsonSchema.type)) {
if (!jsonSchema.type.includes('null')) jsonSchema.type.push('null');
} else if (typeof jsonSchema.type === 'string') {
jsonSchema.type = [jsonSchema.type as JSONSchema7TypeName, 'null'];
} else if (!jsonSchema.type) {
jsonSchema.type = 'null';
} }
} }
jsonSchema.properties = mappedProps; // Recursively process object properties
} if (jsonSchema.type === 'object' && jsonSchema.properties) {
const mappedProps: { [key: string]: JSONSchema7 | boolean } = {};
// Recursively process array items for (const [key, propSchema] of Object.entries(jsonSchema.properties)) {
if ( if (typeof propSchema === 'object' && propSchema !== null) {
jsonSchema.type === 'array' && mappedProps[key] = mapOpenApiSchemaToJsonSchema(
typeof jsonSchema.items === 'object' && propSchema as OpenAPIV3.SchemaObject,
jsonSchema.items !== null seen
) { );
jsonSchema.items = mapOpenApiSchemaToJsonSchema( } else if (typeof propSchema === 'boolean') {
jsonSchema.items as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject mappedProps[key] = propSchema;
); }
} }
return jsonSchema; jsonSchema.properties = mappedProps;
}
// Recursively process array items
if (
jsonSchema.type === 'array' &&
typeof jsonSchema.items === 'object' &&
jsonSchema.items !== null
) {
jsonSchema.items = mapOpenApiSchemaToJsonSchema(
jsonSchema.items as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject,
seen
);
}
return jsonSchema;
} finally {
seen.delete(schema);
}
} }

View File

@ -29,6 +29,12 @@ 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,6 +1,7 @@
/** /**
* 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
@ -110,3 +111,63 @@ 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;
}

View File

@ -1,18 +1,18 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"declaration": true, "declaration": true,
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"resolveJsonModule": true "resolveJsonModule": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"] "exclude": ["node_modules", "dist", "test"]
} }