Compare commits

..

1 Commits

Author SHA1 Message Date
coderabbitai[bot]
2823c75bb2
📝 Add docstrings to fix/schema-one-of-parser
Docstrings generation was requested by @harsha-iiiv.

* https://github.com/harsha-iiiv/openapi-mcp-generator/pull/16#issuecomment-2900651451

The following files were modified:

* `src/parser/extract-tools.ts`
2025-05-22 10:53:37 +00:00
41 changed files with 7820 additions and 9502 deletions

View File

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

View File

@ -1,40 +0,0 @@
---
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.

View File

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

View File

@ -1,19 +0,0 @@
---
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.

View File

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

View File

@ -1,30 +0,0 @@
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:
node-version: '20'
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
- name: Install dependencies
@ -35,8 +35,9 @@ jobs:
- name: Check version change
id: check_version
run: |
git fetch origin main
CURRENT_VERSION=$(node -p "require('./package.json').version")
PREV_VERSION=$(git show HEAD^:package.json | grep '"version":' | sed -E 's/.*"version": *"([^"]+)".*/\1/')
PREV_VERSION=$(git show origin/main:package.json | grep '"version":' | sed -E 's/.*"version": *"([^"]+)".*/\1/')
if [ "$CURRENT_VERSION" != "$PREV_VERSION" ]; then
echo "Version changed from $PREV_VERSION to $CURRENT_VERSION"
echo "version_changed=true" >> $GITHUB_OUTPUT

View File

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

View File

@ -5,64 +5,9 @@ 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/),
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
### Added
- Programmatic API to extract MCP tool definitions from OpenAPI specs
- New exportable `getToolsFromOpenApi` function for direct integration in code
- Advanced filtering capabilities for programmatic tool extraction
@ -70,32 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Updated README with programmatic API usage examples
### Changed
- Improved module structure with better exports
- 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
### Added
- Runtime argument validation using Zod
- JSON Schema to Zod schema conversion
- Improved error handling and formatting
@ -106,7 +31,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Support for multiple content types
### Changed
- Simplified transport layer to only support stdio transport
- Removed support for WebSocket and HTTP transports
- Updated to use @modelcontextprotocol/sdk v1.9.0
@ -116,7 +40,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- More robust OpenAPI schema processing
### Fixed
- Path parameter resolution in URLs
- Content-Type header handling
- Response processing for different content types
@ -126,9 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.0.0] - Initial Release
### Added
- Basic OpenAPI to MCP server generation
- Support for GET, POST, PUT, DELETE methods
- Basic error handling
- Simple CLI interface
- Basic TypeScript support
- Basic TypeScript support

21
LICENSE
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,13 +13,11 @@ This API uses OAuth2 for authentication. The MCP server can handle OAuth2 authen
- `OAUTH_CLIENT_ID_PETSTORE_AUTH`: Your OAuth client ID
- `OAUTH_CLIENT_SECRET_PETSTORE_AUTH`: Your OAuth client secret
## 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.
When making API requests, the server will:
1. Check for a cached token that's still valid
2. Use the cached token if available
3. Request a new token if no valid cached token exists

View File

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

View File

@ -1,406 +1,393 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>swagger-petstore---openapi-3-0 MCP Test Client</title>
<style>
body {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.5;
}
h1 {
margin-bottom: 10px;
}
.container {
display: flex;
flex-direction: column;
height: calc(100vh - 150px);
}
#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>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>swagger-petstore---openapi-3-0 MCP Test Client</title>
<style>
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.5;
}
h1 { margin-bottom: 10px; }
.container {
display: flex;
flex-direction: column;
height: calc(100vh - 150px);
}
#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="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>
<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>
<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>
<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}`);
<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}`);
}
// Toggle debug console
showDebugBtn.addEventListener('click', () => {
if (debugDiv.style.display === 'block') {
debugDiv.style.display = 'none';
showDebugBtn.textContent = 'Show Debug Console';
});
// 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 {
debugDiv.style.display = 'block';
showDebugBtn.textContent = 'Hide Debug Console';
log('INFO', `Received other message: ${JSON.stringify(data)}`);
}
});
// 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}`);
}
});
// 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 parsing SSE message: ${error.message}`);
appendMessage('system', `Error parsing message: ${event.data}`);
}
// 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) {
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}`);
});
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)
}
}
// Try to parse arguments from user input
// Format: toolName param1=value1 param2=value2
function parseArguments(text) {
const parts = text.split(' ');
if (parts.length <= 1) return {};
const args = {};
// Skip the first part (tool name) and process the rest
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
const equalsIndex = part.indexOf('=');
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();
};
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)
});
// 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>
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
// Format: toolName param1=value1 param2=value2
function parseArguments(text) {
const parts = text.split(' ');
if (parts.length <= 1) return {};
const args = {};
// Skip the first part (tool name) and process the rest
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
const equalsIndex = part.indexOf('=');
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();
});
// 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,304 +1,286 @@
/**
* 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 { cors } from 'hono/cors';
import { serve } from '@hono/node-server';
import { streamSSE } from 'hono/streaming';
import { v4 as uuid } from 'uuid';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js';
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { JSONRPCMessage, JSONRPCMessageSchema } from "@modelcontextprotocol/sdk/types.js";
import type { Context } from 'hono';
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_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 {
private _sessionId: string;
private stream: SSEStreamingApi;
private messageUrl: string;
private _sessionId: string;
private stream: SSEStreamingApi;
private messageUrl: string;
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
constructor(messageUrl: string, stream: SSEStreamingApi) {
this._sessionId = uuid();
this.stream = stream;
this.messageUrl = messageUrl;
constructor(messageUrl: string, stream: SSEStreamingApi) {
this._sessionId = uuid();
this.stream = stream;
this.messageUrl = messageUrl;
// Set up stream abort handler
this.stream.onAbort(() => {
console.error(`SSE connection aborted for session ${this._sessionId}`);
this.close();
});
}
// Set up stream abort handler
this.stream.onAbort(() => {
console.error(`SSE connection aborted for session ${this._sessionId}`);
this.close();
});
get sessionId(): string {
return this._sessionId;
}
async start(): Promise<void> {
if (this.stream.closed) {
throw new Error('SSE transport already closed!');
}
get sessionId(): string {
return this._sessionId;
}
async start(): Promise<void> {
if (this.stream.closed) {
throw new Error('SSE transport already closed!');
// Send the endpoint information
await this.stream.writeSSE({
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
}
}
});
}
// Send the endpoint information
await this.stream.writeSSE({
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,
},
},
});
async handlePostMessage(c: Context): Promise<Response> {
if (this.stream?.closed) {
return c.text('SSE connection closed', 400);
}
async handlePostMessage(c: Context): Promise<Response> {
if (this.stream?.closed) {
return c.text('SSE connection closed', 400);
}
try {
// Parse and validate the message
const body = await c.req.json();
try {
// Parse and validate the message
const body = await c.req.json();
try {
// Parse and validate the message
const parsedMessage = JSONRPCMessageSchema.parse(body);
// Forward to the message handler
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);
const parsedMessage = JSONRPCMessageSchema.parse(body);
// Forward to the message handler
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 processing request:', error);
return c.text('Error processing message', 400);
console.error('Error parsing message:', error);
return c.text('Invalid message format', 400);
}
} catch (error) {
if (this.onerror) {
this.onerror(error instanceof Error ? error : new Error(String(error)));
}
console.error('Error processing request:', error);
return c.text('Error processing message', 400);
}
}
async close(): Promise<void> {
if (this.stream && !this.stream.closed) {
this.stream.abort();
}
if (this.onclose) {
this.onclose();
}
async close(): Promise<void> {
if (this.stream && !this.stream.closed) {
this.stream.abort();
}
async send(message: JSONRPCMessage): Promise<void> {
if (this.stream.closed) {
throw new Error('Not connected');
}
await this.stream.writeSSE({
event: 'message',
data: JSON.stringify(message),
});
if (this.onclose) {
this.onclose();
}
}
async send(message: JSONRPCMessage): Promise<void> {
if (this.stream.closed) {
throw new Error('Not connected');
}
await this.stream.writeSSE({
event: 'message',
data: JSON.stringify(message)
});
}
}
/**
* Sets up a web server for the MCP server using Server-Sent Events (SSE)
*
* @param server The MCP Server instance
* @param port The port to listen on (default: 3000)
* @returns The Hono app instance
*/
* Sets up a web server for the MCP server using Server-Sent Events (SSE)
*
* @param server The MCP Server instance
* @param port The port to listen on (default: 3000)
* @returns The Hono app instance
*/
export async function setupWebServer(server: Server, port = 3000) {
// Create Hono app
const app = new Hono();
// Create Hono app
const app = new Hono();
// Enable CORS
app.use('*', cors());
// Enable CORS
app.use('*', cors());
// Store active SSE transports by session ID
const transports: { [sessionId: string]: SSETransport } = {};
// Store active SSE transports by session ID
const transports: {[sessionId: string]: SSETransport} = {};
// Add a simple health check endpoint
app.get('/health', (c) => {
return c.json({ status: 'OK', server: SERVER_NAME, version: SERVER_VERSION });
});
// Add a simple health check endpoint
app.get('/health', (c) => {
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
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;
// 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 {
// Use Node.js fs to serve static files
const fs = await import('fs');
const path = await import('path');
const { fileURLToPath } = await import('url');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const publicPath = path.join(__dirname, '..', '..', 'public');
const fullPath = path.join(publicPath, filePath);
// Simple security check to prevent directory traversal
if (!fullPath.startsWith(publicPath)) {
return c.text('Forbidden', 403);
}
try {
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 },
});
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);
}
} catch (err) {
// File not found or other error
return c.text('Not Found', 404);
};
// 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 {
// Use Node.js fs to serve static files
const fs = await import('fs');
const path = await import('path');
const { fileURLToPath } = await import('url');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const publicPath = path.join(__dirname, '..', '..', 'public');
const fullPath = path.join(publicPath, filePath);
// Simple security check to prevent directory traversal
if (!fullPath.startsWith(publicPath)) {
return c.text('Forbidden', 403);
}
try {
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) {
console.error('Error serving static file:', err);
return c.text('Internal Server Error', 500);
// File not found or other error
return c.text('Not Found', 404);
}
} catch (err) {
console.error('Error serving static file:', err);
return c.text('Internal Server Error', 500);
}
return c.text('Not Found', 404);
});
return c.text('Not Found', 404);
});
// 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`);
});
// 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;
return app;
}

View File

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

View File

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

View File

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

View File

@ -13,13 +13,11 @@ This API uses OAuth2 for authentication. The MCP server can handle OAuth2 authen
- `OAUTH_CLIENT_ID_PETSTORE_AUTH`: Your OAuth client ID
- `OAUTH_CLIENT_SECRET_PETSTORE_AUTH`: Your OAuth client secret
## 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.
When making API requests, the server will:
1. Check for a cached token that's still valid
2. Use the cached token if available
3. Request a new token if no valid cached token exists

View File

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

View File

@ -1,424 +1,402 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>swagger-petstore---openapi-3-0 MCP StreamableHTTP Test Client</title>
<style>
body {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.5;
}
h1 {
margin-bottom: 10px;
}
.container {
display: flex;
flex-direction: column;
height: calc(100vh - 150px);
}
#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>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>swagger-petstore---openapi-3-0 MCP StreamableHTTP Test Client</title>
<style>
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.5;
}
h1 { margin-bottom: 10px; }
.container {
display: flex;
flex-direction: column;
height: calc(100vh - 150px);
}
#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>
<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>
<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>
<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}`);
<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';
}
// 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;
});
// 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: {}
}
// 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}`);
};
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 list of available tools
async function listTools() {
try {
const requestBody = {
jsonrpc: '2.0',
id: messageId++,
method: 'listTools',
params: {},
};
log('REQUEST', JSON.stringify(requestBody));
const response = await fetch('http://localhost:3000/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 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}`);
// 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;
}
}
// 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));
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}`);
// 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.';
}
// Try to parse arguments from user input
// Format: toolName param1=value1 param2=value2
function parseArguments(text) {
}
// Get list of available tools
async function listTools() {
try {
const requestBody = {
jsonrpc: '2.0',
id: messageId++,
method: 'listTools',
params: {}
};
log('REQUEST', JSON.stringify(requestBody));
const response = await fetch('http://localhost:3000/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 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}`);
}
}
// 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(' ');
if (parts.length <= 1) return {};
const args = {};
// Skip the first part (tool name) and process the rest
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
const equalsIndex = part.indexOf('=');
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;
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;
}
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;
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));
}
conversation.appendChild(messageDiv);
conversation.scrollTop = conversation.scrollHeight;
} catch (error) {
log('ERROR', `Error sending message: ${error.message}`);
appendMessage('system', `Error sending message: ${error.message}`);
}
// 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>
}
// Try to parse arguments from user input
// Format: toolName param1=value1 param2=value2
function parseArguments(text) {
const parts = text.split(' ');
if (parts.length <= 1) return {};
const args = {};
// Skip the first part (tool name) and process the rest
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
const equalsIndex = part.indexOf('=');
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,3 +1,4 @@
/**
* StreamableHTTP server setup for HTTP-based MCP communication using Hono
*/
@ -5,17 +6,17 @@ import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { serve } from '@hono/node-server';
import { v4 as uuid } from 'uuid';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { InitializeRequestSchema, JSONRPCError } from '@modelcontextprotocol/sdk/types.js';
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { InitializeRequestSchema, JSONRPCError } from "@modelcontextprotocol/sdk/types.js";
import { toReqRes, toFetchResponse } from 'fetch-to-node';
// Import server configuration constants
import { SERVER_NAME, SERVER_VERSION } from './index.js';
// Constants
const SESSION_ID_HEADER_NAME = 'mcp-session-id';
const JSON_RPC = '2.0';
const SESSION_ID_HEADER_NAME = "mcp-session-id";
const JSON_RPC = "2.0";
/**
* StreamableHTTP MCP Server handler
@ -23,102 +24,106 @@ const JSON_RPC = '2.0';
class MCPStreamableHttpServer {
server: Server;
// Store active transports by session ID
transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
transports: {[sessionId: string]: StreamableHTTPServerTransport} = {};
constructor(server: Server) {
this.server = server;
}
/**
* Handle GET requests (typically used for static files)
*/
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, {
Allow: 'POST',
'Allow': 'POST'
});
}
/**
* Handle POST requests (all MCP communication)
*/
async handlePostRequest(c: any) {
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 {
const body = await c.req.json();
// Convert Fetch Request to Node.js req/res
const { req, res } = toReqRes(c.req.raw);
// Reuse existing transport if we have a session ID
if (sessionId && this.transports[sessionId]) {
const transport = this.transports[sessionId];
// Handle the request with the transport
await transport.handleRequest(req, res, body);
// Cleanup when the response ends
res.on('close', () => {
console.error(`Request closed for session ${sessionId}`);
});
// Convert Node.js response back to Fetch Response
return toFetchResponse(res);
}
// Create new transport for initialize requests
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({
sessionIdGenerator: () => uuid(),
});
// Add error handler for debug purposes
transport.onerror = (err) => {
console.error('StreamableHTTP transport error:', err);
};
// Connect the transport to the MCP server
await this.server.connect(transport);
// Handle the request with the transport
await transport.handleRequest(req, res, body);
// Store the transport if we have a session ID
const newSessionId = transport.sessionId;
if (newSessionId) {
console.error(`New session established: ${newSessionId}`);
this.transports[newSessionId] = transport;
// Set up clean-up for when the transport is closed
transport.onclose = () => {
console.error(`Session closed: ${newSessionId}`);
delete this.transports[newSessionId];
};
}
// Cleanup when the response ends
res.on('close', () => {
console.error(`Request closed for new session`);
});
// Convert Node.js response back to Fetch Response
return toFetchResponse(res);
}
// Invalid request (no session ID and not initialize)
return c.json(this.createErrorResponse('Bad Request: invalid session ID or method.'), 400);
return c.json(
this.createErrorResponse("Bad Request: invalid session ID or method."),
400
);
} catch (error) {
console.error('Error handling MCP request:', error);
return c.json(this.createErrorResponse('Internal server error.'), 500);
return c.json(
this.createErrorResponse("Internal server error."),
500
);
}
}
/**
* Create a JSON-RPC error response
*/
@ -132,7 +137,7 @@ class MCPStreamableHttpServer {
id: uuid(),
};
}
/**
* Check if the request is an initialize request
*/
@ -141,18 +146,18 @@ class MCPStreamableHttpServer {
const result = InitializeRequestSchema.safeParse(data);
return result.success;
};
if (Array.isArray(body)) {
return body.some((request) => isInitial(request));
return body.some(request => isInitial(request));
}
return isInitial(body);
}
}
/**
* Sets up a web server for the MCP server using StreamableHTTP transport
*
*
* @param server The MCP Server instance
* @param port The port to listen on (default: 3000)
* @returns The Hono app instance
@ -160,22 +165,22 @@ class MCPStreamableHttpServer {
export async function setupStreamableHttpServer(server: Server, port = 3000) {
// Create Hono app
const app = new Hono();
// Enable CORS
app.use('*', cors());
// Create MCP handler
const mcpHandler = new MCPStreamableHttpServer(server);
// Add a simple health check endpoint
app.get('/health', (c) => {
return c.json({ status: 'OK', server: SERVER_NAME, version: SERVER_VERSION });
});
// Main MCP endpoint supporting both GET and POST
app.get('/mcp', (c) => mcpHandler.handleGetRequest(c));
app.post('/mcp', (c) => mcpHandler.handlePostRequest(c));
app.get("/mcp", (c) => mcpHandler.handleGetRequest(c));
app.post("/mcp", (c) => mcpHandler.handlePostRequest(c));
// Static files for the web client (if any)
app.get('/*', async (c) => {
const filePath = c.req.path === '/' ? '/index.html' : c.req.path;
@ -184,51 +189,37 @@ export async function setupStreamableHttpServer(server: Server, port = 3000) {
const fs = await import('fs');
const path = await import('path');
const { fileURLToPath } = await import('url');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const publicPath = path.join(__dirname, '..', '..', 'public');
const fullPath = path.join(publicPath, filePath);
// Simple security check to prevent directory traversal
if (!fullPath.startsWith(publicPath)) {
return c.text('Forbidden', 403);
}
try {
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;
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 },
headers: { 'Content-Type': contentType }
});
}
} catch (err) {
@ -239,22 +230,19 @@ export async function setupStreamableHttpServer(server: Server, port = 3000) {
console.error('Error serving static file:', err);
return c.text('Internal Server Error', 500);
}
return c.text('Not Found', 404);
});
// Start the server
serve(
{
fetch: app.fetch,
port,
},
(info) => {
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`);
}
);
serve({
fetch: app.fetch,
port
}, (info) => {
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;
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

6467
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -30,8 +30,6 @@ import {
// Import types
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 { getToolsFromOpenApi, McpToolDefinition, GetToolsOptions } from './api.js';
@ -73,30 +71,23 @@ program
'Port for web or streamable-http transport (default: 3000)',
(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')
.version(pkg.version) // Match package.json version
.action((options) => {
runGenerator(options).catch((error) => {
.version('3.1.0'); // Match package.json version
// Check if module is being run directly (not imported)
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);
process.exit(1);
});
});
// Export the program object for use in bin stub
export { program };
}
/**
* Main function to run the generator
@ -297,4 +288,4 @@ async function runGenerator(options: CliOptions & { force?: boolean }) {
}
// Export the run function for programmatic usage
export { runGenerator as generateMcpServer };
export { runGenerator as generateMcpServer };

View File

@ -5,7 +5,6 @@ import { OpenAPIV3 } from 'openapi-types';
import type { JSONSchema7, JSONSchema7TypeName } from 'json-schema';
import { generateOperationId } from '../utils/code-gen.js';
import { McpToolDefinition } from '../types/index.js';
import { shouldIncludeOperationForMcp } from '../utils/helpers.js';
/**
* Extracts tool definitions from an OpenAPI document
@ -13,10 +12,7 @@ import { shouldIncludeOperationForMcp } from '../utils/helpers.js';
* @param api OpenAPI document
* @returns Array of MCP tool definitions
*/
export function extractToolsFromApi(
api: OpenAPIV3.Document,
defaultInclude: boolean = true
): McpToolDefinition[] {
export function extractToolsFromApi(api: OpenAPIV3.Document): McpToolDefinition[] {
const tools: McpToolDefinition[] = [];
const usedNames = new Set<string>();
const globalSecurity = api.security || [];
@ -30,43 +26,15 @@ export function extractToolsFromApi(
const operation = pathItem[method];
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
let baseName = operation.operationId || generateOperationId(method, path);
if (!baseName) continue;
// Sanitize the name to be MCP-compatible (only a-z, 0-9, _, -)
baseName = baseName.replace(/\./g, '_').replace(/[^a-z0-9_-]/gi, '_');
baseName = baseName
.replace(/\./g, '_')
.replace(/[^a-z0-9_-]/gi, '_')
.toLowerCase();
let finalToolName = baseName;
let counter = 1;
@ -185,15 +153,17 @@ export function generateInputSchemaAndDetails(operation: OpenAPIV3.OperationObje
}
/**
* Maps an OpenAPI schema to a JSON Schema with cycle protection.
* Converts an OpenAPI schema or reference object to a JSON Schema representation.
*
* @param schema OpenAPI schema object or reference
* @param seen WeakSet tracking already visited schema objects
* @returns JSON Schema representation
* Handles composite schemas (`oneOf`, `anyOf`, `allOf`) by merging subschemas and combining enum values. Removes OpenAPI-specific properties and adjusts types for JSON Schema compatibility, including handling of nullable fields. Recursively processes nested object properties and array items.
*
* @param schema - The OpenAPI schema object or reference to convert.
* @returns The corresponding JSON Schema object or boolean schema.
*
* @remark If a `$ref` reference cannot be resolved, returns a generic object schema and logs a warning.
*/
export function mapOpenApiSchemaToJsonSchema(
schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject,
seen: WeakSet<object> = new WeakSet()
schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject
): JSONSchema7 | boolean {
// Handle reference objects
if ('$ref' in schema) {
@ -204,73 +174,85 @@ export function mapOpenApiSchemaToJsonSchema(
// Handle boolean schemas
if (typeof schema === 'boolean') return schema;
// Detect cycles
if (seen.has(schema)) {
console.warn(
`Cycle detected in schema${schema.title ? ` "${schema.title}"` : ''}, returning generic object to break recursion.`
);
return { type: 'object' };
}
seen.add(schema);
// Create a copy of the schema to modify
let jsonSchema: JSONSchema7 = { ...schema } as any;
try {
// Create a copy of the schema to modify
const jsonSchema: JSONSchema7 = { ...schema } as any;
if (schema.oneOf || schema.anyOf || schema.allOf) {
const oneSchema = structuredClone(schema.oneOf || schema.anyOf || schema.allOf);
// Convert integer type to number (JSON Schema compatible)
if (schema.type === 'integer') jsonSchema.type = 'number';
// 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';
}
}
// Recursively process object properties
if (jsonSchema.type === 'object' && jsonSchema.properties) {
const mappedProps: { [key: string]: JSONSchema7 | boolean } = {};
for (const [key, propSchema] of Object.entries(jsonSchema.properties)) {
if (typeof propSchema === 'object' && propSchema !== null) {
mappedProps[key] = mapOpenApiSchemaToJsonSchema(
propSchema as OpenAPIV3.SchemaObject,
seen
);
} else if (typeof propSchema === 'boolean') {
mappedProps[key] = propSchema;
if (oneSchema) {
const combinedSchema = mapOpenApiSchemaToJsonSchema(oneSchema[0]);
for (let i = 1; i < oneSchema.length; i++) {
const mappedSubSchema = mapOpenApiSchemaToJsonSchema(oneSchema[i]);
if (typeof mappedSubSchema === 'object' && typeof combinedSchema === 'object') {
// Handle enum values
if (mappedSubSchema.enum) {
if (!combinedSchema.enum) {
combinedSchema.enum = [];
}
// Combine enum values from both schemas
const uniqueEnums = new Set([
...combinedSchema.enum,
...(mappedSubSchema.enum || [])
]);
combinedSchema.enum = Array.from(uniqueEnums);
}
}
}
jsonSchema.properties = mappedProps;
jsonSchema = combinedSchema as JSONSchema7;
}
}
// Convert integer type to number (JSON Schema compatible)
if (schema.type === 'integer') jsonSchema.type = 'number';
// 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';
}
}
// Recursively process object properties
if (jsonSchema.type === 'object' && jsonSchema.properties) {
const mappedProps: { [key: string]: JSONSchema7 | boolean } = {};
for (const [key, propSchema] of Object.entries(jsonSchema.properties)) {
if (typeof propSchema === 'object' && propSchema !== null) {
mappedProps[key] = mapOpenApiSchemaToJsonSchema(propSchema as OpenAPIV3.SchemaObject);
} else if (typeof propSchema === 'boolean') {
mappedProps[key] = propSchema;
}
}
// 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);
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
);
}
return jsonSchema;
}

View File

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

View File

@ -1,7 +1,6 @@
/**
* General helper utilities for OpenAPI to MCP generator
*/
import { OpenAPIV3 } from 'openapi-types';
/**
* Safely stringify a JSON object with proper error handling
@ -111,63 +110,3 @@ export function formatComment(str: string, maxLineLength: number = 80): string {
return lines.join('\n * ');
}
/**
* 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": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}