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", "parser": "@typescript-eslint/parser",
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], "extends": [
"plugins": ["@typescript-eslint"], "eslint:recommended",
"env": { "plugin:@typescript-eslint/recommended"
"node": true, ],
"es2022": true "plugins": ["@typescript-eslint"],
}, "env": {
"rules": { "node": true,
"no-console": "off", "es2022": true
"@typescript-eslint/explicit-function-return-type": "off", },
"@typescript-eslint/no-explicit-any": "off", "rules": {
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] "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: with:
node-version: '20' node-version: '20'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
cache: 'npm' # Enables npm dependency caching cache: 'npm' # Enables npm dependency caching
cache-dependency-path: '**/package-lock.json' # Cache key based on lockfile cache-dependency-path: '**/package-lock.json' # Cache key based on lockfile
- name: Install dependencies - name: Install dependencies
@ -35,8 +35,9 @@ jobs:
- name: Check version change - name: Check version change
id: check_version id: check_version
run: | run: |
git fetch origin main
CURRENT_VERSION=$(node -p "require('./package.json').version") CURRENT_VERSION=$(node -p "require('./package.json').version")
PREV_VERSION=$(git show 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 if [ "$CURRENT_VERSION" != "$PREV_VERSION" ]; then
echo "Version changed from $PREV_VERSION to $CURRENT_VERSION" echo "Version changed from $PREV_VERSION to $CURRENT_VERSION"
echo "version_changed=true" >> $GITHUB_OUTPUT echo "version_changed=true" >> $GITHUB_OUTPUT

View File

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

View File

@ -5,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/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.2.0] - 2025-08-24
### Added
- Endpoint filtering using `x-mcp` OpenAPI extension to control which operations are exposed as MCP tools
- CLI option `--default-include` to change default behavior for endpoint inclusion
- Precedence rules for `x-mcp` extension (operation > path > root level)
- Enhanced programmatic API with `defaultInclude` option in `getToolsFromOpenApi`
### Changed
- Improved documentation with examples for endpoint filtering and OpenAPI extensions.
- Version bump to next minor release
- Updated package version to reflect accumulated features and improvements
## [3.1.4] - 2025-06-18
### Chores
- Updated the application version to 3.1.4 and ensured the CLI displays the version dynamically.
### Style
- Improved code formatting for better readability.
### Bug Fixes
- Tool names now retain their original casing during extraction.
## [3.1.3] - 2025-06-12
### Fixed
- Cannot find the package after building and the problem during the building.
## [3.1.2] - 2025-06-08
### Fixed
- Prevent stack overflow (RangeError: Maximum call stack size exceeded) when processing recursive or cyclic OpenAPI schemas (e.g., self-referencing objects).
- Added cycle detection to schema mapping, ensuring robust handling of recursive structures.
## [3.1.1] - 2025-05-26
### Added
- Introduced a new executable command-line script for easier usage in Unix-like environments.
### Changed
- Use new CLI entry point to use the new `bin/openapi-mcp-generator.js` file.
- Updated build script to ensure the new CLI file has the correct permissions.
- Refactored `index.ts` to streamline argument parsing and error handling.
## [3.1.0] - 2025-05-18 ## [3.1.0] - 2025-05-18
### Added ### Added
- Programmatic API to extract MCP tool definitions from OpenAPI specs - Programmatic API to extract MCP tool definitions from OpenAPI specs
- New exportable `getToolsFromOpenApi` function for direct integration in code - New exportable `getToolsFromOpenApi` function for direct integration in code
- Advanced filtering capabilities for programmatic tool extraction - Advanced filtering capabilities for programmatic tool extraction
@ -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 - Updated README with programmatic API usage examples
### Changed ### Changed
- Improved module structure with better exports - Improved module structure with better exports
- Enhanced detection of module execution context - Enhanced detection of module execution context
## [3.0.0] - 2025-04-26
### Added
- Streamable HTTP support for OpenAPI MCP generator, enabling efficient handling of large payloads and real-time data transfer.
- Major architectural refactor to support streaming responses and requests.
### Fixed
- Multiple bugs related to HTTP/HTTPS connection handling, stream closure, and error propagation in streaming scenarios.
- Fixed resource leak issues on server aborts and client disconnects during streaming.
### Changed
- Major version bump due to breaking changes in API and internal structures to support streaming.
- Updated documentation to reflect new streaming capabilities and usage instructions.
- Enhanced performance and robustness of HTTP/HTTPS transport layers.
## [2.0.0] - 2025-04-12 ## [2.0.0] - 2025-04-12
### Added ### Added
- Runtime argument validation using Zod - Runtime argument validation using Zod
- JSON Schema to Zod schema conversion - JSON Schema to Zod schema conversion
- Improved error handling and formatting - Improved error handling and formatting
@ -106,7 +31,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Support for multiple content types - Support for multiple content types
### Changed ### Changed
- Simplified transport layer to only support stdio transport - Simplified transport layer to only support stdio transport
- Removed support for WebSocket and HTTP transports - Removed support for WebSocket and HTTP transports
- Updated to use @modelcontextprotocol/sdk v1.9.0 - Updated to use @modelcontextprotocol/sdk v1.9.0
@ -116,7 +40,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- More robust OpenAPI schema processing - More robust OpenAPI schema processing
### Fixed ### Fixed
- Path parameter resolution in URLs - Path parameter resolution in URLs
- Content-Type header handling - Content-Type header handling
- Response processing for different content types - Response processing for different content types
@ -126,9 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.0.0] - Initial Release ## [1.0.0] - Initial Release
### Added ### Added
- Basic OpenAPI to MCP server generation - Basic OpenAPI to MCP server generation
- Support for GET, POST, PUT, DELETE methods - Support for GET, POST, PUT, DELETE methods
- Basic error handling - Basic error handling
- Simple CLI interface - Simple CLI interface
- Basic TypeScript support - Basic TypeScript support

21
LICENSE
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. This function extracts an array of tools from an OpenAPI specification.
**Parameters:** **Parameters:**
- `specPathOrUrl`: Path to a local OpenAPI spec file or URL to a remote spec - `specPathOrUrl`: Path to a local OpenAPI spec file or URL to a remote spec
- `options`: (Optional) Configuration options - `options`: (Optional) Configuration options
**Options:** **Options:**
- `baseUrl`: Override the base URL in the OpenAPI spec - `baseUrl`: Override the base URL in the OpenAPI spec
- `dereference`: Whether to resolve $refs (default: false) - `dereference`: Whether to resolve $refs (default: false)
- `excludeOperationIds`: Array of operation IDs to exclude from the results - `excludeOperationIds`: Array of operation IDs to exclude from the results
- `filterFn`: Custom function to filter tools (receives tool, returns boolean) - `filterFn`: Custom function to filter tools (receives tool, returns boolean)
**Returns:** **Returns:**
- Promise that resolves to an array of McpToolDefinition objects - Promise that resolves to an array of McpToolDefinition objects
**Example:** **Example:**
@ -45,15 +42,12 @@ import { getToolsFromOpenApi } from 'openapi-mcp-generator';
const tools = await getToolsFromOpenApi('./petstore.json'); const tools = await getToolsFromOpenApi('./petstore.json');
// With options // With options
const filteredTools = await getToolsFromOpenApi( const filteredTools = await getToolsFromOpenApi('https://petstore3.swagger.io/api/v3/openapi.json', {
'https://petstore3.swagger.io/api/v3/openapi.json', baseUrl: 'https://petstore3.swagger.io/api/v3',
{ dereference: true,
baseUrl: 'https://petstore3.swagger.io/api/v3', excludeOperationIds: ['addPet', 'updatePet'],
dereference: true, filterFn: (tool) => tool.method.toLowerCase() === 'get'
excludeOperationIds: ['addPet', 'updatePet'], });
filterFn: (tool) => tool.method.toLowerCase() === 'get',
}
);
// Process the results // Process the results
for (const tool of filteredTools) { for (const tool of filteredTools) {
@ -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 ## Tool Definition Structure
Each tool definition (`McpToolDefinition`) has the following properties: Each tool definition (`McpToolDefinition`) has the following properties:
@ -86,34 +66,34 @@ Each tool definition (`McpToolDefinition`) has the following properties:
interface McpToolDefinition { interface McpToolDefinition {
/** Name of the tool, must be unique */ /** Name of the tool, must be unique */
name: string; name: string;
/** Human-readable description of the tool */ /** Human-readable description of the tool */
description: string; description: string;
/** JSON Schema that defines the input parameters */ /** JSON Schema that defines the input parameters */
inputSchema: JSONSchema7 | boolean; inputSchema: JSONSchema7 | boolean;
/** HTTP method for the operation (get, post, etc.) */ /** HTTP method for the operation (get, post, etc.) */
method: string; method: string;
/** URL path template with parameter placeholders */ /** URL path template with parameter placeholders */
pathTemplate: string; pathTemplate: string;
/** OpenAPI parameter objects for this operation */ /** OpenAPI parameter objects for this operation */
parameters: OpenAPIV3.ParameterObject[]; parameters: OpenAPIV3.ParameterObject[];
/** Parameter names and locations for execution */ /** Parameter names and locations for execution */
executionParameters: { name: string; in: string }[]; executionParameters: { name: string; in: string }[];
/** Content type for request body, if applicable */ /** Content type for request body, if applicable */
requestBodyContentType?: string; requestBodyContentType?: string;
/** Security requirements for this operation */ /** Security requirements for this operation */
securityRequirements: OpenAPIV3.SecurityRequirementObject[]; securityRequirements: OpenAPIV3.SecurityRequirementObject[];
/** Original operation ID from the OpenAPI spec */ /** Original operation ID from the OpenAPI spec */
operationId: string; operationId: string;
/** Base URL for the API (if available) */ /** Base URL for the API (if available) */
baseUrl?: string; baseUrl?: string;
} }
@ -125,7 +105,7 @@ interface McpToolDefinition {
```typescript ```typescript
const getTools = await getToolsFromOpenApi(specUrl, { const getTools = await getToolsFromOpenApi(specUrl, {
filterFn: (tool) => tool.method.toLowerCase() === 'get', filterFn: (tool) => tool.method.toLowerCase() === 'get'
}); });
``` ```
@ -133,7 +113,7 @@ const getTools = await getToolsFromOpenApi(specUrl, {
```typescript ```typescript
const secureTools = await getToolsFromOpenApi(specUrl, { const secureTools = await getToolsFromOpenApi(specUrl, {
filterFn: (tool) => tool.securityRequirements.length > 0, filterFn: (tool) => tool.securityRequirements.length > 0
}); });
``` ```
@ -141,7 +121,7 @@ const secureTools = await getToolsFromOpenApi(specUrl, {
```typescript ```typescript
const userTools = await getToolsFromOpenApi(specUrl, { const userTools = await getToolsFromOpenApi(specUrl, {
filterFn: (tool) => tool.pathTemplate.includes('/user'), filterFn: (tool) => tool.pathTemplate.includes('/user')
}); });
``` ```
@ -150,6 +130,8 @@ const userTools = await getToolsFromOpenApi(specUrl, {
```typescript ```typescript
const safeUserTools = await getToolsFromOpenApi(specUrl, { const safeUserTools = await getToolsFromOpenApi(specUrl, {
excludeOperationIds: ['deleteUser', 'updateUser'], excludeOperationIds: ['deleteUser', 'updateUser'],
filterFn: (tool) => 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 ### CLI Options
| Option | Alias | Description | Default | | Option | Alias | Description | Default |
| ------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | |--------------------|-------|-----------------------------------------------------------------------------------------------------|---------------------------------|
| `--input` | `-i` | Path or URL to OpenAPI specification (YAML or JSON) | **Required** | | `--input` | `-i` | Path or URL to OpenAPI specification (YAML or JSON) | **Required** |
| `--output` | `-o` | Directory to output the generated MCP project | **Required** | | `--output` | `-o` | Directory to output the generated MCP project | **Required** |
| `--server-name` | `-n` | Name of the MCP server (`package.json:name`) | OpenAPI title or `mcp-api-server` | | `--server-name` | `-n` | Name of the MCP server (`package.json:name`) | OpenAPI title or `mcp-api-server` |
| `--server-version` | `-v` | Version of the MCP server (`package.json:version`) | OpenAPI version or `1.0.0` | | `--server-version` | `-v` | Version of the MCP server (`package.json:version`) | OpenAPI version or `1.0.0` |
| `--base-url` | `-b` | Base URL for API requests. Required if OpenAPI `servers` missing or ambiguous. | Auto-detected if possible | | `--base-url` | `-b` | Base URL for API requests. Required if OpenAPI `servers` missing or ambiguous. | Auto-detected if possible |
| `--transport` | `-t` | Transport mode: `"stdio"` (default), `"web"`, or `"streamable-http"` | `"stdio"` | | `--transport` | `-t` | Transport mode: `"stdio"` (default), `"web"`, or `"streamable-http"` | `"stdio"` |
| `--port` | `-p` | Port for web-based transports | `3000` | | `--port` | `-p` | Port for web-based transports | `3000` |
| `--default-include` | | Default behavior for x-mcp filtering. Accepts `true` or `false` (case-insensitive). `true` = include by default, `false` = exclude by default. | `true` | | `--force` | | Overwrite existing files in the output directory without confirmation | `false` |
| `--force` | | Overwrite existing files in the output directory without confirmation | `false` |
## 📦 Programmatic API ## 📦 Programmatic API
@ -75,7 +74,7 @@ const filteredTools = await getToolsFromOpenApi('https://example.com/api-spec.js
baseUrl: 'https://api.example.com', baseUrl: 'https://api.example.com',
dereference: true, dereference: true,
excludeOperationIds: ['deletePet'], excludeOperationIds: ['deletePet'],
filterFn: (tool) => tool.method.toLowerCase() === 'get', filterFn: (tool) => tool.method.toLowerCase() === 'get'
}); });
``` ```
@ -101,7 +100,6 @@ The generated project includes:
``` ```
Core dependencies: Core dependencies:
- `@modelcontextprotocol/sdk` - MCP protocol implementation - `@modelcontextprotocol/sdk` - MCP protocol implementation
- `axios` - HTTP client for API requests - `axios` - HTTP client for API requests
- `zod` - Runtime validation - `zod` - Runtime validation
@ -140,18 +138,18 @@ Implements the MCP StreamableHTTP transport which offers:
### Transport Comparison ### Transport Comparison
| Feature | stdio | web (SSE) | streamable-http | | Feature | stdio | web (SSE) | streamable-http |
| ------------------ | ------------------- | ----------------- | ------------------ | |---------|-------|-----------|----------------|
| Protocol | JSON-RPC over stdio | JSON-RPC over SSE | JSON-RPC over HTTP | | Protocol | JSON-RPC over stdio | JSON-RPC over SSE | JSON-RPC over HTTP |
| Connection | Persistent | Persistent | Request/response | | Connection | Persistent | Persistent | Request/response |
| Bidirectional | Yes | Yes | Yes (stateful) | | Bidirectional | Yes | Yes | Yes (stateful) |
| Multiple clients | No | Yes | Yes | | Multiple clients | No | Yes | Yes |
| Browser compatible | No | Yes | Yes | | Browser compatible | No | Yes | Yes |
| Firewall friendly | No | Yes | Yes | | Firewall friendly | No | Yes | Yes |
| Load balancing | No | Limited | Yes | | Load balancing | No | Limited | Yes |
| Status codes | No | Limited | Full HTTP codes | | Status codes | No | Limited | Full HTTP codes |
| Headers | No | Limited | Full HTTP headers | | Headers | No | Limited | Full HTTP headers |
| Test client | No | Yes | Yes | | Test client | No | Yes | Yes |
--- ---
@ -159,44 +157,12 @@ Implements the MCP StreamableHTTP transport which offers:
Configure auth credentials in your environment: Configure auth credentials in your environment:
| Auth Type | Variable Format | | Auth Type | Variable Format |
| ---------- | -------------------------------------------------------------------------------------------------- | |-------------|----------------------------------------------------------|
| API Key | `API_KEY_<SCHEME_NAME>` | | API Key | `API_KEY_<SCHEME_NAME>` |
| Bearer | `BEARER_TOKEN_<SCHEME_NAME>` | | Bearer | `BEARER_TOKEN_<SCHEME_NAME>` |
| Basic Auth | `BASIC_USERNAME_<SCHEME_NAME>`, `BASIC_PASSWORD_<SCHEME_NAME>` | | Basic Auth | `BASIC_USERNAME_<SCHEME_NAME>`, `BASIC_PASSWORD_<SCHEME_NAME>` |
| OAuth2 | `OAUTH_CLIENT_ID_<SCHEME_NAME>`, `OAUTH_CLIENT_SECRET_<SCHEME_NAME>`, `OAUTH_SCOPES_<SCHEME_NAME>` | | OAuth2 | `OAUTH_CLIENT_ID_<SCHEME_NAME>`, `OAUTH_CLIENT_SECRET_<SCHEME_NAME>`, `OAUTH_SCOPES_<SCHEME_NAME>` |
---
## 🔎 Filtering Endpoints with OpenAPI Extensions
You can control which operations are exposed as MCP tools using a vendor extension flag `x-mcp`. This extension is supported at the root, path, and operation levels. By default, endpoints are included unless explicitly excluded.
- Extension: `x-mcp: true | false`
- Default: `true` (include by default)
- Precedence: operation > path > root (first non-undefined wins)
- CLI option: `--default-include false` to change default to exclude by default
Examples:
```yaml
# Optional root-level default
x-mcp: true
paths:
/pets:
x-mcp: false # exclude all ops under /pets
get:
x-mcp: true # include this operation anyway
/users/{id}:
get:
# no x-mcp -> included by default
```
This uses standard OpenAPI extensions (x-… fields). See the [OpenAPI Extensions guide](https://swagger.io/docs/specification/v3_0/openapi-extensions/) for details.
Note: `x-mcp` must be a boolean or the strings `"true"`/`"false"` (case-insensitive). Other values are ignored in favor of higher-precedence or default behavior.
--- ---
@ -248,9 +214,8 @@ Contributions are welcome!
1. Fork the repo 1. Fork the repo
2. Create a feature branch: `git checkout -b feature/amazing-feature` 2. Create a feature branch: `git checkout -b feature/amazing-feature`
3. Run `npm run format.write` to format your code 3. Commit your changes: `git commit -m "Add amazing feature"`
4. Commit your changes: `git commit -m "Add amazing feature"` 4. Push and open a PR
5. Push and open a PR
📌 Repository: [github.com/harsha-iiiv/openapi-mcp-generator](https://github.com/harsha-iiiv/openapi-mcp-generator) 📌 Repository: [github.com/harsha-iiiv/openapi-mcp-generator](https://github.com/harsha-iiiv/openapi-mcp-generator)

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

View File

@ -4,4 +4,4 @@
"singleQuote": true, "singleQuote": true,
"printWidth": 100, "printWidth": 100,
"tabWidth": 2 "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_ID_PETSTORE_AUTH`: Your OAuth client ID
- `OAUTH_CLIENT_SECRET_PETSTORE_AUTH`: Your OAuth client secret - `OAUTH_CLIENT_SECRET_PETSTORE_AUTH`: Your OAuth client secret
## Token Caching ## Token Caching
The MCP server automatically caches OAuth tokens obtained via client credentials flow. Tokens are cached for their lifetime (as specified by the `expires_in` parameter in the token response) minus 60 seconds as a safety margin. The MCP server automatically caches OAuth tokens obtained via client credentials flow. Tokens are cached for their lifetime (as specified by the `expires_in` parameter in the token response) minus 60 seconds as a safety margin.
When making API requests, the server will: When making API requests, the server will:
1. Check for a cached token that's still valid 1. Check for a cached token that's still valid
2. Use the cached token if available 2. Use the cached token if available
3. Request a new token if no valid cached token exists 3. Request a new token if no valid cached token exists

View File

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

View File

@ -1,406 +1,393 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>swagger-petstore---openapi-3-0 MCP Test Client</title> <title>swagger-petstore---openapi-3-0 MCP Test Client</title>
<style> <style>
body { body {
font-family: font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
system-ui, max-width: 800px;
-apple-system, margin: 0 auto;
BlinkMacSystemFont, padding: 20px;
'Segoe UI', line-height: 1.5;
Roboto, }
sans-serif; h1 { margin-bottom: 10px; }
max-width: 800px; .container {
margin: 0 auto; display: flex;
padding: 20px; flex-direction: column;
line-height: 1.5; height: calc(100vh - 150px);
} }
h1 { #conversation {
margin-bottom: 10px; flex: 1;
} border: 1px solid #ccc;
.container { overflow-y: auto;
display: flex; margin-bottom: 10px;
flex-direction: column; padding: 10px;
height: calc(100vh - 150px); border-radius: 5px;
} }
#conversation { .input-area {
flex: 1; display: flex;
border: 1px solid #ccc; margin-bottom: 20px;
overflow-y: auto; }
margin-bottom: 10px; #userInput {
padding: 10px; flex: 1;
border-radius: 5px; padding: 8px;
} font-size: 16px;
.input-area { border: 1px solid #ccc;
display: flex; border-radius: 5px 0 0 5px;
margin-bottom: 20px; }
} #sendButton {
#userInput { padding: 8px 16px;
flex: 1; background-color: #4CAF50;
padding: 8px; color: white;
font-size: 16px; border: none;
border: 1px solid #ccc; cursor: pointer;
border-radius: 5px 0 0 5px; border-radius: 0 5px 5px 0;
} }
#sendButton { #sendButton:hover { background-color: #45a049; }
padding: 8px 16px; .message {
background-color: #4caf50; margin-bottom: 10px;
color: white; padding: 8px 12px;
border: none; border-radius: 5px;
cursor: pointer; }
border-radius: 0 5px 5px 0; .user {
} background-color: #e7f4ff;
#sendButton:hover { align-self: flex-end;
background-color: #45a049; }
} .server {
.message { background-color: #f1f1f1;
margin-bottom: 10px; }
padding: 8px 12px; .system {
border-radius: 5px; background-color: #fffde7;
} color: #795548;
.user { font-style: italic;
background-color: #e7f4ff; }
align-self: flex-end; pre {
} white-space: pre-wrap;
.server { word-wrap: break-word;
background-color: #f1f1f1; }
} code {
.system { background-color: #f8f8f8;
background-color: #fffde7; padding: 2px 4px;
color: #795548; border-radius: 3px;
font-style: italic; }
} .status {
pre { color: #666;
white-space: pre-wrap; font-style: italic;
word-wrap: break-word; margin-bottom: 10px;
} }
code { #debug {
background-color: #f8f8f8; margin-top: 20px;
padding: 2px 4px; background-color: #f8f8f8;
border-radius: 3px; padding: 10px;
} border-radius: 5px;
.status { display: none;
color: #666; }
font-style: italic; .debug-controls {
margin-bottom: 10px; display: flex;
} justify-content: space-between;
#debug { align-items: center;
margin-top: 20px; }
background-color: #f8f8f8; #showDebug {
padding: 10px; margin-top: 10px;
border-radius: 5px; padding: 5px 10px;
display: none; cursor: pointer;
} background-color: #f1f1f1;
.debug-controls { border: 1px solid #ccc;
display: flex; border-radius: 3px;
justify-content: space-between; }
align-items: center; #debugLog {
} max-height: 200px;
#showDebug { overflow-y: auto;
margin-top: 10px; background-color: #111;
padding: 5px 10px; color: #0f0;
cursor: pointer; font-family: monospace;
background-color: #f1f1f1; padding: 5px;
border: 1px solid #ccc; margin-top: 10px;
border-radius: 3px; }
} .clear-debug {
#debugLog { padding: 3px 8px;
max-height: 200px; background-color: #f44336;
overflow-y: auto; color: white;
background-color: #111; border: none;
color: #0f0; border-radius: 3px;
font-family: monospace; cursor: pointer;
padding: 5px; }
margin-top: 10px; </style>
} </head>
.clear-debug { <body>
padding: 3px 8px; <h1>swagger-petstore---openapi-3-0 MCP Test Client</h1>
background-color: #f44336; <p class="status" id="status">Disconnected</p>
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 class="container">
<div id="conversation"></div> <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"> <button id="showDebug">Show Debug Console</button>
<input type="text" id="userInput" placeholder="Type a message..." disabled />
<button id="sendButton" disabled>Send</button>
</div>
</div>
<button id="showDebug">Show Debug Console</button> <div id="debug">
<div class="debug-controls">
<h3>Debug Console</h3>
<button class="clear-debug" id="clearDebug">Clear</button>
</div>
<div id="debugLog"></div>
</div>
<div id="debug"> <script>
<div class="debug-controls"> const conversation = document.getElementById('conversation');
<h3>Debug Console</h3> const userInput = document.getElementById('userInput');
<button class="clear-debug" id="clearDebug">Clear</button> const sendButton = document.getElementById('sendButton');
</div> const statusEl = document.getElementById('status');
<div id="debugLog"></div> const showDebugBtn = document.getElementById('showDebug');
</div> const debugDiv = document.getElementById('debug');
const debugLog = document.getElementById('debugLog');
<script> const clearDebugBtn = document.getElementById('clearDebug');
const conversation = document.getElementById('conversation');
const userInput = document.getElementById('userInput'); let sessionId = null;
const sendButton = document.getElementById('sendButton'); let messageId = 1;
const statusEl = document.getElementById('status'); let eventSource = null;
const showDebugBtn = document.getElementById('showDebug'); let apiEndpoint = 'http://localhost:3000/api/messages'; // default endpoint
const debugDiv = document.getElementById('debug');
const debugLog = document.getElementById('debugLog'); // Debug logging
const clearDebugBtn = document.getElementById('clearDebug'); function log(type, message) {
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1);
let sessionId = null; const entry = document.createElement('div');
let messageId = 1; entry.innerHTML = `<span style="color:#aaa;">${timestamp}</span> <span style="color:#58a6ff;">${type}:</span> ${message}`;
let eventSource = null; debugLog.appendChild(entry);
let apiEndpoint = 'http://localhost:3000/api/messages'; // default endpoint debugLog.scrollTop = debugLog.scrollHeight;
console.log(`${type}: ${message}`);
// Debug logging }
function log(type, message) {
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1); // Toggle debug console
const entry = document.createElement('div'); showDebugBtn.addEventListener('click', () => {
entry.innerHTML = `<span style="color:#aaa;">${timestamp}</span> <span style="color:#58a6ff;">${type}:</span> ${message}`; if (debugDiv.style.display === 'block') {
debugLog.appendChild(entry); debugDiv.style.display = 'none';
debugLog.scrollTop = debugLog.scrollHeight; showDebugBtn.textContent = 'Show Debug Console';
console.log(`${type}: ${message}`); } 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', () => { // Listen for regular messages
if (debugDiv.style.display === 'block') { eventSource.addEventListener('message', (event) => {
debugDiv.style.display = 'none'; log('RAW', event.data);
showDebugBtn.textContent = 'Show Debug Console';
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 { } else {
debugDiv.style.display = 'block'; log('INFO', `Received other message: ${JSON.stringify(data)}`);
showDebugBtn.textContent = 'Hide Debug Console';
} }
}); } catch (error) {
log('ERROR', `Error parsing SSE message: ${error.message}`);
// Clear debug logs appendMessage('system', `Error parsing message: ${event.data}`);
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;
} }
});
// Send a message to the server
async function sendMessage() { return eventSource;
const text = userInput.value.trim(); }
if (!text || !sessionId) return;
// Send a message to the server
appendMessage('user', text); async function sendMessage() {
userInput.value = ''; const text = userInput.value.trim();
if (!text || !sessionId) return;
log('INFO', `Sending message: ${text}`);
appendMessage('user', text);
try { userInput.value = '';
const parts = text.split(' ');
const toolName = parts[0]; log('INFO', `Sending message: ${text}`);
const requestBody = { try {
jsonrpc: '2.0', const parts = text.split(' ');
id: messageId++, const toolName = parts[0];
method: 'callTool',
params: { const requestBody = {
name: toolName, jsonrpc: '2.0',
arguments: parseArguments(text), id: messageId++,
}, method: 'callTool',
}; params: {
name: toolName,
log('REQUEST', JSON.stringify(requestBody)); arguments: parseArguments(text)
// Use the endpoint provided by the server, or fall back to the default
const fullEndpoint = `http://localhost:3000/api/messages?sessionId=${sessionId}`;
console.log('fullEndpoint', fullEndpoint);
const response = await fetch(fullEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
log('ERROR', `Error response: ${response.status} ${response.statusText} ${errorText}`);
appendMessage(
'system',
`Error: ${response.status} ${response.statusText}\n${errorText}`
);
} else {
log('INFO', `Request sent successfully`);
// Note: We don't handle the response content here because the response
// will come through the SSE connection, not this fetch response
}
} catch (error) {
log('ERROR', `Error sending message: ${error.message}`);
appendMessage('system', `Error sending message: ${error.message}`);
} }
} };
// Try to parse arguments from user input log('REQUEST', JSON.stringify(requestBody));
// Format: toolName param1=value1 param2=value2
function parseArguments(text) { // Use the endpoint provided by the server, or fall back to the default
const parts = text.split(' '); const fullEndpoint = `http://localhost:3000/api/messages?sessionId=${sessionId}`;
if (parts.length <= 1) return {}; console.log('fullEndpoint', fullEndpoint);
const args = {}; const response = await fetch(fullEndpoint, {
// Skip the first part (tool name) and process the rest method: 'POST',
for (let i = 1; i < parts.length; i++) { headers: {
const part = parts[i]; 'Content-Type': 'application/json'
const equalsIndex = part.indexOf('='); },
body: JSON.stringify(requestBody)
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 if (!response.ok) {
appendMessage('system', 'Connecting to server...'); const errorText = await response.text();
connect(); log('ERROR', `Error response: ${response.status} ${response.statusText} ${errorText}`);
appendMessage('system', `Error: ${response.status} ${response.statusText}\n${errorText}`);
// Clean up on page unload } else {
window.addEventListener('beforeunload', () => { log('INFO', `Request sent successfully`);
if (eventSource) eventSource.close(); // Note: We don't handle the response content here because the response
}); // will come through the SSE connection, not this fetch response
</script> }
</body> } catch (error) {
</html> 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 { Hono } from 'hono';
import { cors } from 'hono/cors'; import { cors } from 'hono/cors';
import { serve } from '@hono/node-server'; import { serve } from '@hono/node-server';
import { streamSSE } from 'hono/streaming'; import { streamSSE } from 'hono/streaming';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js'; import { JSONRPCMessage, JSONRPCMessageSchema } from "@modelcontextprotocol/sdk/types.js";
import type { Context } from 'hono'; import type { Context } from 'hono';
import type { SSEStreamingApi } from 'hono/streaming'; import type { SSEStreamingApi } from 'hono/streaming';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
// Import server configuration constants // Import server configuration constants
import { SERVER_NAME, SERVER_VERSION } from './index.js'; import { SERVER_NAME, SERVER_VERSION } from './index.js';
/** /**
* Custom SSE Transport implementation using Hono's streaming API * Custom SSE Transport implementation using Hono's streaming API
*/ */
class SSETransport implements Transport { class SSETransport implements Transport {
private _sessionId: string; private _sessionId: string;
private stream: SSEStreamingApi; private stream: SSEStreamingApi;
private messageUrl: string; private messageUrl: string;
onclose?: () => void; onclose?: () => void;
onerror?: (error: Error) => void; onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void; onmessage?: (message: JSONRPCMessage) => void;
constructor(messageUrl: string, stream: SSEStreamingApi) { constructor(messageUrl: string, stream: SSEStreamingApi) {
this._sessionId = uuid(); this._sessionId = uuid();
this.stream = stream; this.stream = stream;
this.messageUrl = messageUrl; this.messageUrl = messageUrl;
// Set up stream abort handler
this.stream.onAbort(() => {
console.error(`SSE connection aborted for session ${this._sessionId}`);
this.close();
});
}
// Set up stream abort handler get sessionId(): string {
this.stream.onAbort(() => { return this._sessionId;
console.error(`SSE connection aborted for session ${this._sessionId}`); }
this.close();
}); async start(): Promise<void> {
if (this.stream.closed) {
throw new Error('SSE transport already closed!');
} }
get sessionId(): string { // Send the endpoint information
return this._sessionId; await this.stream.writeSSE({
} event: 'endpoint',
data: `${this.messageUrl}?sessionId=${this._sessionId}`
async start(): Promise<void> { });
if (this.stream.closed) {
throw new Error('SSE transport already closed!'); // 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 async handlePostMessage(c: Context): Promise<Response> {
await this.stream.writeSSE({ if (this.stream?.closed) {
event: 'endpoint', return c.text('SSE connection closed', 400);
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> { try {
if (this.stream?.closed) { // Parse and validate the message
return c.text('SSE connection closed', 400); const body = await c.req.json();
}
try { try {
// Parse and validate the message // Parse and validate the message
const body = await c.req.json(); const parsedMessage = JSONRPCMessageSchema.parse(body);
try { // Forward to the message handler
// Parse and validate the message if (this.onmessage) {
const parsedMessage = JSONRPCMessageSchema.parse(body); this.onmessage(parsedMessage);
return c.text('Accepted', 202);
// Forward to the message handler } else {
if (this.onmessage) { return c.text('No message handler defined', 500);
this.onmessage(parsedMessage);
return c.text('Accepted', 202);
} else {
return c.text('No message handler defined', 500);
}
} catch (error) {
if (this.onerror) {
this.onerror(error instanceof Error ? error : new Error(String(error)));
}
console.error('Error parsing message:', error);
return c.text('Invalid message format', 400);
} }
} catch (error) { } catch (error) {
if (this.onerror) { if (this.onerror) {
this.onerror(error instanceof Error ? error : new Error(String(error))); this.onerror(error instanceof Error ? error : new Error(String(error)));
} }
console.error('Error processing request:', error); console.error('Error parsing message:', error);
return c.text('Error processing message', 400); 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> { async close(): Promise<void> {
if (this.stream && !this.stream.closed) { if (this.stream && !this.stream.closed) {
this.stream.abort(); this.stream.abort();
}
if (this.onclose) {
this.onclose();
}
} }
async send(message: JSONRPCMessage): Promise<void> { if (this.onclose) {
if (this.stream.closed) { this.onclose();
throw new Error('Not connected');
}
await this.stream.writeSSE({
event: 'message',
data: JSON.stringify(message),
});
} }
} }
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) * Sets up a web server for the MCP server using Server-Sent Events (SSE)
* *
* @param server The MCP Server instance * @param server The MCP Server instance
* @param port The port to listen on (default: 3000) * @param port The port to listen on (default: 3000)
* @returns The Hono app instance * @returns The Hono app instance
*/ */
export async function setupWebServer(server: Server, port = 3000) { export async function setupWebServer(server: Server, port = 3000) {
// Create Hono app // Create Hono app
const app = new Hono(); const app = new Hono();
// Enable CORS // Enable CORS
app.use('*', cors()); app.use('*', cors());
// Store active SSE transports by session ID // Store active SSE transports by session ID
const transports: { [sessionId: string]: SSETransport } = {}; const transports: {[sessionId: string]: SSETransport} = {};
// Add a simple health check endpoint // Add a simple health check endpoint
app.get('/health', (c) => { app.get('/health', (c) => {
return c.json({ status: 'OK', server: SERVER_NAME, version: SERVER_VERSION }); return c.json({ status: 'OK', server: SERVER_NAME, version: SERVER_VERSION });
}); });
// SSE endpoint for clients to connect to // SSE endpoint for clients to connect to
app.get('/sse', (c) => { app.get("/sse", (c) => {
return streamSSE(c, async (stream) => { return streamSSE(c, async (stream) => {
// Create SSE transport // Create SSE transport
const transport = new SSETransport('/api/messages', stream); const transport = new SSETransport('/api/messages', stream);
const sessionId = transport.sessionId; const sessionId = transport.sessionId;
console.error(`New SSE connection established: ${sessionId}`); console.error(`New SSE connection established: ${sessionId}`);
// Store the transport // Store the transport
transports[sessionId] = transport; transports[sessionId] = transport;
// Set up cleanup on transport close // Set up cleanup on transport close
transport.onclose = () => { transport.onclose = () => {
console.error(`SSE connection closed for session ${sessionId}`); console.error(`SSE connection closed for session ${sessionId}`);
delete transports[sessionId]; delete transports[sessionId];
}; };
// Make the transport available to the MCP server // 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;
try { try {
// Use Node.js fs to serve static files transport.onmessage = async (message: JSONRPCMessage) => {
const fs = await import('fs'); try {
const path = await import('path'); // The server will automatically send a response via the transport
const { fileURLToPath } = await import('url'); // if the message has an ID (i.e., it's a request, not a notification)
} catch (error) {
const __dirname = path.dirname(fileURLToPath(import.meta.url)); console.error('Error handling MCP message:', error);
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) { };
// 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) { } catch (err) {
console.error('Error serving static file:', err); // File not found or other error
return c.text('Internal Server Error', 500); 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 return app;
serve(
{
fetch: app.fetch,
port,
},
(info) => {
console.error(`MCP Web Server running at http://localhost:${info.port}`);
console.error(`- SSE Endpoint: http://localhost:${info.port}/sse`);
console.error(
`- Messages Endpoint: http://localhost:${info.port}/api/messages?sessionId=YOUR_SESSION_ID`
);
console.error(`- Health Check: http://localhost:${info.port}/health`);
}
);
return app;
} }

View File

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

View File

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

View File

@ -4,4 +4,4 @@
"singleQuote": true, "singleQuote": true,
"printWidth": 100, "printWidth": 100,
"tabWidth": 2 "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_ID_PETSTORE_AUTH`: Your OAuth client ID
- `OAUTH_CLIENT_SECRET_PETSTORE_AUTH`: Your OAuth client secret - `OAUTH_CLIENT_SECRET_PETSTORE_AUTH`: Your OAuth client secret
## Token Caching ## Token Caching
The MCP server automatically caches OAuth tokens obtained via client credentials flow. Tokens are cached for their lifetime (as specified by the `expires_in` parameter in the token response) minus 60 seconds as a safety margin. The MCP server automatically caches OAuth tokens obtained via client credentials flow. Tokens are cached for their lifetime (as specified by the `expires_in` parameter in the token response) minus 60 seconds as a safety margin.
When making API requests, the server will: When making API requests, the server will:
1. Check for a cached token that's still valid 1. Check for a cached token that's still valid
2. Use the cached token if available 2. Use the cached token if available
3. Request a new token if no valid cached token exists 3. Request a new token if no valid cached token exists

View File

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

View File

@ -1,424 +1,402 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>swagger-petstore---openapi-3-0 MCP StreamableHTTP Test Client</title> <title>swagger-petstore---openapi-3-0 MCP StreamableHTTP Test Client</title>
<style> <style>
body { body {
font-family: font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
system-ui, max-width: 800px;
-apple-system, margin: 0 auto;
BlinkMacSystemFont, padding: 20px;
'Segoe UI', line-height: 1.5;
Roboto, }
sans-serif; h1 { margin-bottom: 10px; }
max-width: 800px; .container {
margin: 0 auto; display: flex;
padding: 20px; flex-direction: column;
line-height: 1.5; height: calc(100vh - 150px);
} }
h1 { #conversation {
margin-bottom: 10px; flex: 1;
} border: 1px solid #ccc;
.container { overflow-y: auto;
display: flex; margin-bottom: 10px;
flex-direction: column; padding: 10px;
height: calc(100vh - 150px); border-radius: 5px;
} }
#conversation { .input-area {
flex: 1; display: flex;
border: 1px solid #ccc; margin-bottom: 20px;
overflow-y: auto; }
margin-bottom: 10px; #userInput {
padding: 10px; flex: 1;
border-radius: 5px; padding: 8px;
} font-size: 16px;
.input-area { border: 1px solid #ccc;
display: flex; border-radius: 5px 0 0 5px;
margin-bottom: 20px; }
} #sendButton {
#userInput { padding: 8px 16px;
flex: 1; background-color: #4CAF50;
padding: 8px; color: white;
font-size: 16px; border: none;
border: 1px solid #ccc; cursor: pointer;
border-radius: 5px 0 0 5px; border-radius: 0 5px 5px 0;
} }
#sendButton { #sendButton:hover { background-color: #45a049; }
padding: 8px 16px; .message {
background-color: #4caf50; margin-bottom: 10px;
color: white; padding: 8px 12px;
border: none; border-radius: 5px;
cursor: pointer; }
border-radius: 0 5px 5px 0; .user {
} background-color: #e7f4ff;
#sendButton:hover { align-self: flex-end;
background-color: #45a049; }
} .server {
.message { background-color: #f1f1f1;
margin-bottom: 10px; }
padding: 8px 12px; .system {
border-radius: 5px; background-color: #fffde7;
} color: #795548;
.user { font-style: italic;
background-color: #e7f4ff; }
align-self: flex-end; pre {
} white-space: pre-wrap;
.server { word-wrap: break-word;
background-color: #f1f1f1; }
} code {
.system { background-color: #f8f8f8;
background-color: #fffde7; padding: 2px 4px;
color: #795548; border-radius: 3px;
font-style: italic; }
} .status {
pre { color: #666;
white-space: pre-wrap; font-style: italic;
word-wrap: break-word; margin-bottom: 10px;
} }
code { #debug {
background-color: #f8f8f8; margin-top: 20px;
padding: 2px 4px; background-color: #f8f8f8;
border-radius: 3px; padding: 10px;
} border-radius: 5px;
.status { display: none;
color: #666; }
font-style: italic; .debug-controls {
margin-bottom: 10px; display: flex;
} justify-content: space-between;
#debug { align-items: center;
margin-top: 20px; }
background-color: #f8f8f8; #showDebug {
padding: 10px; margin-top: 10px;
border-radius: 5px; padding: 5px 10px;
display: none; cursor: pointer;
} background-color: #f1f1f1;
.debug-controls { border: 1px solid #ccc;
display: flex; border-radius: 3px;
justify-content: space-between; }
align-items: center; #debugLog {
} max-height: 200px;
#showDebug { overflow-y: auto;
margin-top: 10px; background-color: #111;
padding: 5px 10px; color: #0f0;
cursor: pointer; font-family: monospace;
background-color: #f1f1f1; padding: 5px;
border: 1px solid #ccc; margin-top: 10px;
border-radius: 3px; }
} .clear-debug {
#debugLog { padding: 3px 8px;
max-height: 200px; background-color: #f44336;
overflow-y: auto; color: white;
background-color: #111; border: none;
color: #0f0; border-radius: 3px;
font-family: monospace; cursor: pointer;
padding: 5px; }
margin-top: 10px; </style>
} </head>
.clear-debug { <body>
padding: 3px 8px; <h1>swagger-petstore---openapi-3-0 MCP StreamableHTTP Test Client</h1>
background-color: #f44336; <p class="status" id="status">Disconnected</p>
color: white;
border: none; <div class="container">
border-radius: 3px; <div id="conversation"></div>
cursor: pointer;
} <div class="input-area">
</style> <input type="text" id="userInput" placeholder="Type a message..." disabled>
</head> <button id="sendButton" disabled>Send</button>
<body>
<h1>swagger-petstore---openapi-3-0 MCP StreamableHTTP Test Client</h1>
<p class="status" id="status">Disconnected</p>
<div class="container">
<div id="conversation"></div>
<div class="input-area">
<input type="text" id="userInput" placeholder="Type a message..." disabled />
<button id="sendButton" disabled>Send</button>
</div>
</div> </div>
</div>
<button id="showDebug">Show Debug Console</button>
<button id="showDebug">Show Debug Console</button>
<div id="debug">
<div class="debug-controls"> <div id="debug">
<h3>Debug Console</h3> <div class="debug-controls">
<button class="clear-debug" id="clearDebug">Clear</button> <h3>Debug Console</h3>
</div> <button class="clear-debug" id="clearDebug">Clear</button>
<div id="debugLog"></div>
</div> </div>
<div id="debugLog"></div>
<script> </div>
const conversation = document.getElementById('conversation');
const userInput = document.getElementById('userInput'); <script>
const sendButton = document.getElementById('sendButton'); const conversation = document.getElementById('conversation');
const statusEl = document.getElementById('status'); const userInput = document.getElementById('userInput');
const showDebugBtn = document.getElementById('showDebug'); const sendButton = document.getElementById('sendButton');
const debugDiv = document.getElementById('debug'); const statusEl = document.getElementById('status');
const debugLog = document.getElementById('debugLog'); const showDebugBtn = document.getElementById('showDebug');
const clearDebugBtn = document.getElementById('clearDebug'); const debugDiv = document.getElementById('debug');
const debugLog = document.getElementById('debugLog');
let sessionId = null; const clearDebugBtn = document.getElementById('clearDebug');
let messageId = 1;
let sessionId = null;
// Debug logging let messageId = 1;
function log(type, message) {
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1); // Debug logging
const entry = document.createElement('div'); function log(type, message) {
entry.innerHTML = `<span style="color:#aaa;">${timestamp}</span> <span style="color:#58a6ff;">${type}:</span> ${message}`; const timestamp = new Date().toISOString().split('T')[1].slice(0, -1);
debugLog.appendChild(entry); const entry = document.createElement('div');
debugLog.scrollTop = debugLog.scrollHeight; entry.innerHTML = `<span style="color:#aaa;">${timestamp}</span> <span style="color:#58a6ff;">${type}:</span> ${message}`;
console.log(`${type}: ${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', () => { // Clear debug logs
if (debugDiv.style.display === 'block') { clearDebugBtn.addEventListener('click', () => {
debugDiv.style.display = 'none'; debugLog.innerHTML = '';
showDebugBtn.textContent = 'Show Debug Console'; });
} else {
debugDiv.style.display = 'block'; // Initialize the MCP connection
showDebugBtn.textContent = 'Hide Debug Console'; async function initialize() {
} statusEl.textContent = 'Connecting...';
}); log('INFO', 'Initializing MCP connection...');
// Clear debug logs try {
clearDebugBtn.addEventListener('click', () => { const requestBody = {
debugLog.innerHTML = ''; jsonrpc: '2.0',
}); id: messageId++,
method: 'initialize',
// Initialize the MCP connection params: {
async function initialize() { clientName: 'MCP StreamableHTTP Test Client',
statusEl.textContent = 'Connecting...'; clientVersion: '1.0.0',
log('INFO', 'Initializing MCP connection...'); capabilities: {}
try {
const requestBody = {
jsonrpc: '2.0',
id: messageId++,
method: 'initialize',
params: {
clientName: 'MCP StreamableHTTP Test Client',
clientVersion: '1.0.0',
capabilities: {},
},
};
log('REQUEST', JSON.stringify(requestBody));
const response = await fetch('/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
log('ERROR', `Error response: ${response.status} ${response.statusText} ${errorText}`);
appendMessage(
'system',
`Error: ${response.status} ${response.statusText}\n${errorText}`
);
statusEl.textContent = 'Connection error. Try again.';
return;
} }
};
// Get session ID from response headers
sessionId = response.headers.get('mcp-session-id'); log('REQUEST', JSON.stringify(requestBody));
if (!sessionId) { const response = await fetch('/mcp', {
log('ERROR', 'No session ID in response headers'); method: 'POST',
appendMessage('system', 'Error: No session ID in response headers'); headers: {
statusEl.textContent = 'Connection error. Try again.'; 'Content-Type': 'application/json'
return; },
} body: JSON.stringify(requestBody)
});
// Process response body
const data = await response.json(); if (!response.ok) {
log('RESPONSE', JSON.stringify(data)); const errorText = await response.text();
log('ERROR', `Error response: ${response.status} ${response.statusText} ${errorText}`);
if (data.result) { appendMessage('system', `Error: ${response.status} ${response.statusText}\n${errorText}`);
appendMessage('server', JSON.stringify(data.result, null, 2));
}
// Enable UI
statusEl.textContent = `Connected (Session ID: ${sessionId})`;
userInput.disabled = false;
sendButton.disabled = false;
userInput.focus();
appendMessage('system', `Connected with session ID: ${sessionId}`);
// Get list of tools
await listTools();
} catch (error) {
log('ERROR', `Error during initialization: ${error.message}`);
appendMessage('system', `Error during initialization: ${error.message}`);
statusEl.textContent = 'Connection error. Try again.'; statusEl.textContent = 'Connection error. Try again.';
return;
} }
}
// Get session ID from response headers
// Get list of available tools sessionId = response.headers.get('mcp-session-id');
async function listTools() {
try { if (!sessionId) {
const requestBody = { log('ERROR', 'No session ID in response headers');
jsonrpc: '2.0', appendMessage('system', 'Error: No session ID in response headers');
id: messageId++, statusEl.textContent = 'Connection error. Try again.';
method: 'listTools', return;
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}`);
} }
}
// Process response body
// Send a message to the server const data = await response.json();
async function sendMessage() { log('RESPONSE', JSON.stringify(data));
const text = userInput.value.trim();
if (!text || !sessionId) return; if (data.result) {
appendMessage('server', JSON.stringify(data.result, null, 2));
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}`);
} }
// 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 // Get list of available tools
function parseArguments(text) { 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(' '); const parts = text.split(' ');
if (parts.length <= 1) return {}; const toolName = parts[0];
const args = {}; const requestBody = {
// Skip the first part (tool name) and process the rest jsonrpc: '2.0',
for (let i = 1; i < parts.length; i++) { id: messageId++,
const part = parts[i]; method: 'callTool',
const equalsIndex = part.indexOf('='); params: {
name: toolName,
if (equalsIndex > 0) { arguments: parseArguments(text)
const key = part.substring(0, equalsIndex);
const value = part.substring(equalsIndex + 1);
// Try to parse as number or boolean if possible
if (value === 'true') args[key] = true;
else if (value === 'false') args[key] = false;
else if (!isNaN(Number(value))) args[key] = Number(value);
else args[key] = value;
} }
};
log('REQUEST', JSON.stringify(requestBody));
const response = await fetch('/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'mcp-session-id': sessionId
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
log('ERROR', `Error response: ${response.status} ${response.statusText} ${errorText}`);
appendMessage('system', `Error: ${response.status} ${response.statusText}\n${errorText}`);
return;
} }
return args; const data = await response.json();
} log('RESPONSE', JSON.stringify(data));
// Add a message to the conversation if (data.error) {
function appendMessage(sender, text) { appendMessage('system', `Error: ${data.error.code} - ${data.error.message}`);
const messageDiv = document.createElement('div'); } else if (data.result) {
messageDiv.className = `message ${sender}`; appendMessage('server', JSON.stringify(data.result, null, 2));
// Format as code block if it looks like JSON
if (text.trim().startsWith('{') || text.trim().startsWith('[')) {
const pre = document.createElement('pre');
const code = document.createElement('code');
code.textContent = text;
pre.appendChild(code);
messageDiv.appendChild(pre);
} else {
messageDiv.textContent = text;
} }
} catch (error) {
conversation.appendChild(messageDiv); log('ERROR', `Error sending message: ${error.message}`);
conversation.scrollTop = conversation.scrollHeight; appendMessage('system', `Error sending message: ${error.message}`);
} }
}
// Event listeners
sendButton.addEventListener('click', sendMessage); // Try to parse arguments from user input
userInput.addEventListener('keypress', (e) => { // Format: toolName param1=value1 param2=value2
if (e.key === 'Enter') sendMessage(); function parseArguments(text) {
}); const parts = text.split(' ');
if (parts.length <= 1) return {};
// Initialize on page load
appendMessage('system', 'Initializing MCP connection...'); const args = {};
initialize(); // Skip the first part (tool name) and process the rest
</script> for (let i = 1; i < parts.length; i++) {
</body> const part = parts[i];
</html> 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 * StreamableHTTP server setup for HTTP-based MCP communication using Hono
*/ */
@ -5,17 +6,17 @@ import { Hono } from 'hono';
import { cors } from 'hono/cors'; import { cors } from 'hono/cors';
import { serve } from '@hono/node-server'; import { serve } from '@hono/node-server';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { InitializeRequestSchema, JSONRPCError } from '@modelcontextprotocol/sdk/types.js'; import { InitializeRequestSchema, JSONRPCError } from "@modelcontextprotocol/sdk/types.js";
import { toReqRes, toFetchResponse } from 'fetch-to-node'; import { toReqRes, toFetchResponse } from 'fetch-to-node';
// Import server configuration constants // Import server configuration constants
import { SERVER_NAME, SERVER_VERSION } from './index.js'; import { SERVER_NAME, SERVER_VERSION } from './index.js';
// Constants // Constants
const SESSION_ID_HEADER_NAME = 'mcp-session-id'; const SESSION_ID_HEADER_NAME = "mcp-session-id";
const JSON_RPC = '2.0'; const JSON_RPC = "2.0";
/** /**
* StreamableHTTP MCP Server handler * StreamableHTTP MCP Server handler
@ -23,102 +24,106 @@ const JSON_RPC = '2.0';
class MCPStreamableHttpServer { class MCPStreamableHttpServer {
server: Server; server: Server;
// Store active transports by session ID // Store active transports by session ID
transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; transports: {[sessionId: string]: StreamableHTTPServerTransport} = {};
constructor(server: Server) { constructor(server: Server) {
this.server = server; this.server = server;
} }
/** /**
* Handle GET requests (typically used for static files) * Handle GET requests (typically used for static files)
*/ */
async handleGetRequest(c: any) { async handleGetRequest(c: any) {
console.error('GET request received - StreamableHTTP transport only supports POST'); console.error("GET request received - StreamableHTTP transport only supports POST");
return c.text('Method Not Allowed', 405, { return c.text('Method Not Allowed', 405, {
Allow: 'POST', 'Allow': 'POST'
}); });
} }
/** /**
* Handle POST requests (all MCP communication) * Handle POST requests (all MCP communication)
*/ */
async handlePostRequest(c: any) { async handlePostRequest(c: any) {
const sessionId = c.req.header(SESSION_ID_HEADER_NAME); const sessionId = c.req.header(SESSION_ID_HEADER_NAME);
console.error( console.error(`POST request received ${sessionId ? 'with session ID: ' + sessionId : 'without session ID'}`);
`POST request received ${sessionId ? 'with session ID: ' + sessionId : 'without session ID'}`
);
try { try {
const body = await c.req.json(); const body = await c.req.json();
// Convert Fetch Request to Node.js req/res // Convert Fetch Request to Node.js req/res
const { req, res } = toReqRes(c.req.raw); const { req, res } = toReqRes(c.req.raw);
// Reuse existing transport if we have a session ID // Reuse existing transport if we have a session ID
if (sessionId && this.transports[sessionId]) { if (sessionId && this.transports[sessionId]) {
const transport = this.transports[sessionId]; const transport = this.transports[sessionId];
// Handle the request with the transport // Handle the request with the transport
await transport.handleRequest(req, res, body); await transport.handleRequest(req, res, body);
// Cleanup when the response ends // Cleanup when the response ends
res.on('close', () => { res.on('close', () => {
console.error(`Request closed for session ${sessionId}`); console.error(`Request closed for session ${sessionId}`);
}); });
// Convert Node.js response back to Fetch Response // Convert Node.js response back to Fetch Response
return toFetchResponse(res); return toFetchResponse(res);
} }
// Create new transport for initialize requests // Create new transport for initialize requests
if (!sessionId && this.isInitializeRequest(body)) { if (!sessionId && this.isInitializeRequest(body)) {
console.error('Creating new StreamableHTTP transport for initialize request'); console.error("Creating new StreamableHTTP transport for initialize request");
const transport = new StreamableHTTPServerTransport({ const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => uuid(), sessionIdGenerator: () => uuid(),
}); });
// Add error handler for debug purposes // Add error handler for debug purposes
transport.onerror = (err) => { transport.onerror = (err) => {
console.error('StreamableHTTP transport error:', err); console.error('StreamableHTTP transport error:', err);
}; };
// Connect the transport to the MCP server // Connect the transport to the MCP server
await this.server.connect(transport); await this.server.connect(transport);
// Handle the request with the transport // Handle the request with the transport
await transport.handleRequest(req, res, body); await transport.handleRequest(req, res, body);
// Store the transport if we have a session ID // Store the transport if we have a session ID
const newSessionId = transport.sessionId; const newSessionId = transport.sessionId;
if (newSessionId) { if (newSessionId) {
console.error(`New session established: ${newSessionId}`); console.error(`New session established: ${newSessionId}`);
this.transports[newSessionId] = transport; this.transports[newSessionId] = transport;
// Set up clean-up for when the transport is closed // Set up clean-up for when the transport is closed
transport.onclose = () => { transport.onclose = () => {
console.error(`Session closed: ${newSessionId}`); console.error(`Session closed: ${newSessionId}`);
delete this.transports[newSessionId]; delete this.transports[newSessionId];
}; };
} }
// Cleanup when the response ends // Cleanup when the response ends
res.on('close', () => { res.on('close', () => {
console.error(`Request closed for new session`); console.error(`Request closed for new session`);
}); });
// Convert Node.js response back to Fetch Response // Convert Node.js response back to Fetch Response
return toFetchResponse(res); return toFetchResponse(res);
} }
// Invalid request (no session ID and not initialize) // Invalid request (no session ID and not initialize)
return c.json(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) { } catch (error) {
console.error('Error handling MCP request:', 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 * Create a JSON-RPC error response
*/ */
@ -132,7 +137,7 @@ class MCPStreamableHttpServer {
id: uuid(), id: uuid(),
}; };
} }
/** /**
* Check if the request is an initialize request * Check if the request is an initialize request
*/ */
@ -141,18 +146,18 @@ class MCPStreamableHttpServer {
const result = InitializeRequestSchema.safeParse(data); const result = InitializeRequestSchema.safeParse(data);
return result.success; return result.success;
}; };
if (Array.isArray(body)) { if (Array.isArray(body)) {
return body.some((request) => isInitial(request)); return body.some(request => isInitial(request));
} }
return isInitial(body); return isInitial(body);
} }
} }
/** /**
* Sets up a web server for the MCP server using StreamableHTTP transport * Sets up a web server for the MCP server using StreamableHTTP transport
* *
* @param server The MCP Server instance * @param server The MCP Server instance
* @param port The port to listen on (default: 3000) * @param port The port to listen on (default: 3000)
* @returns The Hono app instance * @returns The Hono app instance
@ -160,22 +165,22 @@ class MCPStreamableHttpServer {
export async function setupStreamableHttpServer(server: Server, port = 3000) { export async function setupStreamableHttpServer(server: Server, port = 3000) {
// Create Hono app // Create Hono app
const app = new Hono(); const app = new Hono();
// Enable CORS // Enable CORS
app.use('*', cors()); app.use('*', cors());
// Create MCP handler // Create MCP handler
const mcpHandler = new MCPStreamableHttpServer(server); const mcpHandler = new MCPStreamableHttpServer(server);
// Add a simple health check endpoint // Add a simple health check endpoint
app.get('/health', (c) => { app.get('/health', (c) => {
return c.json({ status: 'OK', server: SERVER_NAME, version: SERVER_VERSION }); return c.json({ status: 'OK', server: SERVER_NAME, version: SERVER_VERSION });
}); });
// Main MCP endpoint supporting both GET and POST // Main MCP endpoint supporting both GET and POST
app.get('/mcp', (c) => mcpHandler.handleGetRequest(c)); app.get("/mcp", (c) => mcpHandler.handleGetRequest(c));
app.post('/mcp', (c) => mcpHandler.handlePostRequest(c)); app.post("/mcp", (c) => mcpHandler.handlePostRequest(c));
// Static files for the web client (if any) // Static files for the web client (if any)
app.get('/*', async (c) => { app.get('/*', async (c) => {
const filePath = c.req.path === '/' ? '/index.html' : c.req.path; const filePath = c.req.path === '/' ? '/index.html' : c.req.path;
@ -184,51 +189,37 @@ export async function setupStreamableHttpServer(server: Server, port = 3000) {
const fs = await import('fs'); const fs = await import('fs');
const path = await import('path'); const path = await import('path');
const { fileURLToPath } = await import('url'); const { fileURLToPath } = await import('url');
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const publicPath = path.join(__dirname, '..', '..', 'public'); const publicPath = path.join(__dirname, '..', '..', 'public');
const fullPath = path.join(publicPath, filePath); const fullPath = path.join(publicPath, filePath);
// Simple security check to prevent directory traversal // Simple security check to prevent directory traversal
if (!fullPath.startsWith(publicPath)) { if (!fullPath.startsWith(publicPath)) {
return c.text('Forbidden', 403); return c.text('Forbidden', 403);
} }
try { try {
const stat = fs.statSync(fullPath); const stat = fs.statSync(fullPath);
if (stat.isFile()) { if (stat.isFile()) {
const content = fs.readFileSync(fullPath); const content = fs.readFileSync(fullPath);
// Set content type based on file extension // Set content type based on file extension
const ext = path.extname(fullPath).toLowerCase(); const ext = path.extname(fullPath).toLowerCase();
let contentType = 'text/plain'; let contentType = 'text/plain';
switch (ext) { switch (ext) {
case '.html': case '.html': contentType = 'text/html'; break;
contentType = 'text/html'; case '.css': contentType = 'text/css'; break;
break; case '.js': contentType = 'text/javascript'; break;
case '.css': case '.json': contentType = 'application/json'; break;
contentType = 'text/css'; case '.png': contentType = 'image/png'; break;
break; case '.jpg': contentType = 'image/jpeg'; break;
case '.js': case '.svg': contentType = 'image/svg+xml'; break;
contentType = 'text/javascript';
break;
case '.json':
contentType = 'application/json';
break;
case '.png':
contentType = 'image/png';
break;
case '.jpg':
contentType = 'image/jpeg';
break;
case '.svg':
contentType = 'image/svg+xml';
break;
} }
return new Response(content, { return new Response(content, {
headers: { 'Content-Type': contentType }, headers: { 'Content-Type': contentType }
}); });
} }
} catch (err) { } catch (err) {
@ -239,22 +230,19 @@ export async function setupStreamableHttpServer(server: Server, port = 3000) {
console.error('Error serving static file:', err); console.error('Error serving static file:', err);
return c.text('Internal Server Error', 500); return c.text('Internal Server Error', 500);
} }
return c.text('Not Found', 404); return c.text('Not Found', 404);
}); });
// Start the server // Start the server
serve( serve({
{ fetch: app.fetch,
fetch: app.fetch, port
port, }, (info) => {
}, console.error(`MCP StreamableHTTP Server running at http://localhost:${info.port}`);
(info) => { console.error(`- MCP Endpoint: http://localhost:${info.port}/mcp`);
console.error(`MCP StreamableHTTP Server running at http://localhost:${info.port}`); console.error(`- Health Check: http://localhost:${info.port}/health`);
console.error(`- MCP Endpoint: http://localhost:${info.port}/mcp`); });
console.error(`- Health Check: http://localhost:${info.port}/health`);
}
);
return app; return app;
} }

View File

@ -17,6 +17,12 @@
"sourceMap": true, "sourceMap": true,
"forceConsistentCasingInFileNames": true "forceConsistentCasingInFileNames": true
}, },
"include": ["src/**/*"], "include": [
"exclude": ["node_modules", "build", "**/*.test.ts"] "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, "sourceMap": true,
"forceConsistentCasingInFileNames": true "forceConsistentCasingInFileNames": true
}, },
"include": ["src/**/*"], "include": [
"exclude": ["node_modules", "build"] "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", "name": "openapi-mcp-generator",
"version": "3.2.0", "version": "3.1.0",
"description": "Generates MCP server code from OpenAPI specifications", "description": "Generates MCP server code from OpenAPI specifications",
"license": "MIT", "license": "MIT",
"author": "Harsha", "author": "Harsha",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
"bin": { "bin": {
"openapi-mcp-generator": "./bin/openapi-mcp-generator.js" "openapi-mcp-generator": "./dist/index.js"
}, },
"main": "dist/index.js", "files": [
"files": [ "dist",
"dist", "README.md",
"bin", "LICENSE"
"README.md", ],
"LICENSE" "scripts": {
], "start": "node dist/index.js",
"types": "./dist/index.d.ts", "clean": "rimraf dist",
"scripts": { "typecheck": "tsc --noEmit",
"start": "node dist/index.js", "build": "tsc && chmod 755 dist/index.js",
"clean": "rimraf dist", "version:patch": "npm version patch",
"format.check": "prettier --check .", "version:minor": "npm version minor",
"format.write": "prettier --write .", "version:major": "npm version major"
"typecheck": "tsc --noEmit", },
"build": "tsc && chmod 755 dist/index.js bin/openapi-mcp-generator.js", "keywords": [
"version:patch": "npm version patch", "openapi",
"version:minor": "npm version minor", "mcp",
"version:major": "npm version major" "model-context-protocol",
}, "generator",
"keywords": [ "llm",
"openapi", "ai",
"mcp", "api"
"model-context-protocol", ],
"generator", "repository": {
"llm", "type": "git",
"ai", "url": "git+https://github.com/harsha-iiiv/openapi-mcp-generator.git"
"api" },
], "bugs": {
"repository": { "url": "https://github.com/harsha-iiiv/openapi-mcp-generator/issues"
"type": "git", },
"url": "git+https://github.com/harsha-iiiv/openapi-mcp-generator.git" "homepage": "https://github.com/harsha-iiiv/openapi-mcp-generator#readme",
}, "dependencies": {
"bugs": { "@apidevtools/swagger-parser": "^10.1.1",
"url": "https://github.com/harsha-iiiv/openapi-mcp-generator/issues" "commander": "^13.1.0",
}, "openapi-types": "^12.1.3"
"homepage": "https://github.com/harsha-iiiv/openapi-mcp-generator#readme", },
"dependencies": { "devDependencies": {
"@apidevtools/swagger-parser": "^10.1.1", "@types/node": "^22.15.2",
"commander": "^13.1.0", "@typescript-eslint/eslint-plugin": "^8.31.0",
"openapi-types": "^12.1.3" "@typescript-eslint/parser": "^8.31.0",
}, "eslint": "^9.25.1",
"devDependencies": { "prettier": "^3.5.3",
"@types/node": "^22.17.2", "rimraf": "^6.0.1",
"@typescript-eslint/eslint-plugin": "^8.39.1", "typescript": "^5.8.3"
"@typescript-eslint/parser": "^8.39.1", },
"eslint": "^9.33.0", "peerDependencies": {
"prettier": "^3.6.2", "@modelcontextprotocol/sdk": "^1.10.0",
"rimraf": "^6.0.1", "json-schema-to-zod": "^2.6.1",
"typescript": "^5.9.2" "zod": "^3.24.3"
}, }
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"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 { export interface GetToolsOptions {
/** Optional base URL to override the one in the OpenAPI spec */ /** Optional base URL to override the one in the OpenAPI spec */
baseUrl?: string; baseUrl?: string;
/** Whether to dereference the OpenAPI spec */ /** Whether to dereference the OpenAPI spec */
dereference?: boolean; dereference?: boolean;
/** Array of operation IDs to exclude from the tools list */ /** Array of operation IDs to exclude from the tools list */
excludeOperationIds?: string[]; excludeOperationIds?: string[];
/** Optional filter function to exclude tools based on custom criteria */ /** Optional filter function to exclude tools based on custom criteria */
filterFn?: (tool: McpToolDefinition) => boolean; filterFn?: (tool: McpToolDefinition) => boolean;
/** Default behavior for x-mcp filtering (default: true = include by default) */
defaultInclude?: boolean;
}
function isOpenApiDocument(spec: string | OpenAPIV3.Document): spec is OpenAPIV3.Document {
return typeof spec === 'object' && spec !== null && 'openapi' in spec;
} }
/** /**
* Get a list of tools from an OpenAPI specification * Get a list of tools from an OpenAPI specification
* *
* @param specPathOrUrl Path or URL to the OpenAPI specification * @param specPathOrUrl Path or URL to the OpenAPI specification
* @param options Options for generating the tools * @param options Options for generating the tools
* @returns Promise that resolves to an array of tool definitions * @returns Promise that resolves to an array of tool definitions
*/ */
export async function getToolsFromOpenApi( export async function getToolsFromOpenApi(
specPathOrUrl: string | OpenAPIV3.Document, specPathOrUrl: string,
options: GetToolsOptions = {} options: GetToolsOptions = {}
): Promise<McpToolDefinition[]> { ): Promise<McpToolDefinition[]> {
try { try {
// Parse the OpenAPI spec // Parse the OpenAPI spec
const api = isOpenApiDocument(specPathOrUrl) const api = options.dereference
? specPathOrUrl ? (await SwaggerParser.dereference(specPathOrUrl)) as OpenAPIV3.Document
: options.dereference : (await SwaggerParser.parse(specPathOrUrl)) as OpenAPIV3.Document;
? ((await SwaggerParser.dereference(specPathOrUrl)) as OpenAPIV3.Document)
: ((await SwaggerParser.parse(specPathOrUrl)) as OpenAPIV3.Document);
// Extract tools from the API // Extract tools from the API
const allTools = extractToolsFromApi(api, options.defaultInclude ?? true); const allTools = extractToolsFromApi(api);
// Add base URL to each tool // Add base URL to each tool
const baseUrl = determineBaseUrl(api, options.baseUrl); const baseUrl = determineBaseUrl(api, options.baseUrl);
// Apply filters to exclude specified operationIds and custom filter function // Apply filters to exclude specified operationIds and custom filter function
let filteredTools = allTools; let filteredTools = allTools;
// Filter by excluded operation IDs if provided // Filter by excluded operation IDs if provided
if (options.excludeOperationIds && options.excludeOperationIds.length > 0) { if (options.excludeOperationIds && options.excludeOperationIds.length > 0) {
const excludeSet = new Set(options.excludeOperationIds); const excludeSet = new Set(options.excludeOperationIds);
filteredTools = filteredTools.filter((tool) => !excludeSet.has(tool.operationId)); filteredTools = filteredTools.filter(tool => !excludeSet.has(tool.operationId));
} }
// Apply custom filter function if provided // Apply custom filter function if provided
if (options.filterFn) { if (options.filterFn) {
filteredTools = filteredTools.filter(options.filterFn); filteredTools = filteredTools.filter(options.filterFn);
} }
// Return the filtered tools with base URL added // Return the filtered tools with base URL added
return filteredTools.map((tool) => ({ return filteredTools.map(tool => ({
...tool, ...tool,
baseUrl: baseUrl || '', baseUrl: baseUrl || '',
})); }));
} catch (error) { } catch (error) {
// Provide more context for the error // Provide more context for the error
if (error instanceof Error) { if (error instanceof Error) {
// Preserve original stack/context throw new Error(`Failed to extract tools from OpenAPI: ${error.message}`);
throw new Error(`Failed to extract tools from OpenAPI: ${error.message}`, { cause: error });
} }
throw error; throw error;
} }
} }
// Export types for convenience // Export types for convenience
export { McpToolDefinition }; export { McpToolDefinition };

View File

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

View File

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

View File

@ -30,8 +30,6 @@ import {
// Import types // Import types
import { CliOptions, TransportType } from './types/index.js'; import { CliOptions, TransportType } from './types/index.js';
import { normalizeBoolean } from './utils/helpers.js';
import pkg from '../package.json' with { type: 'json' };
// Export programmatic API // Export programmatic API
export { getToolsFromOpenApi, McpToolDefinition, GetToolsOptions } from './api.js'; export { getToolsFromOpenApi, McpToolDefinition, GetToolsOptions } from './api.js';
@ -73,30 +71,23 @@ program
'Port for web or streamable-http transport (default: 3000)', 'Port for web or streamable-http transport (default: 3000)',
(val) => parseInt(val, 10) (val) => parseInt(val, 10)
) )
.option(
'--default-include <boolean>',
'Default behavior for x-mcp filtering (true|false, case-insensitive). Default: true (include by default), false = exclude by default',
(val) => {
const parsed = normalizeBoolean(val);
if (typeof parsed === 'boolean') return parsed;
console.warn(
`Invalid value for --default-include: "${val}". Expected true/false (case-insensitive). Using default: true.`
);
return true;
},
true
)
.option('--force', 'Overwrite existing files without prompting') .option('--force', 'Overwrite existing files without prompting')
.version(pkg.version) // Match package.json version .version('3.1.0'); // Match package.json version
.action((options) => {
runGenerator(options).catch((error) => { // 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); console.error('Unhandled error:', error);
process.exit(1); process.exit(1);
}); });
}); }
// Export the program object for use in bin stub
export { program };
/** /**
* Main function to run the generator * Main function to run the generator
@ -297,4 +288,4 @@ async function runGenerator(options: CliOptions & { force?: boolean }) {
} }
// Export the run function for programmatic usage // Export the run function for programmatic usage
export { runGenerator as generateMcpServer }; export { runGenerator as generateMcpServer };

View File

@ -5,7 +5,6 @@ import { OpenAPIV3 } from 'openapi-types';
import type { JSONSchema7, JSONSchema7TypeName } from 'json-schema'; import type { JSONSchema7, JSONSchema7TypeName } from 'json-schema';
import { generateOperationId } from '../utils/code-gen.js'; import { generateOperationId } from '../utils/code-gen.js';
import { McpToolDefinition } from '../types/index.js'; import { McpToolDefinition } from '../types/index.js';
import { shouldIncludeOperationForMcp } from '../utils/helpers.js';
/** /**
* Extracts tool definitions from an OpenAPI document * Extracts tool definitions from an OpenAPI document
@ -13,10 +12,7 @@ import { shouldIncludeOperationForMcp } from '../utils/helpers.js';
* @param api OpenAPI document * @param api OpenAPI document
* @returns Array of MCP tool definitions * @returns Array of MCP tool definitions
*/ */
export function extractToolsFromApi( export function extractToolsFromApi(api: OpenAPIV3.Document): McpToolDefinition[] {
api: OpenAPIV3.Document,
defaultInclude: boolean = true
): McpToolDefinition[] {
const tools: McpToolDefinition[] = []; const tools: McpToolDefinition[] = [];
const usedNames = new Set<string>(); const usedNames = new Set<string>();
const globalSecurity = api.security || []; const globalSecurity = api.security || [];
@ -30,43 +26,15 @@ export function extractToolsFromApi(
const operation = pathItem[method]; const operation = pathItem[method];
if (!operation) continue; if (!operation) continue;
// Apply x-mcp filtering, precedence: operation > path > root
try {
if (
!shouldIncludeOperationForMcp(
api,
pathItem as OpenAPIV3.PathItemObject,
operation,
defaultInclude
)
) {
continue;
}
} catch (error) {
const loc = operation.operationId || `${method} ${path}`;
const extVal =
(operation as any)['x-mcp'] ?? (pathItem as any)['x-mcp'] ?? (api as any)['x-mcp'];
let extPreview: string;
try {
extPreview = JSON.stringify(extVal);
} catch {
extPreview = String(extVal);
}
console.warn(
`Error evaluating x-mcp extension for operation ${loc} (x-mcp=${extPreview}):`,
error
);
if (!defaultInclude) {
continue;
}
}
// Generate a unique name for the tool // Generate a unique name for the tool
let baseName = operation.operationId || generateOperationId(method, path); let baseName = operation.operationId || generateOperationId(method, path);
if (!baseName) continue; if (!baseName) continue;
// Sanitize the name to be MCP-compatible (only a-z, 0-9, _, -) // Sanitize the name to be MCP-compatible (only a-z, 0-9, _, -)
baseName = baseName.replace(/\./g, '_').replace(/[^a-z0-9_-]/gi, '_'); baseName = baseName
.replace(/\./g, '_')
.replace(/[^a-z0-9_-]/gi, '_')
.toLowerCase();
let finalToolName = baseName; let finalToolName = baseName;
let counter = 1; 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 * 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 seen WeakSet tracking already visited schema objects *
* @returns JSON Schema representation * @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( export function mapOpenApiSchemaToJsonSchema(
schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject
seen: WeakSet<object> = new WeakSet()
): JSONSchema7 | boolean { ): JSONSchema7 | boolean {
// Handle reference objects // Handle reference objects
if ('$ref' in schema) { if ('$ref' in schema) {
@ -204,73 +174,85 @@ export function mapOpenApiSchemaToJsonSchema(
// Handle boolean schemas // Handle boolean schemas
if (typeof schema === 'boolean') return schema; if (typeof schema === 'boolean') return schema;
// Detect cycles // Create a copy of the schema to modify
if (seen.has(schema)) { let jsonSchema: JSONSchema7 = { ...schema } as any;
console.warn(
`Cycle detected in schema${schema.title ? ` "${schema.title}"` : ''}, returning generic object to break recursion.`
);
return { type: 'object' };
}
seen.add(schema);
try { if (schema.oneOf || schema.anyOf || schema.allOf) {
// Create a copy of the schema to modify const oneSchema = structuredClone(schema.oneOf || schema.anyOf || schema.allOf);
const jsonSchema: JSONSchema7 = { ...schema } as any;
// Convert integer type to number (JSON Schema compatible) if (oneSchema) {
if (schema.type === 'integer') jsonSchema.type = 'number'; const combinedSchema = mapOpenApiSchemaToJsonSchema(oneSchema[0]);
// Remove OpenAPI-specific properties that aren't in JSON Schema for (let i = 1; i < oneSchema.length; i++) {
delete (jsonSchema as any).nullable; const mappedSubSchema = mapOpenApiSchemaToJsonSchema(oneSchema[i]);
delete (jsonSchema as any).example; if (typeof mappedSubSchema === 'object' && typeof combinedSchema === 'object') {
delete (jsonSchema as any).xml; // Handle enum values
delete (jsonSchema as any).externalDocs; if (mappedSubSchema.enum) {
delete (jsonSchema as any).deprecated; if (!combinedSchema.enum) {
delete (jsonSchema as any).readOnly; combinedSchema.enum = [];
delete (jsonSchema as any).writeOnly; }
// Combine enum values from both schemas
// Handle nullable properties by adding null to the type const uniqueEnums = new Set([
if (schema.nullable) { ...combinedSchema.enum,
if (Array.isArray(jsonSchema.type)) { ...(mappedSubSchema.enum || [])
if (!jsonSchema.type.includes('null')) jsonSchema.type.push('null'); ]);
} else if (typeof jsonSchema.type === 'string') { combinedSchema.enum = Array.from(uniqueEnums);
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;
} }
} }
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 jsonSchema.properties = mappedProps;
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);
} }
// 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; transport?: TransportType;
/** Server port (for web and streamable-http transports) */ /** Server port (for web and streamable-http transports) */
port?: number; port?: number;
/**
* Default behavior for x-mcp filtering.
* true (default) = include by default when x-mcp is missing or invalid;
* false = exclude by default unless x-mcp explicitly enables.
*/
defaultInclude?: boolean;
} }
/** /**

View File

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

View File

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