Fix and add streamable http
This commit is contained in:
parent
258074b2d6
commit
a4d5e589e6
79
README.md
79
README.md
@ -6,7 +6,7 @@
|
||||
|
||||
Generate [Model Context Protocol (MCP)](https://modelcontextprotocol.github.io/) servers from OpenAPI specifications.
|
||||
|
||||
This CLI tool automates the generation of MCP-compatible servers that proxy requests to existing REST APIs—enabling AI agents and other MCP clients to seamlessly interact with your APIs using either standard input/output or HTTP-based transport.
|
||||
This CLI tool automates the generation of MCP-compatible servers that proxy requests to existing REST APIs—enabling AI agents and other MCP clients to seamlessly interact with your APIs using your choice of transport methods.
|
||||
|
||||
---
|
||||
|
||||
@ -17,9 +17,9 @@ This CLI tool automates the generation of MCP-compatible servers that proxy requ
|
||||
- 🔐 **Authentication Support**: API keys, Bearer tokens, Basic auth, and OAuth2 supported via environment variables.
|
||||
- 🧪 **Zod Validation**: Automatically generates Zod schemas from OpenAPI definitions for runtime input validation.
|
||||
- ⚙️ **Typed Server**: Fully typed, maintainable TypeScript code output.
|
||||
- 💬 **Stdio & Web Transport**: Communicate over stdio or HTTP (beta, SSE support).
|
||||
- 🔌 **Multiple Transports**: Communicate over stdio, SSE via Hono, or StreamableHTTP.
|
||||
- 🧰 **Project Scaffold**: Generates a complete Node.js project with `tsconfig.json`, `package.json`, and entry point.
|
||||
- 🧪 **Built-in HTML Test Client** *(Web mode)*: Test API interactions visually in your browser.
|
||||
- 🧪 **Built-in HTML Test Clients**: Test API interactions visually in your browser (for web-based transports).
|
||||
|
||||
---
|
||||
|
||||
@ -39,8 +39,11 @@ npm install -g openapi-mcp-generator
|
||||
# Generate an MCP server (stdio)
|
||||
openapi-mcp-generator --input path/to/openapi.json --output path/to/output/dir
|
||||
|
||||
# Generate an MCP web server (beta)
|
||||
# Generate an MCP web server with SSE
|
||||
openapi-mcp-generator --input path/to/openapi.json --output path/to/output/dir --transport=web --port=3000
|
||||
|
||||
# Generate an MCP StreamableHTTP server
|
||||
openapi-mcp-generator --input path/to/openapi.json --output path/to/output/dir --transport=streamable-http --port=3000
|
||||
```
|
||||
|
||||
### CLI Options
|
||||
@ -52,8 +55,8 @@ openapi-mcp-generator --input path/to/openapi.json --output path/to/output/dir -
|
||||
| `--server-name` | `-n` | Name of the MCP server (`package.json:name`) | OpenAPI title or `mcp-api-server` |
|
||||
| `--server-version` | `-v` | Version of the MCP server (`package.json:version`) | OpenAPI version or `1.0.0` |
|
||||
| `--base-url` | `-b` | Base URL for API requests. Required if OpenAPI `servers` missing or ambiguous. | Auto-detected if possible |
|
||||
| `--transport` | `-t` | Transport mode: `"stdio"` (default) or `"web"` (beta) | `"stdio"` |
|
||||
| `--port` | `-p` | Port for web server mode | `3000` |
|
||||
| `--transport` | `-t` | Transport mode: `"stdio"` (default), `"web"`, or `"streamable-http"` | `"stdio"` |
|
||||
| `--port` | `-p` | Port for web-based transports | `3000` |
|
||||
| `--force` | | Overwrite existing files in the output directory without confirmation | `false` |
|
||||
---
|
||||
|
||||
@ -66,13 +69,20 @@ The generated project includes:
|
||||
├── .gitignore
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── src/
|
||||
└── index.ts
|
||||
├── .env.example
|
||||
├── src/
|
||||
│ ├── index.ts
|
||||
│ └── [transport-specific-files]
|
||||
└── public/ # For web-based transports
|
||||
└── index.html # Test client
|
||||
```
|
||||
|
||||
- Uses `axios`, `zod`, `@modelcontextprotocol/sdk`, and `json-schema-to-zod`
|
||||
- Secure API key/tokens via environment variables
|
||||
- Tool generation for each endpoint
|
||||
Core dependencies:
|
||||
- `@modelcontextprotocol/sdk` - MCP protocol implementation
|
||||
- `axios` - HTTP client for API requests
|
||||
- `zod` - Runtime validation
|
||||
- `json-schema-to-zod` - Convert JSON Schema to Zod
|
||||
- Transport-specific deps (Hono, uuid, etc.)
|
||||
|
||||
---
|
||||
|
||||
@ -82,7 +92,7 @@ The generated project includes:
|
||||
|
||||
Communicates with MCP clients via standard input/output. Ideal for local development or integration with LLM tools.
|
||||
|
||||
### Web Server Mode (Beta)
|
||||
### Web Server with SSE
|
||||
|
||||
Launches a fully functional HTTP server with:
|
||||
|
||||
@ -90,8 +100,34 @@ Launches a fully functional HTTP server with:
|
||||
- REST endpoint for client → server communication
|
||||
- In-browser test client UI
|
||||
- Multi-connection support
|
||||
- Built with lightweight Hono framework
|
||||
|
||||
> ⚠️ **Note**: Web mode is experimental and may have breaking changes in future updates.
|
||||
### StreamableHTTP
|
||||
|
||||
Implements the MCP StreamableHTTP transport which offers:
|
||||
|
||||
- Stateful JSON-RPC over HTTP POST requests
|
||||
- Session management using HTTP headers
|
||||
- Proper HTTP response status codes
|
||||
- Built-in error handling
|
||||
- Compatibility with MCP StreamableHTTPClientTransport
|
||||
- In-browser test client UI
|
||||
- Built with lightweight Hono framework
|
||||
|
||||
### Transport Comparison
|
||||
|
||||
| Feature | stdio | web (SSE) | streamable-http |
|
||||
|---------|-------|-----------|----------------|
|
||||
| Protocol | JSON-RPC over stdio | JSON-RPC over SSE | JSON-RPC over HTTP |
|
||||
| Connection | Persistent | Persistent | Request/response |
|
||||
| Bidirectional | Yes | Yes | Yes (stateful) |
|
||||
| Multiple clients | No | Yes | Yes |
|
||||
| Browser compatible | No | Yes | Yes |
|
||||
| Firewall friendly | No | Yes | Yes |
|
||||
| Load balancing | No | Limited | Yes |
|
||||
| Status codes | No | Limited | Full HTTP codes |
|
||||
| Headers | No | Limited | Full HTTP headers |
|
||||
| Test client | No | Yes | Yes |
|
||||
|
||||
---
|
||||
|
||||
@ -117,15 +153,26 @@ npm install
|
||||
# Run in stdio mode
|
||||
npm start
|
||||
|
||||
# Run in web server mode (if generated with --transport=web)
|
||||
# Run in web server mode
|
||||
npm run start:web
|
||||
|
||||
# Run in StreamableHTTP mode
|
||||
npm run start:http
|
||||
```
|
||||
|
||||
### Testing Web-Based Servers
|
||||
|
||||
For web and StreamableHTTP transports, a browser-based test client is automatically generated:
|
||||
|
||||
1. Start the server using the appropriate command
|
||||
2. Open your browser to `http://localhost:<port>`
|
||||
3. Use the test client to interact with your MCP server
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Requirements
|
||||
|
||||
- Node.js v18 or later
|
||||
- Node.js v20 or later
|
||||
|
||||
---
|
||||
|
||||
@ -154,4 +201,4 @@ Contributions are welcome!
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License — see [LICENSE](./LICENSE) for full details.
|
||||
MIT License — see [LICENSE](./LICENSE) for full details.
|
||||
13
examples/pet-store-sse/.env.example
Normal file
13
examples/pet-store-sse/.env.example
Normal file
@ -0,0 +1,13 @@
|
||||
# MCP Server Environment Variables
|
||||
# Copy this file to .env and fill in the values
|
||||
|
||||
# Server configuration
|
||||
PORT=3000
|
||||
LOG_LEVEL=info
|
||||
|
||||
# API Authentication
|
||||
# OAuth2 authentication (implicit flow)
|
||||
OAUTH_TOKEN_PETSTORE_AUTH=your_oauth_token_here
|
||||
API_KEY_API_KEY=your_api_key_here
|
||||
|
||||
# Add any other environment variables your API might need
|
||||
33
examples/pet-store-sse/.eslintrc.json
Normal file
33
examples/pet-store-sse/.eslintrc.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"env": {
|
||||
"node": true,
|
||||
"es2022": true
|
||||
},
|
||||
"rules": {
|
||||
"no-console": [
|
||||
"error",
|
||||
{
|
||||
"allow": [
|
||||
"error",
|
||||
"warn"
|
||||
]
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
64
examples/pet-store-sse/.gitignore
vendored
Normal file
64
examples/pet-store-sse/.gitignore
vendored
Normal file
@ -0,0 +1,64 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
build
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Reports
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage
|
||||
coverage
|
||||
*.lcov
|
||||
.nyc_output
|
||||
|
||||
# Build artifacts
|
||||
.grunt
|
||||
bower_components
|
||||
jspm_packages/
|
||||
web_modules/
|
||||
.lock-wscript
|
||||
|
||||
# Editor settings
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
.idea
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
|
||||
# Caches
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
.node_repl_history
|
||||
.browserslistcache
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# OS specific
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
7
examples/pet-store-sse/.prettierrc
Normal file
7
examples/pet-store-sse/.prettierrc
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
23
examples/pet-store-sse/docs/oauth2-configuration.md
Normal file
23
examples/pet-store-sse/docs/oauth2-configuration.md
Normal file
@ -0,0 +1,23 @@
|
||||
# OAuth2 Configuration
|
||||
|
||||
This API uses OAuth2 for authentication. The MCP server can handle OAuth2 authentication in the following ways:
|
||||
|
||||
1. **Using a pre-acquired token**: You provide a token you've already obtained
|
||||
2. **Using client credentials flow**: The server automatically acquires a token using your client ID and secret
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### petstore_auth
|
||||
|
||||
**Configuration Variables:**
|
||||
|
||||
- `OAUTH_CLIENT_ID_PETSTORE_AUTH`: Your OAuth client ID
|
||||
- `OAUTH_CLIENT_SECRET_PETSTORE_AUTH`: Your OAuth client secret
|
||||
## Token Caching
|
||||
|
||||
The MCP server automatically caches OAuth tokens obtained via client credentials flow. Tokens are cached for their lifetime (as specified by the `expires_in` parameter in the token response) minus 60 seconds as a safety margin.
|
||||
|
||||
When making API requests, the server will:
|
||||
1. Check for a cached token that's still valid
|
||||
2. Use the cached token if available
|
||||
3. Request a new token if no valid cached token exists
|
||||
16
examples/pet-store-sse/jest.config.js
Normal file
16
examples/pet-store-sse/jest.config.js
Normal file
@ -0,0 +1,16 @@
|
||||
export default {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
moduleNameMapper: {
|
||||
'^(\.{1,2}/.*)\.js$': '$1',
|
||||
},
|
||||
transform: {
|
||||
'^.+\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
useESM: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
1224
examples/pet-store-sse/package-lock.json
generated
Normal file
1224
examples/pet-store-sse/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
examples/pet-store-sse/package.json
Normal file
37
examples/pet-store-sse/package.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "swagger-petstore---openapi-3-0",
|
||||
"version": "1.0.26",
|
||||
"description": "MCP Server generated from OpenAPI spec for swagger-petstore---openapi-3-0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "build/index.js",
|
||||
"files": [
|
||||
"build",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "node build/index.js",
|
||||
"build": "tsc && chmod 755 build/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prestart": "npm run build",
|
||||
"start:web": "node build/index.js --transport=web"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.10.0",
|
||||
"axios": "^1.9.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"zod": "^3.24.3",
|
||||
"json-schema-to-zod": "^2.6.1",
|
||||
"hono": "^4.7.7",
|
||||
"@hono/node-server": "^1.14.1",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.2",
|
||||
"typescript": "^5.8.3",
|
||||
"@types/uuid": "^10.0.0"
|
||||
}
|
||||
}
|
||||
393
examples/pet-store-sse/public/index.html
Normal file
393
examples/pet-store-sse/public/index.html
Normal file
@ -0,0 +1,393 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>swagger-petstore---openapi-3-0 MCP Test Client</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
h1 { margin-bottom: 10px; }
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 150px);
|
||||
}
|
||||
#conversation {
|
||||
flex: 1;
|
||||
border: 1px solid #ccc;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.input-area {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
#userInput {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
#sendButton {
|
||||
padding: 8px 16px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
#sendButton:hover { background-color: #45a049; }
|
||||
.message {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.user {
|
||||
background-color: #e7f4ff;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.server {
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
.system {
|
||||
background-color: #fffde7;
|
||||
color: #795548;
|
||||
font-style: italic;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
code {
|
||||
background-color: #f8f8f8;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.status {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#debug {
|
||||
margin-top: 20px;
|
||||
background-color: #f8f8f8;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
display: none;
|
||||
}
|
||||
.debug-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
#showDebug {
|
||||
margin-top: 10px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
background-color: #f1f1f1;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
#debugLog {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: #111;
|
||||
color: #0f0;
|
||||
font-family: monospace;
|
||||
padding: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.clear-debug {
|
||||
padding: 3px 8px;
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>swagger-petstore---openapi-3-0 MCP Test Client</h1>
|
||||
<p class="status" id="status">Disconnected</p>
|
||||
|
||||
<div class="container">
|
||||
<div id="conversation"></div>
|
||||
|
||||
<div class="input-area">
|
||||
<input type="text" id="userInput" placeholder="Type a message..." disabled>
|
||||
<button id="sendButton" disabled>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="showDebug">Show Debug Console</button>
|
||||
|
||||
<div id="debug">
|
||||
<div class="debug-controls">
|
||||
<h3>Debug Console</h3>
|
||||
<button class="clear-debug" id="clearDebug">Clear</button>
|
||||
</div>
|
||||
<div id="debugLog"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const conversation = document.getElementById('conversation');
|
||||
const userInput = document.getElementById('userInput');
|
||||
const sendButton = document.getElementById('sendButton');
|
||||
const statusEl = document.getElementById('status');
|
||||
const showDebugBtn = document.getElementById('showDebug');
|
||||
const debugDiv = document.getElementById('debug');
|
||||
const debugLog = document.getElementById('debugLog');
|
||||
const clearDebugBtn = document.getElementById('clearDebug');
|
||||
|
||||
let sessionId = null;
|
||||
let messageId = 1;
|
||||
let eventSource = null;
|
||||
let apiEndpoint = 'http://localhost:3000/api/messages'; // default endpoint
|
||||
|
||||
// Debug logging
|
||||
function log(type, message) {
|
||||
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1);
|
||||
const entry = document.createElement('div');
|
||||
entry.innerHTML = `<span style="color:#aaa;">${timestamp}</span> <span style="color:#58a6ff;">${type}:</span> ${message}`;
|
||||
debugLog.appendChild(entry);
|
||||
debugLog.scrollTop = debugLog.scrollHeight;
|
||||
console.log(`${type}: ${message}`);
|
||||
}
|
||||
|
||||
// Toggle debug console
|
||||
showDebugBtn.addEventListener('click', () => {
|
||||
if (debugDiv.style.display === 'block') {
|
||||
debugDiv.style.display = 'none';
|
||||
showDebugBtn.textContent = 'Show Debug Console';
|
||||
} else {
|
||||
debugDiv.style.display = 'block';
|
||||
showDebugBtn.textContent = 'Hide Debug Console';
|
||||
}
|
||||
});
|
||||
|
||||
// Clear debug logs
|
||||
clearDebugBtn.addEventListener('click', () => {
|
||||
debugLog.innerHTML = '';
|
||||
});
|
||||
|
||||
// Connect to SSE endpoint
|
||||
function connect() {
|
||||
statusEl.textContent = 'Connecting...';
|
||||
log('INFO', 'Connecting to SSE endpoint...');
|
||||
|
||||
// Close existing connection if any
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
log('INFO', 'Closed existing connection');
|
||||
}
|
||||
|
||||
eventSource = new EventSource('http://localhost:3000/sse');
|
||||
|
||||
eventSource.onopen = () => {
|
||||
log('INFO', 'SSE connection opened');
|
||||
statusEl.textContent = 'Connected, waiting for session ID...';
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
log('ERROR', `SSE connection error: ${error}`);
|
||||
statusEl.textContent = 'Connection error. Reconnecting in 3s...';
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
// Listen for the endpoint event
|
||||
eventSource.addEventListener('endpoint', (event) => {
|
||||
apiEndpoint = event.data;
|
||||
log('INFO', `API endpoint received: ${apiEndpoint}`);
|
||||
});
|
||||
|
||||
// Listen for the session event
|
||||
eventSource.addEventListener('session', (event) => {
|
||||
log('INFO', `Session data received: ${event.data}`);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'session_id') {
|
||||
sessionId = data.session_id;
|
||||
statusEl.textContent = `Connected (Session ID: ${sessionId})`;
|
||||
userInput.disabled = false;
|
||||
sendButton.disabled = false;
|
||||
userInput.focus();
|
||||
appendMessage('system', `Connected with session ID: ${sessionId}`);
|
||||
log('INFO', `Received session ID: ${sessionId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
log('ERROR', `Error parsing session data: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 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() {
|
||||
const text = userInput.value.trim();
|
||||
if (!text || !sessionId) return;
|
||||
|
||||
appendMessage('user', text);
|
||||
userInput.value = '';
|
||||
|
||||
log('INFO', `Sending message: ${text}`);
|
||||
|
||||
try {
|
||||
const parts = text.split(' ');
|
||||
const toolName = parts[0];
|
||||
|
||||
const requestBody = {
|
||||
jsonrpc: '2.0',
|
||||
id: messageId++,
|
||||
method: 'callTool',
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: parseArguments(text)
|
||||
}
|
||||
};
|
||||
|
||||
log('REQUEST', JSON.stringify(requestBody));
|
||||
|
||||
// Use the endpoint provided by the server, or fall back to the default
|
||||
const fullEndpoint = `http://localhost:3000/api/messages?sessionId=${sessionId}`;
|
||||
console.log('fullEndpoint', fullEndpoint);
|
||||
|
||||
const response = await fetch(fullEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log('ERROR', `Error response: ${response.status} ${response.statusText} ${errorText}`);
|
||||
appendMessage('system', `Error: ${response.status} ${response.statusText}\n${errorText}`);
|
||||
} else {
|
||||
log('INFO', `Request sent successfully`);
|
||||
// Note: We don't handle the response content here because the response
|
||||
// will come through the SSE connection, not this fetch response
|
||||
}
|
||||
} catch (error) {
|
||||
log('ERROR', `Error sending message: ${error.message}`);
|
||||
appendMessage('system', `Error sending message: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
799
examples/pet-store-sse/src/index.ts
Normal file
799
examples/pet-store-sse/src/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
286
examples/pet-store-sse/src/web-server.ts
Normal file
286
examples/pet-store-sse/src/web-server.ts
Normal file
@ -0,0 +1,286 @@
|
||||
|
||||
/**
|
||||
* Web server setup for HTTP-based MCP communication using Hono
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { streamSSE } from 'hono/streaming';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { JSONRPCMessage, JSONRPCMessageSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { Context } from 'hono';
|
||||
import type { SSEStreamingApi } from 'hono/streaming';
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
|
||||
// Import server configuration constants
|
||||
import { SERVER_NAME, SERVER_VERSION } from './index.js';
|
||||
|
||||
/**
|
||||
* Custom SSE Transport implementation using Hono's streaming API
|
||||
*/
|
||||
class SSETransport implements Transport {
|
||||
private _sessionId: string;
|
||||
private stream: SSEStreamingApi;
|
||||
private messageUrl: string;
|
||||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
constructor(messageUrl: string, stream: SSEStreamingApi) {
|
||||
this._sessionId = uuid();
|
||||
this.stream = stream;
|
||||
this.messageUrl = messageUrl;
|
||||
|
||||
// Set up stream abort handler
|
||||
this.stream.onAbort(() => {
|
||||
console.error(`SSE connection aborted for session ${this._sessionId}`);
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
get sessionId(): string {
|
||||
return this._sessionId;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.stream.closed) {
|
||||
throw new Error('SSE transport already closed!');
|
||||
}
|
||||
|
||||
// Send the endpoint information
|
||||
await this.stream.writeSSE({
|
||||
event: 'endpoint',
|
||||
data: `${this.messageUrl}?sessionId=${this._sessionId}`
|
||||
});
|
||||
|
||||
// Send session ID and connection info in a format the client can understand
|
||||
await this.stream.writeSSE({
|
||||
event: 'session',
|
||||
data: JSON.stringify({
|
||||
type: 'session_id',
|
||||
session_id: this._sessionId
|
||||
})
|
||||
});
|
||||
|
||||
// Send a welcome notification
|
||||
await this.send({
|
||||
jsonrpc: "2.0",
|
||||
method: "notification",
|
||||
params: {
|
||||
type: "welcome",
|
||||
clientInfo: {
|
||||
sessionId: this._sessionId,
|
||||
serverName: SERVER_NAME,
|
||||
serverVersion: SERVER_VERSION
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handlePostMessage(c: Context): Promise<Response> {
|
||||
if (this.stream?.closed) {
|
||||
return c.text('SSE connection closed', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse and validate the message
|
||||
const body = await c.req.json();
|
||||
|
||||
try {
|
||||
// Parse and validate the message
|
||||
const parsedMessage = JSONRPCMessageSchema.parse(body);
|
||||
|
||||
// Forward to the message handler
|
||||
if (this.onmessage) {
|
||||
this.onmessage(parsedMessage);
|
||||
return c.text('Accepted', 202);
|
||||
} else {
|
||||
return c.text('No message handler defined', 500);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.onerror) {
|
||||
this.onerror(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
console.error('Error parsing message:', error);
|
||||
return c.text('Invalid message format', 400);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.onerror) {
|
||||
this.onerror(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
console.error('Error processing request:', error);
|
||||
return c.text('Error processing message', 400);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.stream && !this.stream.closed) {
|
||||
this.stream.abort();
|
||||
}
|
||||
|
||||
if (this.onclose) {
|
||||
this.onclose();
|
||||
}
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
if (this.stream.closed) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.stream.writeSSE({
|
||||
event: 'message',
|
||||
data: JSON.stringify(message)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a web server for the MCP server using Server-Sent Events (SSE)
|
||||
*
|
||||
* @param server The MCP Server instance
|
||||
* @param port The port to listen on (default: 3000)
|
||||
* @returns The Hono app instance
|
||||
*/
|
||||
export async function setupWebServer(server: Server, port = 3000) {
|
||||
// Create Hono app
|
||||
const app = new Hono();
|
||||
|
||||
// Enable CORS
|
||||
app.use('*', cors());
|
||||
|
||||
// Store active SSE transports by session ID
|
||||
const transports: {[sessionId: string]: SSETransport} = {};
|
||||
|
||||
// Add a simple health check endpoint
|
||||
app.get('/health', (c) => {
|
||||
return c.json({ status: 'OK', server: SERVER_NAME, version: SERVER_VERSION });
|
||||
});
|
||||
|
||||
// SSE endpoint for clients to connect to
|
||||
app.get("/sse", (c) => {
|
||||
return streamSSE(c, async (stream) => {
|
||||
// Create SSE transport
|
||||
const transport = new SSETransport('/api/messages', stream);
|
||||
const sessionId = transport.sessionId;
|
||||
|
||||
console.error(`New SSE connection established: ${sessionId}`);
|
||||
|
||||
// Store the transport
|
||||
transports[sessionId] = transport;
|
||||
|
||||
// Set up cleanup on transport close
|
||||
transport.onclose = () => {
|
||||
console.error(`SSE connection closed for session ${sessionId}`);
|
||||
delete transports[sessionId];
|
||||
};
|
||||
|
||||
// Make the transport available to the MCP server
|
||||
try {
|
||||
transport.onmessage = async (message: JSONRPCMessage) => {
|
||||
try {
|
||||
// The server will automatically send a response via the transport
|
||||
// if the message has an ID (i.e., it's a request, not a notification)
|
||||
} catch (error) {
|
||||
console.error('Error handling MCP message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to the MCP server
|
||||
await server.connect(transport);
|
||||
} catch (error) {
|
||||
console.error(`Error connecting transport for session ${sessionId}:`, error);
|
||||
}
|
||||
|
||||
// Keep the stream open until aborted
|
||||
while (!stream.closed) {
|
||||
await stream.sleep(1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// API endpoint for clients to send messages
|
||||
app.post("/api/messages", async (c) => {
|
||||
const sessionId = c.req.query('sessionId');
|
||||
|
||||
if (!sessionId) {
|
||||
return c.json({ error: 'Missing sessionId query parameter' }, 400);
|
||||
}
|
||||
|
||||
const transport = transports[sessionId];
|
||||
|
||||
if (!transport) {
|
||||
return c.json({ error: 'No active session found with the provided sessionId' }, 404);
|
||||
}
|
||||
|
||||
return transport.handlePostMessage(c);
|
||||
});
|
||||
|
||||
// Static files for the web client (if any)
|
||||
app.get('/*', async (c) => {
|
||||
const filePath = c.req.path === '/' ? '/index.html' : c.req.path;
|
||||
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) {
|
||||
// File not found or other error
|
||||
return c.text('Not Found', 404);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error serving static file:', err);
|
||||
return c.text('Internal Server Error', 500);
|
||||
}
|
||||
|
||||
return c.text('Not Found', 404);
|
||||
});
|
||||
|
||||
// Start the server
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
port
|
||||
}, (info) => {
|
||||
console.error(`MCP Web Server running at http://localhost:${info.port}`);
|
||||
console.error(`- SSE Endpoint: http://localhost:${info.port}/sse`);
|
||||
console.error(`- Messages Endpoint: http://localhost:${info.port}/api/messages?sessionId=YOUR_SESSION_ID`);
|
||||
console.error(`- Health Check: http://localhost:${info.port}/health`);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
28
examples/pet-store-sse/tsconfig.json
Normal file
28
examples/pet-store-sse/tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"target": "ES2022",
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"noEmit": false,
|
||||
"outDir": "./build",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"build",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
||||
13
examples/pet-store-streamable-http/.env.example
Normal file
13
examples/pet-store-streamable-http/.env.example
Normal file
@ -0,0 +1,13 @@
|
||||
# MCP Server Environment Variables
|
||||
# Copy this file to .env and fill in the values
|
||||
|
||||
# Server configuration
|
||||
PORT=3000
|
||||
LOG_LEVEL=info
|
||||
|
||||
# API Authentication
|
||||
# OAuth2 authentication (implicit flow)
|
||||
OAUTH_TOKEN_PETSTORE_AUTH=your_oauth_token_here
|
||||
API_KEY_API_KEY=your_api_key_here
|
||||
|
||||
# Add any other environment variables your API might need
|
||||
33
examples/pet-store-streamable-http/.eslintrc.json
Normal file
33
examples/pet-store-streamable-http/.eslintrc.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"env": {
|
||||
"node": true,
|
||||
"es2022": true
|
||||
},
|
||||
"rules": {
|
||||
"no-console": [
|
||||
"error",
|
||||
{
|
||||
"allow": [
|
||||
"error",
|
||||
"warn"
|
||||
]
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
64
examples/pet-store-streamable-http/.gitignore
vendored
Normal file
64
examples/pet-store-streamable-http/.gitignore
vendored
Normal file
@ -0,0 +1,64 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
build
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Reports
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage
|
||||
coverage
|
||||
*.lcov
|
||||
.nyc_output
|
||||
|
||||
# Build artifacts
|
||||
.grunt
|
||||
bower_components
|
||||
jspm_packages/
|
||||
web_modules/
|
||||
.lock-wscript
|
||||
|
||||
# Editor settings
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
.idea
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
|
||||
# Caches
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
.node_repl_history
|
||||
.browserslistcache
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# OS specific
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
7
examples/pet-store-streamable-http/.prettierrc
Normal file
7
examples/pet-store-streamable-http/.prettierrc
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
# OAuth2 Configuration
|
||||
|
||||
This API uses OAuth2 for authentication. The MCP server can handle OAuth2 authentication in the following ways:
|
||||
|
||||
1. **Using a pre-acquired token**: You provide a token you've already obtained
|
||||
2. **Using client credentials flow**: The server automatically acquires a token using your client ID and secret
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### petstore_auth
|
||||
|
||||
**Configuration Variables:**
|
||||
|
||||
- `OAUTH_CLIENT_ID_PETSTORE_AUTH`: Your OAuth client ID
|
||||
- `OAUTH_CLIENT_SECRET_PETSTORE_AUTH`: Your OAuth client secret
|
||||
## Token Caching
|
||||
|
||||
The MCP server automatically caches OAuth tokens obtained via client credentials flow. Tokens are cached for their lifetime (as specified by the `expires_in` parameter in the token response) minus 60 seconds as a safety margin.
|
||||
|
||||
When making API requests, the server will:
|
||||
1. Check for a cached token that's still valid
|
||||
2. Use the cached token if available
|
||||
3. Request a new token if no valid cached token exists
|
||||
16
examples/pet-store-streamable-http/jest.config.js
Normal file
16
examples/pet-store-streamable-http/jest.config.js
Normal file
@ -0,0 +1,16 @@
|
||||
export default {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
moduleNameMapper: {
|
||||
'^(\.{1,2}/.*)\.js$': '$1',
|
||||
},
|
||||
transform: {
|
||||
'^.+\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
useESM: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
1231
examples/pet-store-streamable-http/package-lock.json
generated
Normal file
1231
examples/pet-store-streamable-http/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
examples/pet-store-streamable-http/package.json
Normal file
38
examples/pet-store-streamable-http/package.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "swagger-petstore---openapi-3-0",
|
||||
"version": "1.0.26",
|
||||
"description": "MCP Server generated from OpenAPI spec for swagger-petstore---openapi-3-0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "build/index.js",
|
||||
"files": [
|
||||
"build",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "node build/index.js",
|
||||
"build": "tsc && chmod 755 build/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prestart": "npm run build",
|
||||
"start:http": "node build/index.js --transport=streamable-http"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.10.0",
|
||||
"axios": "^1.9.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"zod": "^3.24.3",
|
||||
"json-schema-to-zod": "^2.6.1",
|
||||
"hono": "^4.7.7",
|
||||
"@hono/node-server": "^1.14.1",
|
||||
"uuid": "^11.1.0",
|
||||
"fetch-to-node": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.2",
|
||||
"typescript": "^5.8.3",
|
||||
"@types/uuid": "^10.0.0"
|
||||
}
|
||||
}
|
||||
402
examples/pet-store-streamable-http/public/index.html
Normal file
402
examples/pet-store-streamable-http/public/index.html
Normal file
@ -0,0 +1,402 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>swagger-petstore---openapi-3-0 MCP StreamableHTTP Test Client</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
h1 { margin-bottom: 10px; }
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 150px);
|
||||
}
|
||||
#conversation {
|
||||
flex: 1;
|
||||
border: 1px solid #ccc;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.input-area {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
#userInput {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
#sendButton {
|
||||
padding: 8px 16px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
#sendButton:hover { background-color: #45a049; }
|
||||
.message {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.user {
|
||||
background-color: #e7f4ff;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.server {
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
.system {
|
||||
background-color: #fffde7;
|
||||
color: #795548;
|
||||
font-style: italic;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
code {
|
||||
background-color: #f8f8f8;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.status {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#debug {
|
||||
margin-top: 20px;
|
||||
background-color: #f8f8f8;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
display: none;
|
||||
}
|
||||
.debug-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
#showDebug {
|
||||
margin-top: 10px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
background-color: #f1f1f1;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
#debugLog {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: #111;
|
||||
color: #0f0;
|
||||
font-family: monospace;
|
||||
padding: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.clear-debug {
|
||||
padding: 3px 8px;
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>swagger-petstore---openapi-3-0 MCP StreamableHTTP Test Client</h1>
|
||||
<p class="status" id="status">Disconnected</p>
|
||||
|
||||
<div class="container">
|
||||
<div id="conversation"></div>
|
||||
|
||||
<div class="input-area">
|
||||
<input type="text" id="userInput" placeholder="Type a message..." disabled>
|
||||
<button id="sendButton" disabled>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="showDebug">Show Debug Console</button>
|
||||
|
||||
<div id="debug">
|
||||
<div class="debug-controls">
|
||||
<h3>Debug Console</h3>
|
||||
<button class="clear-debug" id="clearDebug">Clear</button>
|
||||
</div>
|
||||
<div id="debugLog"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const conversation = document.getElementById('conversation');
|
||||
const userInput = document.getElementById('userInput');
|
||||
const sendButton = document.getElementById('sendButton');
|
||||
const statusEl = document.getElementById('status');
|
||||
const showDebugBtn = document.getElementById('showDebug');
|
||||
const debugDiv = document.getElementById('debug');
|
||||
const debugLog = document.getElementById('debugLog');
|
||||
const clearDebugBtn = document.getElementById('clearDebug');
|
||||
|
||||
let sessionId = null;
|
||||
let messageId = 1;
|
||||
|
||||
// Debug logging
|
||||
function log(type, message) {
|
||||
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1);
|
||||
const entry = document.createElement('div');
|
||||
entry.innerHTML = `<span style="color:#aaa;">${timestamp}</span> <span style="color:#58a6ff;">${type}:</span> ${message}`;
|
||||
debugLog.appendChild(entry);
|
||||
debugLog.scrollTop = debugLog.scrollHeight;
|
||||
console.log(`${type}: ${message}`);
|
||||
}
|
||||
|
||||
// Toggle debug console
|
||||
showDebugBtn.addEventListener('click', () => {
|
||||
if (debugDiv.style.display === 'block') {
|
||||
debugDiv.style.display = 'none';
|
||||
showDebugBtn.textContent = 'Show Debug Console';
|
||||
} else {
|
||||
debugDiv.style.display = 'block';
|
||||
showDebugBtn.textContent = 'Hide Debug Console';
|
||||
}
|
||||
});
|
||||
|
||||
// Clear debug logs
|
||||
clearDebugBtn.addEventListener('click', () => {
|
||||
debugLog.innerHTML = '';
|
||||
});
|
||||
|
||||
// Initialize the MCP connection
|
||||
async function initialize() {
|
||||
statusEl.textContent = 'Connecting...';
|
||||
log('INFO', 'Initializing MCP connection...');
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
jsonrpc: '2.0',
|
||||
id: messageId++,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
clientName: 'MCP StreamableHTTP Test Client',
|
||||
clientVersion: '1.0.0',
|
||||
capabilities: {}
|
||||
}
|
||||
};
|
||||
|
||||
log('REQUEST', JSON.stringify(requestBody));
|
||||
|
||||
const response = await fetch('/mcp', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log('ERROR', `Error response: ${response.status} ${response.statusText} ${errorText}`);
|
||||
appendMessage('system', `Error: ${response.status} ${response.statusText}\n${errorText}`);
|
||||
statusEl.textContent = 'Connection error. Try again.';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get session ID from response headers
|
||||
sessionId = response.headers.get('mcp-session-id');
|
||||
|
||||
if (!sessionId) {
|
||||
log('ERROR', 'No session ID in response headers');
|
||||
appendMessage('system', 'Error: No session ID in response headers');
|
||||
statusEl.textContent = 'Connection error. Try again.';
|
||||
return;
|
||||
}
|
||||
|
||||
// Process response body
|
||||
const data = await response.json();
|
||||
log('RESPONSE', JSON.stringify(data));
|
||||
|
||||
if (data.result) {
|
||||
appendMessage('server', JSON.stringify(data.result, null, 2));
|
||||
}
|
||||
|
||||
// Enable UI
|
||||
statusEl.textContent = `Connected (Session ID: ${sessionId})`;
|
||||
userInput.disabled = false;
|
||||
sendButton.disabled = false;
|
||||
userInput.focus();
|
||||
appendMessage('system', `Connected with session ID: ${sessionId}`);
|
||||
|
||||
// Get list of tools
|
||||
await listTools();
|
||||
} catch (error) {
|
||||
log('ERROR', `Error during initialization: ${error.message}`);
|
||||
appendMessage('system', `Error during initialization: ${error.message}`);
|
||||
statusEl.textContent = 'Connection error. Try again.';
|
||||
}
|
||||
}
|
||||
|
||||
// Get list of available tools
|
||||
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 toolName = parts[0];
|
||||
|
||||
const requestBody = {
|
||||
jsonrpc: '2.0',
|
||||
id: messageId++,
|
||||
method: 'callTool',
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: parseArguments(text)
|
||||
}
|
||||
};
|
||||
|
||||
log('REQUEST', JSON.stringify(requestBody));
|
||||
|
||||
const response = await fetch('/mcp', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'mcp-session-id': sessionId
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log('ERROR', `Error response: ${response.status} ${response.statusText} ${errorText}`);
|
||||
appendMessage('system', `Error: ${response.status} ${response.statusText}\n${errorText}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
log('RESPONSE', JSON.stringify(data));
|
||||
|
||||
if (data.error) {
|
||||
appendMessage('system', `Error: ${data.error.code} - ${data.error.message}`);
|
||||
} else if (data.result) {
|
||||
appendMessage('server', JSON.stringify(data.result, null, 2));
|
||||
}
|
||||
} catch (error) {
|
||||
log('ERROR', `Error sending message: ${error.message}`);
|
||||
appendMessage('system', `Error sending message: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse arguments from user input
|
||||
// Format: toolName param1=value1 param2=value2
|
||||
function parseArguments(text) {
|
||||
const parts = text.split(' ');
|
||||
if (parts.length <= 1) return {};
|
||||
|
||||
const args = {};
|
||||
// Skip the first part (tool name) and process the rest
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const equalsIndex = part.indexOf('=');
|
||||
|
||||
if (equalsIndex > 0) {
|
||||
const key = part.substring(0, equalsIndex);
|
||||
const value = part.substring(equalsIndex + 1);
|
||||
|
||||
// Try to parse as number or boolean if possible
|
||||
if (value === 'true') args[key] = true;
|
||||
else if (value === 'false') args[key] = false;
|
||||
else if (!isNaN(Number(value))) args[key] = Number(value);
|
||||
else args[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
// Add a message to the conversation
|
||||
function appendMessage(sender, text) {
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = `message ${sender}`;
|
||||
|
||||
// Format as code block if it looks like JSON
|
||||
if (text.trim().startsWith('{') || text.trim().startsWith('[')) {
|
||||
const pre = document.createElement('pre');
|
||||
const code = document.createElement('code');
|
||||
code.textContent = text;
|
||||
pre.appendChild(code);
|
||||
messageDiv.appendChild(pre);
|
||||
} else {
|
||||
messageDiv.textContent = text;
|
||||
}
|
||||
|
||||
conversation.appendChild(messageDiv);
|
||||
conversation.scrollTop = conversation.scrollHeight;
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
sendButton.addEventListener('click', sendMessage);
|
||||
userInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') sendMessage();
|
||||
});
|
||||
|
||||
// Initialize on page load
|
||||
appendMessage('system', 'Initializing MCP connection...');
|
||||
initialize();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
799
examples/pet-store-streamable-http/src/index.ts
Normal file
799
examples/pet-store-streamable-http/src/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
248
examples/pet-store-streamable-http/src/streamable-http.ts
Normal file
248
examples/pet-store-streamable-http/src/streamable-http.ts
Normal file
@ -0,0 +1,248 @@
|
||||
|
||||
/**
|
||||
* StreamableHTTP server setup for HTTP-based MCP communication using Hono
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { InitializeRequestSchema, JSONRPCError } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { toReqRes, toFetchResponse } from 'fetch-to-node';
|
||||
|
||||
// Import server configuration constants
|
||||
import { SERVER_NAME, SERVER_VERSION } from './index.js';
|
||||
|
||||
// Constants
|
||||
const SESSION_ID_HEADER_NAME = "mcp-session-id";
|
||||
const JSON_RPC = "2.0";
|
||||
|
||||
/**
|
||||
* StreamableHTTP MCP Server handler
|
||||
*/
|
||||
class MCPStreamableHttpServer {
|
||||
server: Server;
|
||||
// Store active transports by session ID
|
||||
transports: {[sessionId: string]: StreamableHTTPServerTransport} = {};
|
||||
|
||||
constructor(server: Server) {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle GET requests (typically used for static files)
|
||||
*/
|
||||
async handleGetRequest(c: any) {
|
||||
console.error("GET request received - StreamableHTTP transport only supports POST");
|
||||
return c.text('Method Not Allowed', 405, {
|
||||
'Allow': 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle POST requests (all MCP communication)
|
||||
*/
|
||||
async handlePostRequest(c: any) {
|
||||
const sessionId = c.req.header(SESSION_ID_HEADER_NAME);
|
||||
console.error(`POST request received ${sessionId ? 'with session ID: ' + sessionId : 'without session ID'}`);
|
||||
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
|
||||
// Convert Fetch Request to Node.js req/res
|
||||
const { req, res } = toReqRes(c.req.raw);
|
||||
|
||||
// Reuse existing transport if we have a session ID
|
||||
if (sessionId && this.transports[sessionId]) {
|
||||
const transport = this.transports[sessionId];
|
||||
|
||||
// Handle the request with the transport
|
||||
await transport.handleRequest(req, res, body);
|
||||
|
||||
// Cleanup when the response ends
|
||||
res.on('close', () => {
|
||||
console.error(`Request closed for session ${sessionId}`);
|
||||
});
|
||||
|
||||
// Convert Node.js response back to Fetch Response
|
||||
return toFetchResponse(res);
|
||||
}
|
||||
|
||||
// Create new transport for initialize requests
|
||||
if (!sessionId && this.isInitializeRequest(body)) {
|
||||
console.error("Creating new StreamableHTTP transport for initialize request");
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => uuid(),
|
||||
});
|
||||
|
||||
// Add error handler for debug purposes
|
||||
transport.onerror = (err) => {
|
||||
console.error('StreamableHTTP transport error:', err);
|
||||
};
|
||||
|
||||
// Connect the transport to the MCP server
|
||||
await this.server.connect(transport);
|
||||
|
||||
// Handle the request with the transport
|
||||
await transport.handleRequest(req, res, body);
|
||||
|
||||
// Store the transport if we have a session ID
|
||||
const newSessionId = transport.sessionId;
|
||||
if (newSessionId) {
|
||||
console.error(`New session established: ${newSessionId}`);
|
||||
this.transports[newSessionId] = transport;
|
||||
|
||||
// Set up clean-up for when the transport is closed
|
||||
transport.onclose = () => {
|
||||
console.error(`Session closed: ${newSessionId}`);
|
||||
delete this.transports[newSessionId];
|
||||
};
|
||||
}
|
||||
|
||||
// Cleanup when the response ends
|
||||
res.on('close', () => {
|
||||
console.error(`Request closed for new session`);
|
||||
});
|
||||
|
||||
// Convert Node.js response back to Fetch Response
|
||||
return toFetchResponse(res);
|
||||
}
|
||||
|
||||
// Invalid request (no session ID and not initialize)
|
||||
return c.json(
|
||||
this.createErrorResponse("Bad Request: invalid session ID or method."),
|
||||
400
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error handling MCP request:', error);
|
||||
return c.json(
|
||||
this.createErrorResponse("Internal server error."),
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JSON-RPC error response
|
||||
*/
|
||||
private createErrorResponse(message: string): JSONRPCError {
|
||||
return {
|
||||
jsonrpc: JSON_RPC,
|
||||
error: {
|
||||
code: -32000,
|
||||
message: message,
|
||||
},
|
||||
id: uuid(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the request is an initialize request
|
||||
*/
|
||||
private isInitializeRequest(body: any): boolean {
|
||||
const isInitial = (data: any) => {
|
||||
const result = InitializeRequestSchema.safeParse(data);
|
||||
return result.success;
|
||||
};
|
||||
|
||||
if (Array.isArray(body)) {
|
||||
return body.some(request => isInitial(request));
|
||||
}
|
||||
|
||||
return isInitial(body);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a web server for the MCP server using StreamableHTTP transport
|
||||
*
|
||||
* @param server The MCP Server instance
|
||||
* @param port The port to listen on (default: 3000)
|
||||
* @returns The Hono app instance
|
||||
*/
|
||||
export async function setupStreamableHttpServer(server: Server, port = 3000) {
|
||||
// Create Hono app
|
||||
const app = new Hono();
|
||||
|
||||
// Enable CORS
|
||||
app.use('*', cors());
|
||||
|
||||
// Create MCP handler
|
||||
const mcpHandler = new MCPStreamableHttpServer(server);
|
||||
|
||||
// Add a simple health check endpoint
|
||||
app.get('/health', (c) => {
|
||||
return c.json({ status: 'OK', server: SERVER_NAME, version: SERVER_VERSION });
|
||||
});
|
||||
|
||||
// Main MCP endpoint supporting both GET and POST
|
||||
app.get("/mcp", (c) => mcpHandler.handleGetRequest(c));
|
||||
app.post("/mcp", (c) => mcpHandler.handlePostRequest(c));
|
||||
|
||||
// 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) {
|
||||
// File not found or other error
|
||||
return c.text('Not Found', 404);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error serving static file:', err);
|
||||
return c.text('Internal Server Error', 500);
|
||||
}
|
||||
|
||||
return c.text('Not Found', 404);
|
||||
});
|
||||
|
||||
// Start the server
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
port
|
||||
}, (info) => {
|
||||
console.error(`MCP StreamableHTTP Server running at http://localhost:${info.port}`);
|
||||
console.error(`- MCP Endpoint: http://localhost:${info.port}/mcp`);
|
||||
console.error(`- Health Check: http://localhost:${info.port}/health`);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
28
examples/pet-store-streamable-http/tsconfig.json
Normal file
28
examples/pet-store-streamable-http/tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"target": "ES2022",
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"noEmit": false,
|
||||
"outDir": "./build",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"build",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
||||
78
package.json
78
package.json
@ -1,61 +1,61 @@
|
||||
{
|
||||
"name": "openapi-mcp-generator",
|
||||
"version": "2.5.0-beta.0",
|
||||
"version": "3.0.0",
|
||||
"description": "Generates MCP server code from OpenAPI specifications",
|
||||
"license": "MIT",
|
||||
"author": "Harsha",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"openapi-mcp-generator": "./dist/index.js"
|
||||
"openapi-mcp-generator": "./dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc && chmod 755 dist/index.js"
|
||||
"start": "node dist/index.js",
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc && chmod 755 dist/index.js"
|
||||
},
|
||||
"keywords": [
|
||||
"openapi",
|
||||
"mcp",
|
||||
"model-context-protocol",
|
||||
"generator",
|
||||
"llm",
|
||||
"ai",
|
||||
"api"
|
||||
"openapi",
|
||||
"mcp",
|
||||
"model-context-protocol",
|
||||
"generator",
|
||||
"llm",
|
||||
"ai",
|
||||
"api"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/harsha-iiiv/openapi-mcp-generator.git"
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/harsha-iiiv/openapi-mcp-generator.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/harsha-iiiv/openapi-mcp-generator/issues"
|
||||
"url": "https://github.com/harsha-iiiv/openapi-mcp-generator/issues"
|
||||
},
|
||||
"homepage": "https://github.com/harsha-iiiv/openapi-mcp-generator#readme",
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "^10.1.1",
|
||||
"commander": "^13.1.0",
|
||||
"openapi-types": "^12.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.29.1",
|
||||
"@typescript-eslint/parser": "^8.29.1",
|
||||
"eslint": "^9.24.0",
|
||||
"prettier": "^3.5.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"zod": "^3.24.2",
|
||||
"json-schema-to-zod": "^2.4.1"
|
||||
}
|
||||
}
|
||||
"@apidevtools/swagger-parser": "^10.1.1",
|
||||
"commander": "^13.1.0",
|
||||
"openapi-types": "^12.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.0",
|
||||
"@typescript-eslint/parser": "^8.31.0",
|
||||
"eslint": "^9.25.1",
|
||||
"prettier": "^3.5.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.10.0",
|
||||
"json-schema-to-zod": "^2.6.1",
|
||||
"zod": "^3.24.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,43 +4,43 @@
|
||||
|
||||
/**
|
||||
* Generates the content of tsconfig.json for the MCP server
|
||||
*
|
||||
*
|
||||
* @returns JSON string for tsconfig.json
|
||||
*/
|
||||
export function generateTsconfigJson(): string {
|
||||
const tsconfigData = {
|
||||
compilerOptions: {
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"target": "ES2022",
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"noEmit": false,
|
||||
"outDir": "./build",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "build", "**/*.test.ts"]
|
||||
};
|
||||
|
||||
return JSON.stringify(tsconfigData, null, 2);
|
||||
const tsconfigData = {
|
||||
compilerOptions: {
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
target: 'ES2022',
|
||||
allowJs: true,
|
||||
resolveJsonModule: true,
|
||||
moduleDetection: 'force',
|
||||
strict: true,
|
||||
noImplicitAny: true,
|
||||
strictNullChecks: true,
|
||||
module: 'Node16',
|
||||
moduleResolution: 'Node16',
|
||||
noEmit: false,
|
||||
outDir: './build',
|
||||
declaration: true,
|
||||
sourceMap: true,
|
||||
forceConsistentCasingInFileNames: true,
|
||||
},
|
||||
include: ['src/**/*'],
|
||||
exclude: ['node_modules', 'build', '**/*.test.ts'],
|
||||
};
|
||||
|
||||
return JSON.stringify(tsconfigData, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the content of .gitignore for the MCP server
|
||||
*
|
||||
*
|
||||
* @returns Content for .gitignore
|
||||
*/
|
||||
export function generateGitignore(): string {
|
||||
return `# Dependencies
|
||||
return `# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
@ -109,39 +109,36 @@ Thumbs.db
|
||||
|
||||
/**
|
||||
* Generates the content of .eslintrc.json for the MCP server
|
||||
*
|
||||
*
|
||||
* @returns JSON string for .eslintrc.json
|
||||
*/
|
||||
export function generateEslintConfig(): string {
|
||||
const eslintConfig = {
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"env": {
|
||||
"node": true,
|
||||
"es2022": true
|
||||
},
|
||||
"rules": {
|
||||
"no-console": ["error", { "allow": ["error", "warn"] }],
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||
}
|
||||
};
|
||||
|
||||
return JSON.stringify(eslintConfig, null, 2);
|
||||
const eslintConfig = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||
plugins: ['@typescript-eslint'],
|
||||
env: {
|
||||
node: true,
|
||||
es2022: true,
|
||||
},
|
||||
rules: {
|
||||
'no-console': ['error', { allow: ['error', 'warn'] }],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
};
|
||||
|
||||
return JSON.stringify(eslintConfig, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the content of jest.config.js for the MCP server
|
||||
*
|
||||
*
|
||||
* @returns Content for jest.config.js
|
||||
*/
|
||||
export function generateJestConfig(): string {
|
||||
return `export default {
|
||||
return `export default {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
@ -162,17 +159,17 @@ export function generateJestConfig(): string {
|
||||
|
||||
/**
|
||||
* Generates the content of .prettierrc for the MCP server
|
||||
*
|
||||
*
|
||||
* @returns JSON string for .prettierrc
|
||||
*/
|
||||
export function generatePrettierConfig(): string {
|
||||
const prettierConfig = {
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
};
|
||||
|
||||
return JSON.stringify(prettierConfig, null, 2);
|
||||
}
|
||||
const prettierConfig = {
|
||||
semi: true,
|
||||
trailingComma: 'es5',
|
||||
singleQuote: true,
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
};
|
||||
|
||||
return JSON.stringify(prettierConfig, null, 2);
|
||||
}
|
||||
|
||||
@ -6,12 +6,14 @@ import { getEnvVarName } from '../utils/security.js';
|
||||
|
||||
/**
|
||||
* Generates the content of .env.example file for the MCP server
|
||||
*
|
||||
*
|
||||
* @param securitySchemes Security schemes from the OpenAPI spec
|
||||
* @returns Content for .env.example file
|
||||
*/
|
||||
export function generateEnvExample(securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']): string {
|
||||
let content = `# MCP Server Environment Variables
|
||||
export function generateEnvExample(
|
||||
securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']
|
||||
): string {
|
||||
let content = `# MCP Server Environment Variables
|
||||
# Copy this file to .env and fill in the values
|
||||
|
||||
# Server configuration
|
||||
@ -20,56 +22,53 @@ LOG_LEVEL=info
|
||||
|
||||
`;
|
||||
|
||||
// Add security scheme environment variables with examples
|
||||
if (securitySchemes && Object.keys(securitySchemes).length > 0) {
|
||||
content += `# API Authentication\n`;
|
||||
|
||||
for (const [name, schemeOrRef] of Object.entries(securitySchemes)) {
|
||||
if ('$ref' in schemeOrRef) {
|
||||
content += `# ${name} - Referenced security scheme (reference not resolved)\n`;
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheme = schemeOrRef;
|
||||
|
||||
if (scheme.type === 'apiKey') {
|
||||
const varName = getEnvVarName(name, 'API_KEY');
|
||||
content += `${varName}=your_api_key_here\n`;
|
||||
}
|
||||
else if (scheme.type === 'http') {
|
||||
if (scheme.scheme?.toLowerCase() === 'bearer') {
|
||||
const varName = getEnvVarName(name, 'BEARER_TOKEN');
|
||||
content += `${varName}=your_bearer_token_here\n`;
|
||||
}
|
||||
else if (scheme.scheme?.toLowerCase() === 'basic') {
|
||||
const usernameVar = getEnvVarName(name, 'BASIC_USERNAME');
|
||||
const passwordVar = getEnvVarName(name, 'BASIC_PASSWORD');
|
||||
content += `${usernameVar}=your_username_here\n`;
|
||||
content += `${passwordVar}=your_password_here\n`;
|
||||
}
|
||||
}
|
||||
else if (scheme.type === 'oauth2') {
|
||||
content += `# OAuth2 authentication (${scheme.flows ? Object.keys(scheme.flows).join(', ') : 'unknown'} flow)\n`;
|
||||
const varName = `OAUTH_TOKEN_${name.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`;
|
||||
content += `${varName}=your_oauth_token_here\n`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
content += `# No API authentication required\n`;
|
||||
}
|
||||
// Add security scheme environment variables with examples
|
||||
if (securitySchemes && Object.keys(securitySchemes).length > 0) {
|
||||
content += `# API Authentication\n`;
|
||||
|
||||
content += `\n# Add any other environment variables your API might need\n`;
|
||||
|
||||
return content;
|
||||
for (const [name, schemeOrRef] of Object.entries(securitySchemes)) {
|
||||
if ('$ref' in schemeOrRef) {
|
||||
content += `# ${name} - Referenced security scheme (reference not resolved)\n`;
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheme = schemeOrRef;
|
||||
|
||||
if (scheme.type === 'apiKey') {
|
||||
const varName = getEnvVarName(name, 'API_KEY');
|
||||
content += `${varName}=your_api_key_here\n`;
|
||||
} else if (scheme.type === 'http') {
|
||||
if (scheme.scheme?.toLowerCase() === 'bearer') {
|
||||
const varName = getEnvVarName(name, 'BEARER_TOKEN');
|
||||
content += `${varName}=your_bearer_token_here\n`;
|
||||
} else if (scheme.scheme?.toLowerCase() === 'basic') {
|
||||
const usernameVar = getEnvVarName(name, 'BASIC_USERNAME');
|
||||
const passwordVar = getEnvVarName(name, 'BASIC_PASSWORD');
|
||||
content += `${usernameVar}=your_username_here\n`;
|
||||
content += `${passwordVar}=your_password_here\n`;
|
||||
}
|
||||
} else if (scheme.type === 'oauth2') {
|
||||
content += `# OAuth2 authentication (${scheme.flows ? Object.keys(scheme.flows).join(', ') : 'unknown'} flow)\n`;
|
||||
const varName = `OAUTH_TOKEN_${name.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`;
|
||||
content += `${varName}=your_oauth_token_here\n`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
content += `# No API authentication required\n`;
|
||||
}
|
||||
|
||||
content += `\n# Add any other environment variables your API might need\n`;
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates dotenv configuration code for the MCP server
|
||||
*
|
||||
*
|
||||
* @returns Code for loading environment variables
|
||||
*/
|
||||
export function generateDotenvConfig(): string {
|
||||
return `
|
||||
return `
|
||||
/**
|
||||
* Load environment variables from .env file
|
||||
*/
|
||||
@ -93,4 +92,4 @@ export const config = {
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
};
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,4 +6,5 @@ export * from './package-json.js';
|
||||
export * from './config-files.js';
|
||||
export * from './env-file.js';
|
||||
export * from './oauth-docs.js';
|
||||
export * from './web-server.js';
|
||||
export * from './web-server.js';
|
||||
export * from './streamable-http.js';
|
||||
|
||||
@ -5,34 +5,36 @@ import { OpenAPIV3 } from 'openapi-types';
|
||||
|
||||
/**
|
||||
* Generates documentation about OAuth2 configuration
|
||||
*
|
||||
*
|
||||
* @param securitySchemes Security schemes from OpenAPI spec
|
||||
* @returns Markdown documentation about OAuth2 configuration
|
||||
*/
|
||||
export function generateOAuth2Docs(securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']): string {
|
||||
if (!securitySchemes) {
|
||||
return "# OAuth2 Configuration\n\nNo OAuth2 security schemes defined in this API.";
|
||||
}
|
||||
export function generateOAuth2Docs(
|
||||
securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']
|
||||
): string {
|
||||
if (!securitySchemes) {
|
||||
return '# OAuth2 Configuration\n\nNo OAuth2 security schemes defined in this API.';
|
||||
}
|
||||
|
||||
let oauth2Schemes: {name: string, scheme: OpenAPIV3.OAuth2SecurityScheme}[] = [];
|
||||
|
||||
// Find OAuth2 schemes
|
||||
for (const [name, schemeOrRef] of Object.entries(securitySchemes)) {
|
||||
if ('$ref' in schemeOrRef) continue;
|
||||
|
||||
if (schemeOrRef.type === 'oauth2') {
|
||||
oauth2Schemes.push({
|
||||
name,
|
||||
scheme: schemeOrRef
|
||||
});
|
||||
}
|
||||
let oauth2Schemes: { name: string; scheme: OpenAPIV3.OAuth2SecurityScheme }[] = [];
|
||||
|
||||
// Find OAuth2 schemes
|
||||
for (const [name, schemeOrRef] of Object.entries(securitySchemes)) {
|
||||
if ('$ref' in schemeOrRef) continue;
|
||||
|
||||
if (schemeOrRef.type === 'oauth2') {
|
||||
oauth2Schemes.push({
|
||||
name,
|
||||
scheme: schemeOrRef,
|
||||
});
|
||||
}
|
||||
|
||||
if (oauth2Schemes.length === 0) {
|
||||
return "# OAuth2 Configuration\n\nNo OAuth2 security schemes defined in this API.";
|
||||
}
|
||||
|
||||
let content = `# OAuth2 Configuration
|
||||
}
|
||||
|
||||
if (oauth2Schemes.length === 0) {
|
||||
return '# OAuth2 Configuration\n\nNo OAuth2 security schemes defined in this API.';
|
||||
}
|
||||
|
||||
let content = `# OAuth2 Configuration
|
||||
|
||||
This API uses OAuth2 for authentication. The MCP server can handle OAuth2 authentication in the following ways:
|
||||
|
||||
@ -43,59 +45,65 @@ This API uses OAuth2 for authentication. The MCP server can handle OAuth2 authen
|
||||
|
||||
`;
|
||||
|
||||
// Document each OAuth2 scheme
|
||||
for (const {name, scheme} of oauth2Schemes) {
|
||||
content += `### ${name}\n\n`;
|
||||
|
||||
if (scheme.description) {
|
||||
content += `${scheme.description}\n\n`;
|
||||
}
|
||||
|
||||
content += "**Configuration Variables:**\n\n";
|
||||
|
||||
const envVarPrefix = name.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase();
|
||||
|
||||
content += `- \`OAUTH_CLIENT_ID_${envVarPrefix}\`: Your OAuth client ID\n`;
|
||||
content += `- \`OAUTH_CLIENT_SECRET_${envVarPrefix}\`: Your OAuth client secret\n`;
|
||||
|
||||
if (scheme.flows?.clientCredentials) {
|
||||
content += `- \`OAUTH_SCOPES_${envVarPrefix}\`: Space-separated list of scopes to request (optional)\n`;
|
||||
content += `- \`OAUTH_TOKEN_${envVarPrefix}\`: Pre-acquired OAuth token (optional if using client credentials)\n\n`;
|
||||
|
||||
content += "**Client Credentials Flow:**\n\n";
|
||||
content += `- Token URL: \`${scheme.flows.clientCredentials.tokenUrl}\`\n`;
|
||||
|
||||
if (scheme.flows.clientCredentials.scopes && Object.keys(scheme.flows.clientCredentials.scopes).length > 0) {
|
||||
content += "\n**Available Scopes:**\n\n";
|
||||
|
||||
for (const [scope, description] of Object.entries(scheme.flows.clientCredentials.scopes)) {
|
||||
content += `- \`${scope}\`: ${description}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
content += "\n";
|
||||
}
|
||||
|
||||
if (scheme.flows?.authorizationCode) {
|
||||
content += `- \`OAUTH_TOKEN_${envVarPrefix}\`: Pre-acquired OAuth token (required for authorization code flow)\n\n`;
|
||||
|
||||
content += "**Authorization Code Flow:**\n\n";
|
||||
content += `- Authorization URL: \`${scheme.flows.authorizationCode.authorizationUrl}\`\n`;
|
||||
content += `- Token URL: \`${scheme.flows.authorizationCode.tokenUrl}\`\n`;
|
||||
|
||||
if (scheme.flows.authorizationCode.scopes && Object.keys(scheme.flows.authorizationCode.scopes).length > 0) {
|
||||
content += "\n**Available Scopes:**\n\n";
|
||||
|
||||
for (const [scope, description] of Object.entries(scheme.flows.authorizationCode.scopes)) {
|
||||
content += `- \`${scope}\`: ${description}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
content += "\n";
|
||||
}
|
||||
// Document each OAuth2 scheme
|
||||
for (const { name, scheme } of oauth2Schemes) {
|
||||
content += `### ${name}\n\n`;
|
||||
|
||||
if (scheme.description) {
|
||||
content += `${scheme.description}\n\n`;
|
||||
}
|
||||
|
||||
content += `## Token Caching
|
||||
|
||||
content += '**Configuration Variables:**\n\n';
|
||||
|
||||
const envVarPrefix = name.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase();
|
||||
|
||||
content += `- \`OAUTH_CLIENT_ID_${envVarPrefix}\`: Your OAuth client ID\n`;
|
||||
content += `- \`OAUTH_CLIENT_SECRET_${envVarPrefix}\`: Your OAuth client secret\n`;
|
||||
|
||||
if (scheme.flows?.clientCredentials) {
|
||||
content += `- \`OAUTH_SCOPES_${envVarPrefix}\`: Space-separated list of scopes to request (optional)\n`;
|
||||
content += `- \`OAUTH_TOKEN_${envVarPrefix}\`: Pre-acquired OAuth token (optional if using client credentials)\n\n`;
|
||||
|
||||
content += '**Client Credentials Flow:**\n\n';
|
||||
content += `- Token URL: \`${scheme.flows.clientCredentials.tokenUrl}\`\n`;
|
||||
|
||||
if (
|
||||
scheme.flows.clientCredentials.scopes &&
|
||||
Object.keys(scheme.flows.clientCredentials.scopes).length > 0
|
||||
) {
|
||||
content += '\n**Available Scopes:**\n\n';
|
||||
|
||||
for (const [scope, description] of Object.entries(scheme.flows.clientCredentials.scopes)) {
|
||||
content += `- \`${scope}\`: ${description}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
content += '\n';
|
||||
}
|
||||
|
||||
if (scheme.flows?.authorizationCode) {
|
||||
content += `- \`OAUTH_TOKEN_${envVarPrefix}\`: Pre-acquired OAuth token (required for authorization code flow)\n\n`;
|
||||
|
||||
content += '**Authorization Code Flow:**\n\n';
|
||||
content += `- Authorization URL: \`${scheme.flows.authorizationCode.authorizationUrl}\`\n`;
|
||||
content += `- Token URL: \`${scheme.flows.authorizationCode.tokenUrl}\`\n`;
|
||||
|
||||
if (
|
||||
scheme.flows.authorizationCode.scopes &&
|
||||
Object.keys(scheme.flows.authorizationCode.scopes).length > 0
|
||||
) {
|
||||
content += '\n**Available Scopes:**\n\n';
|
||||
|
||||
for (const [scope, description] of Object.entries(scheme.flows.authorizationCode.scopes)) {
|
||||
content += `- \`${scope}\`: ${description}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
content += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
content += `## 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.
|
||||
|
||||
@ -105,5 +113,5 @@ When making API requests, the server will:
|
||||
3. Request a new token if no valid cached token exists
|
||||
`;
|
||||
|
||||
return content;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
@ -1,65 +1,70 @@
|
||||
/**
|
||||
* Generates the content of package.json for the MCP server
|
||||
*
|
||||
*
|
||||
* @param serverName Server name
|
||||
* @param serverVersion Server version
|
||||
* @param includeWebDeps Whether to include web server dependencies
|
||||
* @param transportType Type of transport to use (stdio, web, or streamable-http)
|
||||
* @returns JSON string for package.json
|
||||
*/
|
||||
export function generatePackageJson(
|
||||
serverName: string,
|
||||
serverVersion: string,
|
||||
includeWebDeps: boolean = false
|
||||
serverName: string,
|
||||
serverVersion: string,
|
||||
transportType: string = 'stdio'
|
||||
): string {
|
||||
const packageData: any = {
|
||||
name: serverName,
|
||||
version: serverVersion,
|
||||
description: `MCP Server generated from OpenAPI spec for ${serverName}`,
|
||||
private: true,
|
||||
type: "module",
|
||||
main: "build/index.js",
|
||||
files: [ "build", "src" ],
|
||||
scripts: {
|
||||
"start": "node build/index.js",
|
||||
"build": "tsc && chmod 755 build/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prestart": "npm run build"
|
||||
},
|
||||
engines: {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
dependencies: {
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"axios": "^1.8.4",
|
||||
"dotenv": "^16.4.5",
|
||||
"zod": "^3.24.2",
|
||||
"json-schema-to-zod": "^2.4.1"
|
||||
},
|
||||
devDependencies: {
|
||||
"@types/node": "^18.19.0",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
const includeWebDeps = transportType === 'web' || transportType === 'streamable-http';
|
||||
|
||||
const packageData: any = {
|
||||
name: serverName,
|
||||
version: serverVersion,
|
||||
description: `MCP Server generated from OpenAPI spec for ${serverName}`,
|
||||
private: true,
|
||||
type: 'module',
|
||||
main: 'build/index.js',
|
||||
files: ['build', 'src'],
|
||||
scripts: {
|
||||
start: 'node build/index.js',
|
||||
build: 'tsc && chmod 755 build/index.js',
|
||||
typecheck: 'tsc --noEmit',
|
||||
prestart: 'npm run build',
|
||||
},
|
||||
engines: {
|
||||
node: '>=20.0.0',
|
||||
},
|
||||
dependencies: {
|
||||
'@modelcontextprotocol/sdk': '^1.10.0',
|
||||
axios: '^1.9.0',
|
||||
dotenv: '^16.4.5',
|
||||
zod: '^3.24.3',
|
||||
'json-schema-to-zod': '^2.6.1',
|
||||
},
|
||||
devDependencies: {
|
||||
'@types/node': '^22.15.2',
|
||||
typescript: '^5.8.3',
|
||||
},
|
||||
};
|
||||
|
||||
// Add Hono dependencies for web-based transports
|
||||
if (includeWebDeps) {
|
||||
packageData.dependencies = {
|
||||
...packageData.dependencies,
|
||||
hono: '^4.7.7',
|
||||
'@hono/node-server': '^1.14.1',
|
||||
uuid: '^11.1.0',
|
||||
};
|
||||
|
||||
// Add web server dependencies if needed
|
||||
if (includeWebDeps) {
|
||||
packageData.dependencies = {
|
||||
...packageData.dependencies,
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"uuid": "^11.1.0"
|
||||
};
|
||||
|
||||
packageData.devDependencies = {
|
||||
...packageData.devDependencies,
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/uuid": "^10.0.0"
|
||||
};
|
||||
|
||||
// Add a script to start in web mode
|
||||
packageData.scripts["start:web"] = "node build/index.js --transport=web";
|
||||
|
||||
packageData.devDependencies = {
|
||||
...packageData.devDependencies,
|
||||
'@types/uuid': '^10.0.0',
|
||||
};
|
||||
|
||||
// Add appropriate start script based on transport type
|
||||
if (transportType === 'web') {
|
||||
packageData.scripts['start:web'] = 'node build/index.js --transport=web';
|
||||
} else if (transportType === 'streamable-http') {
|
||||
packageData.scripts['start:http'] = 'node build/index.js --transport=streamable-http';
|
||||
packageData.dependencies['fetch-to-node'] = '^2.1.0';
|
||||
}
|
||||
|
||||
return JSON.stringify(packageData, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(packageData, null, 2);
|
||||
}
|
||||
|
||||
@ -2,16 +2,16 @@ import { OpenAPIV3 } from 'openapi-types';
|
||||
import { CliOptions } from '../types/index.js';
|
||||
import { extractToolsFromApi } from '../parser/extract-tools.js';
|
||||
import { determineBaseUrl } from '../utils/index.js';
|
||||
import {
|
||||
generateToolDefinitionMap,
|
||||
generateCallToolHandler,
|
||||
generateListToolsHandler
|
||||
import {
|
||||
generateToolDefinitionMap,
|
||||
generateCallToolHandler,
|
||||
generateListToolsHandler,
|
||||
} from '../utils/code-gen.js';
|
||||
import { generateExecuteApiToolFunction } from '../utils/security.js';
|
||||
|
||||
/**
|
||||
* Generates the TypeScript code for the MCP server
|
||||
*
|
||||
*
|
||||
* @param api OpenAPI document
|
||||
* @param options CLI options
|
||||
* @param serverName Server name
|
||||
@ -19,43 +19,57 @@ import { generateExecuteApiToolFunction } from '../utils/security.js';
|
||||
* @returns Generated TypeScript code
|
||||
*/
|
||||
export function generateMcpServerCode(
|
||||
api: OpenAPIV3.Document,
|
||||
options: CliOptions,
|
||||
serverName: string,
|
||||
serverVersion: string
|
||||
api: OpenAPIV3.Document,
|
||||
options: CliOptions,
|
||||
serverName: string,
|
||||
serverVersion: string
|
||||
): string {
|
||||
// Extract tools from API
|
||||
const tools = extractToolsFromApi(api);
|
||||
|
||||
// Determine base URL
|
||||
const determinedBaseUrl = determineBaseUrl(api, options.baseUrl);
|
||||
|
||||
// Generate code for tool definition map
|
||||
const toolDefinitionMapCode = generateToolDefinitionMap(tools, api.components?.securitySchemes);
|
||||
|
||||
// Generate code for API tool execution
|
||||
const executeApiToolFunctionCode = generateExecuteApiToolFunction(api.components?.securitySchemes);
|
||||
|
||||
// Generate code for request handlers
|
||||
const callToolHandlerCode = generateCallToolHandler();
|
||||
const listToolsHandlerCode = generateListToolsHandler();
|
||||
// Extract tools from API
|
||||
const tools = extractToolsFromApi(api);
|
||||
|
||||
// Determine if we should include web server code
|
||||
const includeWebServer = options.transport === 'web';
|
||||
const webServerImport = includeWebServer
|
||||
? `\nimport { setupWebServer } from "./web-server.js";`
|
||||
: '';
|
||||
|
||||
// Define transport based on options
|
||||
const transportCode = includeWebServer
|
||||
? `// Set up Web Server transport
|
||||
// Determine base URL
|
||||
const determinedBaseUrl = determineBaseUrl(api, options.baseUrl);
|
||||
|
||||
// Generate code for tool definition map
|
||||
const toolDefinitionMapCode = generateToolDefinitionMap(tools, api.components?.securitySchemes);
|
||||
|
||||
// Generate code for API tool execution
|
||||
const executeApiToolFunctionCode = generateExecuteApiToolFunction(
|
||||
api.components?.securitySchemes
|
||||
);
|
||||
|
||||
// Generate code for request handlers
|
||||
const callToolHandlerCode = generateCallToolHandler();
|
||||
const listToolsHandlerCode = generateListToolsHandler();
|
||||
|
||||
// Determine which transport to include
|
||||
let transportImport = '';
|
||||
let transportCode = '';
|
||||
|
||||
switch (options.transport) {
|
||||
case 'web':
|
||||
transportImport = `\nimport { setupWebServer } from "./web-server.js";`;
|
||||
transportCode = `// Set up Web Server transport
|
||||
try {
|
||||
await setupWebServer(server, ${options.port || 3000});
|
||||
} catch (error) {
|
||||
console.error("Error setting up web server:", error);
|
||||
process.exit(1);
|
||||
}`
|
||||
: `// Set up stdio transport
|
||||
}`;
|
||||
break;
|
||||
case 'streamable-http':
|
||||
transportImport = `\nimport { setupStreamableHttpServer } from "./streamable-http.js";`;
|
||||
transportCode = `// Set up StreamableHTTP transport
|
||||
try {
|
||||
await setupStreamableHttpServer(server, ${options.port || 3000});
|
||||
} catch (error) {
|
||||
console.error("Error setting up StreamableHTTP server:", error);
|
||||
process.exit(1);
|
||||
}`;
|
||||
break;
|
||||
default: // stdio
|
||||
transportImport = '';
|
||||
transportCode = `// Set up stdio transport
|
||||
try {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
@ -64,9 +78,11 @@ export function generateMcpServerCode(
|
||||
console.error("Error during server startup:", error);
|
||||
process.exit(1);
|
||||
}`;
|
||||
break;
|
||||
}
|
||||
|
||||
// Generate the full server code
|
||||
return `#!/usr/bin/env node
|
||||
// Generate the full server code
|
||||
return `#!/usr/bin/env node
|
||||
/**
|
||||
* MCP Server generated from OpenAPI spec for ${serverName} v${serverVersion}
|
||||
* Generated on: ${new Date().toISOString()}
|
||||
@ -84,7 +100,7 @@ import {
|
||||
type Tool,
|
||||
type CallToolResult,
|
||||
type CallToolRequest
|
||||
} from "@modelcontextprotocol/sdk/types.js";${webServerImport}
|
||||
} from "@modelcontextprotocol/sdk/types.js";${transportImport}
|
||||
|
||||
import { z, ZodError } from 'zod';
|
||||
import { jsonSchemaToZod } from 'json-schema-to-zod';
|
||||
@ -224,4 +240,4 @@ function getZodSchemaFromJsonSchema(jsonSchema: any, toolName: string): z.ZodTyp
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
674
src/generator/streamable-http.ts
Normal file
674
src/generator/streamable-http.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
431
src/index.ts
431
src/index.ts
@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OpenAPI to MCP Generator
|
||||
*
|
||||
*
|
||||
* This tool generates a Model Context Protocol (MCP) server from an OpenAPI specification.
|
||||
* It creates a Node.js project that implements MCP over stdio to proxy API requests.
|
||||
*/
|
||||
@ -12,38 +12,64 @@ import SwaggerParser from '@apidevtools/swagger-parser';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
|
||||
// Import generators
|
||||
import {
|
||||
generateMcpServerCode,
|
||||
generatePackageJson,
|
||||
generateTsconfigJson,
|
||||
generateGitignore,
|
||||
generateEslintConfig,
|
||||
generateJestConfig,
|
||||
generatePrettierConfig,
|
||||
generateEnvExample,
|
||||
generateOAuth2Docs,
|
||||
generateWebServerCode,
|
||||
generateTestClientHtml
|
||||
import {
|
||||
generateMcpServerCode,
|
||||
generatePackageJson,
|
||||
generateTsconfigJson,
|
||||
generateGitignore,
|
||||
generateEslintConfig,
|
||||
generateJestConfig,
|
||||
generatePrettierConfig,
|
||||
generateEnvExample,
|
||||
generateOAuth2Docs,
|
||||
generateWebServerCode,
|
||||
generateTestClientHtml,
|
||||
generateStreamableHttpCode,
|
||||
generateStreamableHttpClientHtml,
|
||||
} from './generator/index.js';
|
||||
|
||||
// Import types
|
||||
import { CliOptions } from './types/index.js';
|
||||
import { CliOptions, TransportType } from './types/index.js';
|
||||
|
||||
// Configure CLI
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('openapi-mcp-generator')
|
||||
.description('Generates a buildable MCP server project (TypeScript) from an OpenAPI specification')
|
||||
.requiredOption('-i, --input <file_or_url>', 'Path or URL to the OpenAPI specification file (JSON or YAML)')
|
||||
.requiredOption('-o, --output <directory>', 'Path to the directory where the MCP server project will be created (e.g., ./petstore-mcp)')
|
||||
.option('-n, --server-name <n>', 'Name for the generated MCP server package (default: derived from OpenAPI info title)')
|
||||
.option('-v, --server-version <version>', 'Version for the generated MCP server (default: derived from OpenAPI info version or 0.1.0)')
|
||||
.option('-b, --base-url <url>', 'Base URL for the target API. Required if not specified in OpenAPI `servers` or if multiple servers exist.')
|
||||
.option('-t, --transport <type>', 'Server transport type: "stdio" or "web" (default: "stdio")')
|
||||
.option('-p, --port <number>', 'Port for web server (used with --transport=web, default: 3000)', (val) => parseInt(val, 10))
|
||||
.option('--force', 'Overwrite existing files without prompting')
|
||||
.version('2.0.0'); // Match package.json version
|
||||
.name('openapi-mcp-generator')
|
||||
.description(
|
||||
'Generates a buildable MCP server project (TypeScript) from an OpenAPI specification'
|
||||
)
|
||||
.requiredOption(
|
||||
'-i, --input <file_or_url>',
|
||||
'Path or URL to the OpenAPI specification file (JSON or YAML)'
|
||||
)
|
||||
.requiredOption(
|
||||
'-o, --output <directory>',
|
||||
'Path to the directory where the MCP server project will be created (e.g., ./petstore-mcp)'
|
||||
)
|
||||
.option(
|
||||
'-n, --server-name <n>',
|
||||
'Name for the generated MCP server package (default: derived from OpenAPI info title)'
|
||||
)
|
||||
.option(
|
||||
'-v, --server-version <version>',
|
||||
'Version for the generated MCP server (default: derived from OpenAPI info version or 0.1.0)'
|
||||
)
|
||||
.option(
|
||||
'-b, --base-url <url>',
|
||||
'Base URL for the target API. Required if not specified in OpenAPI `servers` or if multiple servers exist.'
|
||||
)
|
||||
.option(
|
||||
'-t, --transport <type>',
|
||||
'Server transport type: "stdio", "web", or "streamable-http" (default: "stdio")'
|
||||
)
|
||||
.option(
|
||||
'-p, --port <number>',
|
||||
'Port for web or streamable-http transport (default: 3000)',
|
||||
(val) => parseInt(val, 10)
|
||||
)
|
||||
.option('--force', 'Overwrite existing files without prompting')
|
||||
.version('2.0.0'); // Match package.json version
|
||||
|
||||
// Parse arguments explicitly from process.argv
|
||||
program.parse(process.argv);
|
||||
@ -55,172 +81,201 @@ const options = program.opts<CliOptions & { force?: boolean }>();
|
||||
* Main function to run the generator
|
||||
*/
|
||||
async function main() {
|
||||
// Use the parsed options directly
|
||||
const outputDir = options.output;
|
||||
const inputSpec = options.input;
|
||||
// Use the parsed options directly
|
||||
const outputDir = options.output;
|
||||
const inputSpec = options.input;
|
||||
|
||||
const srcDir = path.join(outputDir, 'src');
|
||||
const serverFilePath = path.join(srcDir, 'index.ts');
|
||||
const packageJsonPath = path.join(outputDir, 'package.json');
|
||||
const tsconfigPath = path.join(outputDir, 'tsconfig.json');
|
||||
const gitignorePath = path.join(outputDir, '.gitignore');
|
||||
const eslintPath = path.join(outputDir, '.eslintrc.json');
|
||||
const prettierPath = path.join(outputDir, '.prettierrc');
|
||||
const jestConfigPath = path.join(outputDir, 'jest.config.js');
|
||||
const envExamplePath = path.join(outputDir, '.env.example');
|
||||
const docsDir = path.join(outputDir, 'docs');
|
||||
const oauth2DocsPath = path.join(docsDir, 'oauth2-configuration.md');
|
||||
|
||||
// Web server files (if requested)
|
||||
const webServerPath = path.join(srcDir, 'web-server.ts');
|
||||
const publicDir = path.join(outputDir, 'public');
|
||||
const indexHtmlPath = path.join(publicDir, 'index.html');
|
||||
const srcDir = path.join(outputDir, 'src');
|
||||
const serverFilePath = path.join(srcDir, 'index.ts');
|
||||
const packageJsonPath = path.join(outputDir, 'package.json');
|
||||
const tsconfigPath = path.join(outputDir, 'tsconfig.json');
|
||||
const gitignorePath = path.join(outputDir, '.gitignore');
|
||||
const eslintPath = path.join(outputDir, '.eslintrc.json');
|
||||
const prettierPath = path.join(outputDir, '.prettierrc');
|
||||
const jestConfigPath = path.join(outputDir, 'jest.config.js');
|
||||
const envExamplePath = path.join(outputDir, '.env.example');
|
||||
const docsDir = path.join(outputDir, 'docs');
|
||||
const oauth2DocsPath = path.join(docsDir, 'oauth2-configuration.md');
|
||||
|
||||
try {
|
||||
// Check if output directory exists and is not empty
|
||||
if (!options.force) {
|
||||
try {
|
||||
const dirExists = await fs.stat(outputDir).catch(() => false);
|
||||
if (dirExists) {
|
||||
const files = await fs.readdir(outputDir);
|
||||
if (files.length > 0) {
|
||||
console.error(`Error: Output directory ${outputDir} already exists and is not empty.`);
|
||||
console.error('Use --force to overwrite existing files.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Directory doesn't exist, which is fine
|
||||
}
|
||||
// Web server files (if requested)
|
||||
const webServerPath = path.join(srcDir, 'web-server.ts');
|
||||
const publicDir = path.join(outputDir, 'public');
|
||||
const indexHtmlPath = path.join(publicDir, 'index.html');
|
||||
|
||||
// StreamableHTTP files (if requested)
|
||||
const streamableHttpPath = path.join(srcDir, 'streamable-http.ts');
|
||||
|
||||
try {
|
||||
// Check if output directory exists and is not empty
|
||||
if (!options.force) {
|
||||
try {
|
||||
const dirExists = await fs.stat(outputDir).catch(() => false);
|
||||
if (dirExists) {
|
||||
const files = await fs.readdir(outputDir);
|
||||
if (files.length > 0) {
|
||||
console.error(`Error: Output directory ${outputDir} already exists and is not empty.`);
|
||||
console.error('Use --force to overwrite existing files.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse OpenAPI spec
|
||||
console.error(`Parsing OpenAPI spec: ${inputSpec}`);
|
||||
const api = await SwaggerParser.dereference(inputSpec) as OpenAPIV3.Document;
|
||||
console.error('OpenAPI spec parsed successfully.');
|
||||
|
||||
// Determine server name and version
|
||||
const serverNameRaw = options.serverName || (api.info?.title || 'my-mcp-server');
|
||||
const serverName = serverNameRaw.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
|
||||
const serverVersion = options.serverVersion || (api.info?.version || '0.1.0');
|
||||
|
||||
console.error('Generating server code...');
|
||||
const serverTsContent = generateMcpServerCode(api, options, serverName, serverVersion);
|
||||
|
||||
console.error('Generating package.json...');
|
||||
const packageJsonContent = generatePackageJson(serverName, serverVersion, options.transport === 'web');
|
||||
|
||||
console.error('Generating tsconfig.json...');
|
||||
const tsconfigJsonContent = generateTsconfigJson();
|
||||
|
||||
console.error('Generating .gitignore...');
|
||||
const gitignoreContent = generateGitignore();
|
||||
|
||||
console.error('Generating ESLint config...');
|
||||
const eslintConfigContent = generateEslintConfig();
|
||||
|
||||
console.error('Generating Prettier config...');
|
||||
const prettierConfigContent = generatePrettierConfig();
|
||||
|
||||
console.error('Generating Jest config...');
|
||||
const jestConfigContent = generateJestConfig();
|
||||
|
||||
console.error('Generating .env.example file...');
|
||||
const envExampleContent = generateEnvExample(api.components?.securitySchemes);
|
||||
|
||||
console.error('Generating OAuth2 documentation...');
|
||||
const oauth2DocsContent = generateOAuth2Docs(api.components?.securitySchemes);
|
||||
|
||||
console.error(`Creating project directory structure at: ${outputDir}`);
|
||||
await fs.mkdir(srcDir, { recursive: true });
|
||||
|
||||
await fs.writeFile(serverFilePath, serverTsContent);
|
||||
console.error(` -> Created ${serverFilePath}`);
|
||||
|
||||
await fs.writeFile(packageJsonPath, packageJsonContent);
|
||||
console.error(` -> Created ${packageJsonPath}`);
|
||||
|
||||
await fs.writeFile(tsconfigPath, tsconfigJsonContent);
|
||||
console.error(` -> Created ${tsconfigPath}`);
|
||||
|
||||
await fs.writeFile(gitignorePath, gitignoreContent);
|
||||
console.error(` -> Created ${gitignorePath}`);
|
||||
|
||||
await fs.writeFile(eslintPath, eslintConfigContent);
|
||||
console.error(` -> Created ${eslintPath}`);
|
||||
|
||||
await fs.writeFile(prettierPath, prettierConfigContent);
|
||||
console.error(` -> Created ${prettierPath}`);
|
||||
|
||||
await fs.writeFile(jestConfigPath, jestConfigContent);
|
||||
console.error(` -> Created ${jestConfigPath}`);
|
||||
|
||||
await fs.writeFile(envExamplePath, envExampleContent);
|
||||
console.error(` -> Created ${envExamplePath}`);
|
||||
|
||||
// Only write OAuth2 docs if there are OAuth2 security schemes
|
||||
if (oauth2DocsContent.includes("No OAuth2 security schemes defined")) {
|
||||
console.error(` -> No OAuth2 security schemes found, skipping documentation`);
|
||||
} else {
|
||||
await fs.mkdir(docsDir, { recursive: true });
|
||||
await fs.writeFile(oauth2DocsPath, oauth2DocsContent);
|
||||
console.error(` -> Created ${oauth2DocsPath}`);
|
||||
}
|
||||
|
||||
// Generate web server files if web transport is requested
|
||||
if (options.transport === 'web') {
|
||||
console.error('Generating web server files...');
|
||||
|
||||
// Generate web server code
|
||||
const webServerCode = generateWebServerCode(options.port || 3000);
|
||||
await fs.writeFile(webServerPath, webServerCode);
|
||||
console.error(` -> Created ${webServerPath}`);
|
||||
|
||||
// Create public directory and index.html
|
||||
await fs.mkdir(publicDir, { recursive: true });
|
||||
|
||||
// Generate test client
|
||||
const indexHtmlContent = generateTestClientHtml(serverName);
|
||||
await fs.writeFile(indexHtmlPath, indexHtmlContent);
|
||||
console.error(` -> Created ${indexHtmlPath}`);
|
||||
}
|
||||
|
||||
console.error("\n---");
|
||||
console.error(`MCP server project '${serverName}' successfully generated at: ${outputDir}`);
|
||||
console.error("\nNext steps:");
|
||||
console.error(`1. Navigate to the directory: cd ${outputDir}`);
|
||||
console.error(`2. Install dependencies: npm install`);
|
||||
|
||||
if (options.transport === 'web') {
|
||||
console.error(`3. Build the TypeScript code: npm run build`);
|
||||
console.error(`4. Run the server in web mode: npm run start:web`);
|
||||
console.error(` (This will start a web server on port ${options.port || 3000})`);
|
||||
console.error(` Access the test client at: http://localhost:${options.port || 3000}`);
|
||||
} else {
|
||||
console.error(`3. Build the TypeScript code: npm run build`);
|
||||
console.error(`4. Run the server: npm start`);
|
||||
console.error(` (This runs the built JavaScript code in build/index.js)`);
|
||||
}
|
||||
console.error("---");
|
||||
|
||||
} catch (error) {
|
||||
console.error('\nError generating MCP server project:', error);
|
||||
|
||||
// Only attempt cleanup if the directory exists and force option was used
|
||||
if (options.force) {
|
||||
try {
|
||||
await fs.rm(outputDir, { recursive: true, force: true });
|
||||
console.error(`Cleaned up partially created directory: ${outputDir}`);
|
||||
} catch (cleanupError) {
|
||||
console.error(`Failed to cleanup directory ${outputDir}:`, cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
} catch (err) {
|
||||
// Directory doesn't exist, which is fine
|
||||
}
|
||||
}
|
||||
|
||||
// Parse OpenAPI spec
|
||||
console.error(`Parsing OpenAPI spec: ${inputSpec}`);
|
||||
const api = (await SwaggerParser.dereference(inputSpec)) as OpenAPIV3.Document;
|
||||
console.error('OpenAPI spec parsed successfully.');
|
||||
|
||||
// Determine server name and version
|
||||
const serverNameRaw = options.serverName || api.info?.title || 'my-mcp-server';
|
||||
const serverName = serverNameRaw.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
|
||||
const serverVersion = options.serverVersion || api.info?.version || '0.1.0';
|
||||
|
||||
console.error('Generating server code...');
|
||||
const serverTsContent = generateMcpServerCode(api, options, serverName, serverVersion);
|
||||
|
||||
console.error('Generating package.json...');
|
||||
const packageJsonContent = generatePackageJson(
|
||||
serverName,
|
||||
serverVersion,
|
||||
options.transport as TransportType
|
||||
);
|
||||
|
||||
console.error('Generating tsconfig.json...');
|
||||
const tsconfigJsonContent = generateTsconfigJson();
|
||||
|
||||
console.error('Generating .gitignore...');
|
||||
const gitignoreContent = generateGitignore();
|
||||
|
||||
console.error('Generating ESLint config...');
|
||||
const eslintConfigContent = generateEslintConfig();
|
||||
|
||||
console.error('Generating Prettier config...');
|
||||
const prettierConfigContent = generatePrettierConfig();
|
||||
|
||||
console.error('Generating Jest config...');
|
||||
const jestConfigContent = generateJestConfig();
|
||||
|
||||
console.error('Generating .env.example file...');
|
||||
const envExampleContent = generateEnvExample(api.components?.securitySchemes);
|
||||
|
||||
console.error('Generating OAuth2 documentation...');
|
||||
const oauth2DocsContent = generateOAuth2Docs(api.components?.securitySchemes);
|
||||
|
||||
console.error(`Creating project directory structure at: ${outputDir}`);
|
||||
await fs.mkdir(srcDir, { recursive: true });
|
||||
|
||||
await fs.writeFile(serverFilePath, serverTsContent);
|
||||
console.error(` -> Created ${serverFilePath}`);
|
||||
|
||||
await fs.writeFile(packageJsonPath, packageJsonContent);
|
||||
console.error(` -> Created ${packageJsonPath}`);
|
||||
|
||||
await fs.writeFile(tsconfigPath, tsconfigJsonContent);
|
||||
console.error(` -> Created ${tsconfigPath}`);
|
||||
|
||||
await fs.writeFile(gitignorePath, gitignoreContent);
|
||||
console.error(` -> Created ${gitignorePath}`);
|
||||
|
||||
await fs.writeFile(eslintPath, eslintConfigContent);
|
||||
console.error(` -> Created ${eslintPath}`);
|
||||
|
||||
await fs.writeFile(prettierPath, prettierConfigContent);
|
||||
console.error(` -> Created ${prettierPath}`);
|
||||
|
||||
await fs.writeFile(jestConfigPath, jestConfigContent);
|
||||
console.error(` -> Created ${jestConfigPath}`);
|
||||
|
||||
await fs.writeFile(envExamplePath, envExampleContent);
|
||||
console.error(` -> Created ${envExamplePath}`);
|
||||
|
||||
// Only write OAuth2 docs if there are OAuth2 security schemes
|
||||
if (oauth2DocsContent.includes('No OAuth2 security schemes defined')) {
|
||||
console.error(` -> No OAuth2 security schemes found, skipping documentation`);
|
||||
} else {
|
||||
await fs.mkdir(docsDir, { recursive: true });
|
||||
await fs.writeFile(oauth2DocsPath, oauth2DocsContent);
|
||||
console.error(` -> Created ${oauth2DocsPath}`);
|
||||
}
|
||||
|
||||
// Generate web server files if web transport is requested
|
||||
if (options.transport === 'web') {
|
||||
console.error('Generating web server files...');
|
||||
|
||||
// Generate web server code
|
||||
const webServerCode = generateWebServerCode(options.port || 3000);
|
||||
await fs.writeFile(webServerPath, webServerCode);
|
||||
console.error(` -> Created ${webServerPath}`);
|
||||
|
||||
// Create public directory and index.html
|
||||
await fs.mkdir(publicDir, { recursive: true });
|
||||
|
||||
// Generate test client
|
||||
const indexHtmlContent = generateTestClientHtml(serverName);
|
||||
await fs.writeFile(indexHtmlPath, indexHtmlContent);
|
||||
console.error(` -> Created ${indexHtmlPath}`);
|
||||
}
|
||||
|
||||
// Generate streamable HTTP files if streamable-http transport is requested
|
||||
if (options.transport === 'streamable-http') {
|
||||
console.error('Generating StreamableHTTP server files...');
|
||||
|
||||
// Generate StreamableHTTP server code
|
||||
const streamableHttpCode = generateStreamableHttpCode(options.port || 3000);
|
||||
await fs.writeFile(streamableHttpPath, streamableHttpCode);
|
||||
console.error(` -> Created ${streamableHttpPath}`);
|
||||
|
||||
// Create public directory and index.html
|
||||
await fs.mkdir(publicDir, { recursive: true });
|
||||
|
||||
// Generate test client
|
||||
const indexHtmlContent = generateStreamableHttpClientHtml(serverName);
|
||||
await fs.writeFile(indexHtmlPath, indexHtmlContent);
|
||||
console.error(` -> Created ${indexHtmlPath}`);
|
||||
}
|
||||
|
||||
console.error('\n---');
|
||||
console.error(`MCP server project '${serverName}' successfully generated at: ${outputDir}`);
|
||||
console.error('\nNext steps:');
|
||||
console.error(`1. Navigate to the directory: cd ${outputDir}`);
|
||||
console.error(`2. Install dependencies: npm install`);
|
||||
|
||||
if (options.transport === 'web') {
|
||||
console.error(`3. Build the TypeScript code: npm run build`);
|
||||
console.error(`4. Run the server in web mode: npm run start:web`);
|
||||
console.error(` (This will start a web server on port ${options.port || 3000})`);
|
||||
console.error(` Access the test client at: http://localhost:${options.port || 3000}`);
|
||||
} else if (options.transport === 'streamable-http') {
|
||||
console.error(`3. Build the TypeScript code: npm run build`);
|
||||
console.error(`4. Run the server in StreamableHTTP mode: npm run start:http`);
|
||||
console.error(` (This will start a StreamableHTTP server on port ${options.port || 3000})`);
|
||||
console.error(` Access the test client at: http://localhost:${options.port || 3000}`);
|
||||
} else {
|
||||
console.error(`3. Build the TypeScript code: npm run build`);
|
||||
console.error(`4. Run the server: npm start`);
|
||||
console.error(` (This runs the built JavaScript code in build/index.js)`);
|
||||
}
|
||||
console.error('---');
|
||||
} catch (error) {
|
||||
console.error('\nError generating MCP server project:', error);
|
||||
|
||||
// Only attempt cleanup if the directory exists and force option was used
|
||||
if (options.force) {
|
||||
try {
|
||||
await fs.rm(outputDir, { recursive: true, force: true });
|
||||
console.error(`Cleaned up partially created directory: ${outputDir}`);
|
||||
} catch (cleanupError) {
|
||||
console.error(`Failed to cleanup directory ${outputDir}:`, cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
main().catch((error) => {
|
||||
console.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@ -8,212 +8,219 @@ import { McpToolDefinition } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Extracts tool definitions from an OpenAPI document
|
||||
*
|
||||
*
|
||||
* @param api OpenAPI document
|
||||
* @returns Array of MCP tool definitions
|
||||
*/
|
||||
export function extractToolsFromApi(api: OpenAPIV3.Document): McpToolDefinition[] {
|
||||
const tools: McpToolDefinition[] = [];
|
||||
const usedNames = new Set<string>();
|
||||
const globalSecurity = api.security || [];
|
||||
const tools: McpToolDefinition[] = [];
|
||||
const usedNames = new Set<string>();
|
||||
const globalSecurity = api.security || [];
|
||||
|
||||
if (!api.paths) return tools;
|
||||
if (!api.paths) return tools;
|
||||
|
||||
for (const [path, pathItem] of Object.entries(api.paths)) {
|
||||
if (!pathItem) continue;
|
||||
|
||||
for (const method of Object.values(OpenAPIV3.HttpMethods)) {
|
||||
const operation = pathItem[method];
|
||||
if (!operation) continue;
|
||||
|
||||
// Generate a unique name for the tool
|
||||
let baseName = operation.operationId || generateOperationId(method, path);
|
||||
if (!baseName) continue;
|
||||
for (const [path, pathItem] of Object.entries(api.paths)) {
|
||||
if (!pathItem) continue;
|
||||
|
||||
// Sanitize the name to be MCP-compatible (only a-z, 0-9, _, -)
|
||||
baseName = baseName.replace(/\./g, '_').replace(/[^a-z0-9_-]/gi, '_').toLowerCase();
|
||||
|
||||
let finalToolName = baseName;
|
||||
let counter = 1;
|
||||
while (usedNames.has(finalToolName)) {
|
||||
finalToolName = `${baseName}_${counter++}`;
|
||||
}
|
||||
usedNames.add(finalToolName);
|
||||
|
||||
// Get or create a description
|
||||
const description = operation.description || operation.summary ||
|
||||
`Executes ${method.toUpperCase()} ${path}`;
|
||||
|
||||
// Generate input schema and extract parameters
|
||||
const { inputSchema, parameters, requestBodyContentType } = generateInputSchemaAndDetails(operation);
|
||||
|
||||
// Extract parameter details for execution
|
||||
const executionParameters = parameters.map(p => ({ name: p.name, in: p.in }));
|
||||
|
||||
// Determine security requirements
|
||||
const securityRequirements = operation.security === null ?
|
||||
globalSecurity :
|
||||
operation.security || globalSecurity;
|
||||
for (const method of Object.values(OpenAPIV3.HttpMethods)) {
|
||||
const operation = pathItem[method];
|
||||
if (!operation) continue;
|
||||
|
||||
// Create the tool definition
|
||||
tools.push({
|
||||
name: finalToolName,
|
||||
description,
|
||||
inputSchema,
|
||||
method,
|
||||
pathTemplate: path,
|
||||
parameters,
|
||||
executionParameters,
|
||||
requestBodyContentType,
|
||||
securityRequirements,
|
||||
operationId: baseName,
|
||||
});
|
||||
}
|
||||
// Generate a unique name for the tool
|
||||
let baseName = operation.operationId || generateOperationId(method, path);
|
||||
if (!baseName) continue;
|
||||
|
||||
// Sanitize the name to be MCP-compatible (only a-z, 0-9, _, -)
|
||||
baseName = baseName
|
||||
.replace(/\./g, '_')
|
||||
.replace(/[^a-z0-9_-]/gi, '_')
|
||||
.toLowerCase();
|
||||
|
||||
let finalToolName = baseName;
|
||||
let counter = 1;
|
||||
while (usedNames.has(finalToolName)) {
|
||||
finalToolName = `${baseName}_${counter++}`;
|
||||
}
|
||||
usedNames.add(finalToolName);
|
||||
|
||||
// Get or create a description
|
||||
const description =
|
||||
operation.description || operation.summary || `Executes ${method.toUpperCase()} ${path}`;
|
||||
|
||||
// Generate input schema and extract parameters
|
||||
const { inputSchema, parameters, requestBodyContentType } =
|
||||
generateInputSchemaAndDetails(operation);
|
||||
|
||||
// Extract parameter details for execution
|
||||
const executionParameters = parameters.map((p) => ({ name: p.name, in: p.in }));
|
||||
|
||||
// Determine security requirements
|
||||
const securityRequirements =
|
||||
operation.security === null ? globalSecurity : operation.security || globalSecurity;
|
||||
|
||||
// Create the tool definition
|
||||
tools.push({
|
||||
name: finalToolName,
|
||||
description,
|
||||
inputSchema,
|
||||
method,
|
||||
pathTemplate: path,
|
||||
parameters,
|
||||
executionParameters,
|
||||
requestBodyContentType,
|
||||
securityRequirements,
|
||||
operationId: baseName,
|
||||
});
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates input schema and extracts parameter details from an operation
|
||||
*
|
||||
*
|
||||
* @param operation OpenAPI operation object
|
||||
* @returns Input schema, parameters, and request body content type
|
||||
*/
|
||||
export function generateInputSchemaAndDetails(operation: OpenAPIV3.OperationObject): {
|
||||
inputSchema: JSONSchema7 | boolean;
|
||||
parameters: OpenAPIV3.ParameterObject[];
|
||||
requestBodyContentType?: string;
|
||||
inputSchema: JSONSchema7 | boolean;
|
||||
parameters: OpenAPIV3.ParameterObject[];
|
||||
requestBodyContentType?: string;
|
||||
} {
|
||||
const properties: { [key: string]: JSONSchema7 | boolean } = {};
|
||||
const required: string[] = [];
|
||||
|
||||
// Process parameters
|
||||
const allParameters: OpenAPIV3.ParameterObject[] = Array.isArray(operation.parameters)
|
||||
? operation.parameters.map(p => p as OpenAPIV3.ParameterObject)
|
||||
: [];
|
||||
|
||||
allParameters.forEach(param => {
|
||||
if (!param.name || !param.schema) return;
|
||||
|
||||
const paramSchema = mapOpenApiSchemaToJsonSchema(param.schema as OpenAPIV3.SchemaObject);
|
||||
if (typeof paramSchema === 'object') {
|
||||
paramSchema.description = param.description || paramSchema.description;
|
||||
}
|
||||
|
||||
properties[param.name] = paramSchema;
|
||||
if (param.required) required.push(param.name);
|
||||
});
|
||||
|
||||
// Process request body (if present)
|
||||
let requestBodyContentType: string | undefined = undefined;
|
||||
|
||||
if (operation.requestBody) {
|
||||
const opRequestBody = operation.requestBody as OpenAPIV3.RequestBodyObject;
|
||||
const jsonContent = opRequestBody.content?.['application/json'];
|
||||
const firstContent = opRequestBody.content ? Object.entries(opRequestBody.content)[0] : undefined;
|
||||
|
||||
if (jsonContent?.schema) {
|
||||
requestBodyContentType = 'application/json';
|
||||
const bodySchema = mapOpenApiSchemaToJsonSchema(jsonContent.schema as OpenAPIV3.SchemaObject);
|
||||
|
||||
if (typeof bodySchema === 'object') {
|
||||
bodySchema.description = opRequestBody.description ||
|
||||
bodySchema.description ||
|
||||
'The JSON request body.';
|
||||
}
|
||||
|
||||
properties['requestBody'] = bodySchema;
|
||||
if (opRequestBody.required) required.push('requestBody');
|
||||
} else if (firstContent) {
|
||||
const [contentType] = firstContent;
|
||||
requestBodyContentType = contentType;
|
||||
|
||||
properties['requestBody'] = {
|
||||
type: 'string',
|
||||
description: opRequestBody.description || `Request body (content type: ${contentType})`
|
||||
};
|
||||
|
||||
if (opRequestBody.required) required.push('requestBody');
|
||||
}
|
||||
const properties: { [key: string]: JSONSchema7 | boolean } = {};
|
||||
const required: string[] = [];
|
||||
|
||||
// Process parameters
|
||||
const allParameters: OpenAPIV3.ParameterObject[] = Array.isArray(operation.parameters)
|
||||
? operation.parameters.map((p) => p as OpenAPIV3.ParameterObject)
|
||||
: [];
|
||||
|
||||
allParameters.forEach((param) => {
|
||||
if (!param.name || !param.schema) return;
|
||||
|
||||
const paramSchema = mapOpenApiSchemaToJsonSchema(param.schema as OpenAPIV3.SchemaObject);
|
||||
if (typeof paramSchema === 'object') {
|
||||
paramSchema.description = param.description || paramSchema.description;
|
||||
}
|
||||
|
||||
// Combine everything into a JSON Schema
|
||||
const inputSchema: JSONSchema7 = {
|
||||
type: 'object',
|
||||
properties,
|
||||
...(required.length > 0 && { required })
|
||||
};
|
||||
|
||||
return { inputSchema, parameters: allParameters, requestBodyContentType };
|
||||
|
||||
properties[param.name] = paramSchema;
|
||||
if (param.required) required.push(param.name);
|
||||
});
|
||||
|
||||
// Process request body (if present)
|
||||
let requestBodyContentType: string | undefined = undefined;
|
||||
|
||||
if (operation.requestBody) {
|
||||
const opRequestBody = operation.requestBody as OpenAPIV3.RequestBodyObject;
|
||||
const jsonContent = opRequestBody.content?.['application/json'];
|
||||
const firstContent = opRequestBody.content
|
||||
? Object.entries(opRequestBody.content)[0]
|
||||
: undefined;
|
||||
|
||||
if (jsonContent?.schema) {
|
||||
requestBodyContentType = 'application/json';
|
||||
const bodySchema = mapOpenApiSchemaToJsonSchema(jsonContent.schema as OpenAPIV3.SchemaObject);
|
||||
|
||||
if (typeof bodySchema === 'object') {
|
||||
bodySchema.description =
|
||||
opRequestBody.description || bodySchema.description || 'The JSON request body.';
|
||||
}
|
||||
|
||||
properties['requestBody'] = bodySchema;
|
||||
if (opRequestBody.required) required.push('requestBody');
|
||||
} else if (firstContent) {
|
||||
const [contentType] = firstContent;
|
||||
requestBodyContentType = contentType;
|
||||
|
||||
properties['requestBody'] = {
|
||||
type: 'string',
|
||||
description: opRequestBody.description || `Request body (content type: ${contentType})`,
|
||||
};
|
||||
|
||||
if (opRequestBody.required) required.push('requestBody');
|
||||
}
|
||||
}
|
||||
|
||||
// Combine everything into a JSON Schema
|
||||
const inputSchema: JSONSchema7 = {
|
||||
type: 'object',
|
||||
properties,
|
||||
...(required.length > 0 && { required }),
|
||||
};
|
||||
|
||||
return { inputSchema, parameters: allParameters, requestBodyContentType };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an OpenAPI schema to a JSON Schema
|
||||
*
|
||||
*
|
||||
* @param schema OpenAPI schema object or reference
|
||||
* @returns JSON Schema representation
|
||||
*/
|
||||
export function mapOpenApiSchemaToJsonSchema(schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject): JSONSchema7 | boolean {
|
||||
// Handle reference objects
|
||||
if ('$ref' in schema) {
|
||||
console.warn(`Unresolved $ref '${schema.$ref}'.`);
|
||||
return { type: 'object' };
|
||||
export function mapOpenApiSchemaToJsonSchema(
|
||||
schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject
|
||||
): JSONSchema7 | boolean {
|
||||
// Handle reference objects
|
||||
if ('$ref' in schema) {
|
||||
console.warn(`Unresolved $ref '${schema.$ref}'.`);
|
||||
return { type: 'object' };
|
||||
}
|
||||
|
||||
// Handle boolean schemas
|
||||
if (typeof schema === 'boolean') return schema;
|
||||
|
||||
// Create a copy of the schema to modify
|
||||
const jsonSchema: JSONSchema7 = { ...schema } as any;
|
||||
|
||||
// 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';
|
||||
}
|
||||
|
||||
// Handle boolean schemas
|
||||
if (typeof schema === 'boolean') return schema;
|
||||
|
||||
// Create a copy of the schema to modify
|
||||
const jsonSchema: JSONSchema7 = { ...schema } as any;
|
||||
|
||||
// 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 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;
|
||||
}
|
||||
}
|
||||
|
||||
jsonSchema.properties = mappedProps;
|
||||
}
|
||||
|
||||
// Recursively process array items
|
||||
if (jsonSchema.type === 'array' && typeof jsonSchema.items === 'object' && jsonSchema.items !== null) {
|
||||
jsonSchema.items = mapOpenApiSchemaToJsonSchema(
|
||||
jsonSchema.items as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject
|
||||
);
|
||||
}
|
||||
|
||||
return jsonSchema;
|
||||
}
|
||||
|
||||
jsonSchema.properties = mappedProps;
|
||||
}
|
||||
|
||||
// Recursively process array items
|
||||
if (
|
||||
jsonSchema.type === 'array' &&
|
||||
typeof jsonSchema.items === 'object' &&
|
||||
jsonSchema.items !== null
|
||||
) {
|
||||
jsonSchema.items = mapOpenApiSchemaToJsonSchema(
|
||||
jsonSchema.items as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject
|
||||
);
|
||||
}
|
||||
|
||||
return jsonSchema;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
/**
|
||||
* Parser module exports
|
||||
*/
|
||||
export * from './extract-tools.js';
|
||||
export * from './extract-tools.js';
|
||||
|
||||
@ -1,27 +1,34 @@
|
||||
// src/types/index.ts
|
||||
|
||||
/**
|
||||
* Core type definitions for the openapi-to-mcp generator
|
||||
*/
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import type { JSONSchema7, JSONSchema7TypeName } from 'json-schema';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
|
||||
/**
|
||||
* Transport types supported by the MCP server
|
||||
*/
|
||||
export type TransportType = 'stdio' | 'web' | 'streamable-http';
|
||||
|
||||
/**
|
||||
* CLI options for the generator
|
||||
*/
|
||||
export interface CliOptions {
|
||||
/** Path to the OpenAPI specification file */
|
||||
input: string;
|
||||
/** Output directory path for generated files */
|
||||
output: string;
|
||||
/** Optional server name to override the one in the OpenAPI spec */
|
||||
serverName?: string;
|
||||
/** Optional server version to override the one in the OpenAPI spec */
|
||||
serverVersion?: string;
|
||||
/** Optional base URL to override the one in the OpenAPI spec */
|
||||
baseUrl?: string;
|
||||
/** Server transport type (stdio or web) */
|
||||
transport?: 'stdio' | 'web';
|
||||
/** Server port (for web transport) */
|
||||
port?: number;
|
||||
/** Path to the OpenAPI specification file */
|
||||
input: string;
|
||||
/** Output directory path for generated files */
|
||||
output: string;
|
||||
/** Optional server name to override the one in the OpenAPI spec */
|
||||
serverName?: string;
|
||||
/** Optional server version to override the one in the OpenAPI spec */
|
||||
serverVersion?: string;
|
||||
/** Optional base URL to override the one in the OpenAPI spec */
|
||||
baseUrl?: string;
|
||||
/** Server transport type (stdio, web, or streamable-http) */
|
||||
transport?: TransportType;
|
||||
/** Server port (for web and streamable-http transports) */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -29,29 +36,29 @@ export interface CliOptions {
|
||||
* for use in Model Context Protocol server
|
||||
*/
|
||||
export interface McpToolDefinition {
|
||||
/** Name of the tool, must be unique */
|
||||
name: string;
|
||||
/** Human-readable description of the tool */
|
||||
description: string;
|
||||
/** JSON Schema that defines the input parameters */
|
||||
inputSchema: JSONSchema7 | boolean;
|
||||
/** HTTP method for the operation (get, post, etc.) */
|
||||
method: string;
|
||||
/** URL path template with parameter placeholders */
|
||||
pathTemplate: string;
|
||||
/** OpenAPI parameter objects for this operation */
|
||||
parameters: OpenAPIV3.ParameterObject[];
|
||||
/** Parameter names and locations for execution */
|
||||
executionParameters: { name: string, in: string }[];
|
||||
/** Content type for request body, if applicable */
|
||||
requestBodyContentType?: string;
|
||||
/** Security requirements for this operation */
|
||||
securityRequirements: OpenAPIV3.SecurityRequirementObject[];
|
||||
/** Original operation ID from the OpenAPI spec */
|
||||
operationId: string;
|
||||
/** Name of the tool, must be unique */
|
||||
name: string;
|
||||
/** Human-readable description of the tool */
|
||||
description: string;
|
||||
/** JSON Schema that defines the input parameters */
|
||||
inputSchema: JSONSchema7 | boolean;
|
||||
/** HTTP method for the operation (get, post, etc.) */
|
||||
method: string;
|
||||
/** URL path template with parameter placeholders */
|
||||
pathTemplate: string;
|
||||
/** OpenAPI parameter objects for this operation */
|
||||
parameters: OpenAPIV3.ParameterObject[];
|
||||
/** Parameter names and locations for execution */
|
||||
executionParameters: { name: string; in: string }[];
|
||||
/** Content type for request body, if applicable */
|
||||
requestBodyContentType?: string;
|
||||
/** Security requirements for this operation */
|
||||
securityRequirements: OpenAPIV3.SecurityRequirementObject[];
|
||||
/** Original operation ID from the OpenAPI spec */
|
||||
operationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper type for JSON objects
|
||||
*/
|
||||
export type JsonObject = Record<string, any>;
|
||||
export type JsonObject = Record<string, any>;
|
||||
|
||||
@ -7,48 +7,49 @@ import { sanitizeForTemplate } from './helpers.js';
|
||||
|
||||
/**
|
||||
* Generates the tool definition map code
|
||||
*
|
||||
*
|
||||
* @param tools List of tool definitions
|
||||
* @param securitySchemes Security schemes from OpenAPI spec
|
||||
* @returns Generated code for the tool definition map
|
||||
*/
|
||||
export function generateToolDefinitionMap(
|
||||
tools: McpToolDefinition[],
|
||||
securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']
|
||||
tools: McpToolDefinition[],
|
||||
securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']
|
||||
): string {
|
||||
if (tools.length === 0) return "";
|
||||
|
||||
return tools.map(tool => {
|
||||
// Safely stringify complex objects
|
||||
let schemaString;
|
||||
try {
|
||||
schemaString = JSON.stringify(tool.inputSchema);
|
||||
} catch (e) {
|
||||
schemaString = '{}';
|
||||
console.warn(`Failed to stringify schema for tool ${tool.name}: ${e}`);
|
||||
}
|
||||
|
||||
let execParamsString;
|
||||
try {
|
||||
execParamsString = JSON.stringify(tool.executionParameters);
|
||||
} catch (e) {
|
||||
execParamsString = "[]";
|
||||
console.warn(`Failed to stringify execution parameters for tool ${tool.name}: ${e}`);
|
||||
}
|
||||
|
||||
let securityReqsString;
|
||||
try {
|
||||
securityReqsString = JSON.stringify(tool.securityRequirements);
|
||||
} catch (e) {
|
||||
securityReqsString = "[]";
|
||||
console.warn(`Failed to stringify security requirements for tool ${tool.name}: ${e}`);
|
||||
}
|
||||
|
||||
// Sanitize description for template literal
|
||||
const escapedDescription = sanitizeForTemplate(tool.description);
|
||||
|
||||
// Build the tool definition entry
|
||||
return `
|
||||
if (tools.length === 0) return '';
|
||||
|
||||
return tools
|
||||
.map((tool) => {
|
||||
// Safely stringify complex objects
|
||||
let schemaString;
|
||||
try {
|
||||
schemaString = JSON.stringify(tool.inputSchema);
|
||||
} catch (e) {
|
||||
schemaString = '{}';
|
||||
console.warn(`Failed to stringify schema for tool ${tool.name}: ${e}`);
|
||||
}
|
||||
|
||||
let execParamsString;
|
||||
try {
|
||||
execParamsString = JSON.stringify(tool.executionParameters);
|
||||
} catch (e) {
|
||||
execParamsString = '[]';
|
||||
console.warn(`Failed to stringify execution parameters for tool ${tool.name}: ${e}`);
|
||||
}
|
||||
|
||||
let securityReqsString;
|
||||
try {
|
||||
securityReqsString = JSON.stringify(tool.securityRequirements);
|
||||
} catch (e) {
|
||||
securityReqsString = '[]';
|
||||
console.warn(`Failed to stringify security requirements for tool ${tool.name}: ${e}`);
|
||||
}
|
||||
|
||||
// Sanitize description for template literal
|
||||
const escapedDescription = sanitizeForTemplate(tool.description);
|
||||
|
||||
// Build the tool definition entry
|
||||
return `
|
||||
["${tool.name}", {
|
||||
name: "${tool.name}",
|
||||
description: \`${escapedDescription}\`,
|
||||
@ -59,16 +60,17 @@ export function generateToolDefinitionMap(
|
||||
requestBodyContentType: ${tool.requestBodyContentType ? `"${tool.requestBodyContentType}"` : 'undefined'},
|
||||
securityRequirements: ${securityReqsString}
|
||||
}],`;
|
||||
}).join('');
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the list tools handler code
|
||||
*
|
||||
*
|
||||
* @returns Generated code for the list tools handler
|
||||
*/
|
||||
export function generateListToolsHandler(): string {
|
||||
return `
|
||||
return `
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
const toolsForClient: Tool[] = Array.from(toolDefinitionMap.values()).map(def => ({
|
||||
name: def.name,
|
||||
@ -82,11 +84,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
|
||||
/**
|
||||
* Generates the call tool handler code
|
||||
*
|
||||
*
|
||||
* @returns Generated code for the call tool handler
|
||||
*/
|
||||
export function generateCallToolHandler(): string {
|
||||
return `
|
||||
return `
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<CallToolResult> => {
|
||||
const { name: toolName, arguments: toolArgs } = request.params;
|
||||
const toolDefinition = toolDefinitionMap.get(toolName);
|
||||
@ -101,54 +103,54 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
/**
|
||||
* Convert a string to title case
|
||||
*
|
||||
*
|
||||
* @param str String to convert
|
||||
* @returns Title case string
|
||||
*/
|
||||
export function titleCase(str: string): string {
|
||||
// Converts snake_case, kebab-case, or path/parts to TitleCase
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[-_\/](.)/g, (_, char) => char.toUpperCase()) // Handle separators
|
||||
.replace(/^{/, '') // Remove leading { from path params
|
||||
.replace(/}$/, '') // Remove trailing } from path params
|
||||
.replace(/^./, (char) => char.toUpperCase()); // Capitalize first letter
|
||||
// Converts snake_case, kebab-case, or path/parts to TitleCase
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[-_\/](.)/g, (_, char) => char.toUpperCase()) // Handle separators
|
||||
.replace(/^{/, '') // Remove leading { from path params
|
||||
.replace(/}$/, '') // Remove trailing } from path params
|
||||
.replace(/^./, (char) => char.toUpperCase()); // Capitalize first letter
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an operation ID from method and path
|
||||
*
|
||||
*
|
||||
* @param method HTTP method
|
||||
* @param path API path
|
||||
* @returns Generated operation ID
|
||||
*/
|
||||
export function generateOperationId(method: string, path: string): string {
|
||||
// Generator: get /users/{userId}/posts -> GetUsersPostsByUserId
|
||||
const parts = path.split('/').filter(p => p); // Split and remove empty parts
|
||||
// Generator: get /users/{userId}/posts -> GetUsersPostsByUserId
|
||||
const parts = path.split('/').filter((p) => p); // Split and remove empty parts
|
||||
|
||||
let name = method.toLowerCase(); // Start with method name
|
||||
let name = method.toLowerCase(); // Start with method name
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
if (part.startsWith('{') && part.endsWith('}')) {
|
||||
// Append 'By' + ParamName only for the *last* path parameter segment
|
||||
if (index === parts.length - 1) {
|
||||
name += 'By' + titleCase(part);
|
||||
}
|
||||
// Potentially include non-terminal params differently if needed, e.g.:
|
||||
// else { name += 'With' + titleCase(part); }
|
||||
} else {
|
||||
// Append the static path part in TitleCase
|
||||
name += titleCase(part);
|
||||
}
|
||||
});
|
||||
|
||||
// Simple fallback if name is just the method (e.g., GET /)
|
||||
if (name === method.toLowerCase()) {
|
||||
name += 'Root';
|
||||
parts.forEach((part, index) => {
|
||||
if (part.startsWith('{') && part.endsWith('}')) {
|
||||
// Append 'By' + ParamName only for the *last* path parameter segment
|
||||
if (index === parts.length - 1) {
|
||||
name += 'By' + titleCase(part);
|
||||
}
|
||||
// Potentially include non-terminal params differently if needed, e.g.:
|
||||
// else { name += 'With' + titleCase(part); }
|
||||
} else {
|
||||
// Append the static path part in TitleCase
|
||||
name += titleCase(part);
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure first letter is uppercase after potential lowercase method start
|
||||
name = name.charAt(0).toUpperCase() + name.slice(1);
|
||||
// Simple fallback if name is just the method (e.g., GET /)
|
||||
if (name === method.toLowerCase()) {
|
||||
name += 'Root';
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
// Ensure first letter is uppercase after potential lowercase method start
|
||||
name = name.charAt(0).toUpperCase() + name.slice(1);
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
@ -4,109 +4,109 @@
|
||||
|
||||
/**
|
||||
* Safely stringify a JSON object with proper error handling
|
||||
*
|
||||
*
|
||||
* @param obj Object to stringify
|
||||
* @param defaultValue Default value to return if stringify fails
|
||||
* @returns JSON string or default value
|
||||
*/
|
||||
export function safeJsonStringify(obj: any, defaultValue: string = '{}'): string {
|
||||
try {
|
||||
return JSON.stringify(obj);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to stringify object: ${e}`);
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(obj);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to stringify object: ${e}`);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a string for use in template strings
|
||||
*
|
||||
*
|
||||
* @param str String to sanitize
|
||||
* @returns Sanitized string safe for use in template literals
|
||||
*/
|
||||
export function sanitizeForTemplate(str: string): string {
|
||||
return (str || '').replace(/\\/g, '\\\\').replace(/`/g, '\\`');
|
||||
return (str || '').replace(/\\/g, '\\\\').replace(/`/g, '\\`');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string to camelCase
|
||||
*
|
||||
*
|
||||
* @param str String to convert
|
||||
* @returns camelCase string
|
||||
*/
|
||||
export function toCamelCase(str: string): string {
|
||||
return str
|
||||
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) =>
|
||||
index === 0 ? word.toLowerCase() : word.toUpperCase()
|
||||
)
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[^a-zA-Z0-9]/g, '');
|
||||
return str
|
||||
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) =>
|
||||
index === 0 ? word.toLowerCase() : word.toUpperCase()
|
||||
)
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[^a-zA-Z0-9]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string to PascalCase
|
||||
*
|
||||
*
|
||||
* @param str String to convert
|
||||
* @returns PascalCase string
|
||||
*/
|
||||
export function toPascalCase(str: string): string {
|
||||
return str
|
||||
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word) => word.toUpperCase())
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[^a-zA-Z0-9]/g, '');
|
||||
return str
|
||||
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word) => word.toUpperCase())
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[^a-zA-Z0-9]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a valid variable name from a string
|
||||
*
|
||||
*
|
||||
* @param str Input string
|
||||
* @returns Valid JavaScript variable name
|
||||
*/
|
||||
export function toValidVariableName(str: string): string {
|
||||
// Replace non-alphanumeric characters with underscores
|
||||
const sanitized = str.replace(/[^a-zA-Z0-9_$]/g, '_');
|
||||
|
||||
// Ensure the variable name doesn't start with a number
|
||||
return sanitized.match(/^[0-9]/) ? '_' + sanitized : sanitized;
|
||||
// Replace non-alphanumeric characters with underscores
|
||||
const sanitized = str.replace(/[^a-zA-Z0-9_$]/g, '_');
|
||||
|
||||
// Ensure the variable name doesn't start with a number
|
||||
return sanitized.match(/^[0-9]/) ? '_' + sanitized : sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid JavaScript identifier
|
||||
*
|
||||
*
|
||||
* @param str String to check
|
||||
* @returns True if valid identifier, false otherwise
|
||||
*/
|
||||
export function isValidIdentifier(str: string): boolean {
|
||||
// Check if the string is a valid JavaScript identifier
|
||||
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(str);
|
||||
// Check if the string is a valid JavaScript identifier
|
||||
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a string for use in code comments
|
||||
*
|
||||
*
|
||||
* @param str String to format
|
||||
* @param maxLineLength Maximum line length
|
||||
* @returns Formatted comment string
|
||||
*/
|
||||
export function formatComment(str: string, maxLineLength: number = 80): string {
|
||||
if (!str) return '';
|
||||
|
||||
const words = str.trim().split(/\s+/);
|
||||
const lines: string[] = [];
|
||||
let currentLine = '';
|
||||
|
||||
words.forEach(word => {
|
||||
if ((currentLine + ' ' + word).length <= maxLineLength) {
|
||||
currentLine += (currentLine ? ' ' : '') + word;
|
||||
} else {
|
||||
lines.push(currentLine);
|
||||
currentLine = word;
|
||||
}
|
||||
});
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
if (!str) return '';
|
||||
|
||||
const words = str.trim().split(/\s+/);
|
||||
const lines: string[] = [];
|
||||
let currentLine = '';
|
||||
|
||||
words.forEach((word) => {
|
||||
if ((currentLine + ' ' + word).length <= maxLineLength) {
|
||||
currentLine += (currentLine ? ' ' : '') + word;
|
||||
} else {
|
||||
lines.push(currentLine);
|
||||
currentLine = word;
|
||||
}
|
||||
|
||||
return lines.join('\n * ');
|
||||
}
|
||||
});
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
return lines.join('\n * ');
|
||||
}
|
||||
|
||||
@ -4,4 +4,4 @@
|
||||
export * from './code-gen.js';
|
||||
export * from './security.js';
|
||||
export * from './helpers.js';
|
||||
export { determineBaseUrl } from './url.js';
|
||||
export { determineBaseUrl } from './url.js';
|
||||
|
||||
@ -5,28 +5,37 @@ import { OpenAPIV3 } from 'openapi-types';
|
||||
|
||||
/**
|
||||
* Get environment variable name for a security scheme
|
||||
*
|
||||
*
|
||||
* @param schemeName Security scheme name
|
||||
* @param type Type of security credentials
|
||||
* @returns Environment variable name
|
||||
*/
|
||||
export function getEnvVarName(
|
||||
schemeName: string,
|
||||
type: 'API_KEY' | 'BEARER_TOKEN' | 'BASIC_USERNAME' | 'BASIC_PASSWORD' | 'OAUTH_CLIENT_ID' | 'OAUTH_CLIENT_SECRET' | 'OAUTH_TOKEN' | 'OAUTH_SCOPES' | 'OPENID_TOKEN'
|
||||
schemeName: string,
|
||||
type:
|
||||
| 'API_KEY'
|
||||
| 'BEARER_TOKEN'
|
||||
| 'BASIC_USERNAME'
|
||||
| 'BASIC_PASSWORD'
|
||||
| 'OAUTH_CLIENT_ID'
|
||||
| 'OAUTH_CLIENT_SECRET'
|
||||
| 'OAUTH_TOKEN'
|
||||
| 'OAUTH_SCOPES'
|
||||
| 'OPENID_TOKEN'
|
||||
): string {
|
||||
const sanitizedName = schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase();
|
||||
return `${type}_${sanitizedName}`;
|
||||
const sanitizedName = schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase();
|
||||
return `${type}_${sanitizedName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates code for handling API key security
|
||||
*
|
||||
*
|
||||
* @param scheme API key security scheme
|
||||
* @returns Generated code
|
||||
*/
|
||||
export function generateApiKeySecurityCode(scheme: OpenAPIV3.ApiKeySecurityScheme): string {
|
||||
const schemeName = 'schemeName'; // Placeholder, will be replaced in template
|
||||
return `
|
||||
const schemeName = 'schemeName'; // Placeholder, will be replaced in template
|
||||
return `
|
||||
if (scheme?.type === 'apiKey') {
|
||||
const apiKey = process.env[\`${getEnvVarName(schemeName, 'API_KEY')}\`];
|
||||
if (apiKey) {
|
||||
@ -45,12 +54,12 @@ export function generateApiKeySecurityCode(scheme: OpenAPIV3.ApiKeySecuritySchem
|
||||
|
||||
/**
|
||||
* Generates code for handling HTTP security (Bearer/Basic)
|
||||
*
|
||||
*
|
||||
* @returns Generated code
|
||||
*/
|
||||
export function generateHttpSecurityCode(): string {
|
||||
const schemeName = 'schemeName'; // Placeholder, will be replaced in template
|
||||
return `
|
||||
const schemeName = 'schemeName'; // Placeholder, will be replaced in template
|
||||
return `
|
||||
else if (scheme?.type === 'http') {
|
||||
if (scheme.scheme?.toLowerCase() === 'bearer') {
|
||||
const token = process.env[\`${getEnvVarName(schemeName, 'BEARER_TOKEN')}\`];
|
||||
@ -70,11 +79,11 @@ export function generateHttpSecurityCode(): string {
|
||||
|
||||
/**
|
||||
* Generates code for OAuth2 token acquisition
|
||||
*
|
||||
*
|
||||
* @returns Generated code for OAuth2 token acquisition
|
||||
*/
|
||||
export function generateOAuth2TokenAcquisitionCode(): string {
|
||||
return `
|
||||
return `
|
||||
/**
|
||||
* Type definition for cached OAuth tokens
|
||||
*/
|
||||
@ -182,23 +191,23 @@ async function acquireOAuth2Token(schemeName: string, scheme: any): Promise<stri
|
||||
return null;
|
||||
}
|
||||
}
|
||||
`;
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates code for executing API tools with security handling
|
||||
*
|
||||
*
|
||||
* @param securitySchemes Security schemes from OpenAPI spec
|
||||
* @returns Generated code for the execute API tool function
|
||||
*/
|
||||
export function generateExecuteApiToolFunction(
|
||||
securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']
|
||||
securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']
|
||||
): string {
|
||||
// Generate OAuth2 token acquisition function
|
||||
const oauth2TokenAcquisitionCode = generateOAuth2TokenAcquisitionCode();
|
||||
|
||||
// Generate security handling code for checking, applying security
|
||||
const securityCode = `
|
||||
// Generate OAuth2 token acquisition function
|
||||
const oauth2TokenAcquisitionCode = generateOAuth2TokenAcquisitionCode();
|
||||
|
||||
// Generate security handling code for checking, applying security
|
||||
const securityCode = `
|
||||
// Apply security requirements if available
|
||||
// Security requirements use OR between array items and AND within each object
|
||||
const appliedSecurity = definition.securityRequirements?.find(req => {
|
||||
@ -353,8 +362,8 @@ export function generateExecuteApiToolFunction(
|
||||
}
|
||||
`;
|
||||
|
||||
// Generate complete execute API tool function
|
||||
return `
|
||||
// Generate complete execute API tool function
|
||||
return `
|
||||
${oauth2TokenAcquisitionCode}
|
||||
|
||||
/**
|
||||
@ -506,79 +515,80 @@ ${securityCode}
|
||||
|
||||
/**
|
||||
* Gets security scheme documentation for README
|
||||
*
|
||||
*
|
||||
* @param securitySchemes Security schemes from OpenAPI spec
|
||||
* @returns Documentation for security schemes
|
||||
*/
|
||||
export function getSecuritySchemesDocs(
|
||||
securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']
|
||||
securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']
|
||||
): string {
|
||||
if (!securitySchemes) return 'No security schemes defined in the OpenAPI spec.';
|
||||
|
||||
let docs = '';
|
||||
|
||||
for (const [name, schemeOrRef] of Object.entries(securitySchemes)) {
|
||||
if ('$ref' in schemeOrRef) {
|
||||
docs += `- \`${name}\`: Referenced security scheme (reference not resolved)\n`;
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheme = schemeOrRef;
|
||||
|
||||
if (scheme.type === 'apiKey') {
|
||||
const envVar = getEnvVarName(name, 'API_KEY');
|
||||
docs += `- \`${envVar}\`: API key for ${scheme.name} (in ${scheme.in})\n`;
|
||||
}
|
||||
else if (scheme.type === 'http') {
|
||||
if (scheme.scheme?.toLowerCase() === 'bearer') {
|
||||
const envVar = getEnvVarName(name, 'BEARER_TOKEN');
|
||||
docs += `- \`${envVar}\`: Bearer token for authentication\n`;
|
||||
}
|
||||
else if (scheme.scheme?.toLowerCase() === 'basic') {
|
||||
const usernameEnvVar = getEnvVarName(name, 'BASIC_USERNAME');
|
||||
const passwordEnvVar = getEnvVarName(name, 'BASIC_PASSWORD');
|
||||
docs += `- \`${usernameEnvVar}\`: Username for Basic authentication\n`;
|
||||
docs += `- \`${passwordEnvVar}\`: Password for Basic authentication\n`;
|
||||
}
|
||||
}
|
||||
else if (scheme.type === 'oauth2') {
|
||||
const flowTypes = scheme.flows ? Object.keys(scheme.flows) : ['unknown'];
|
||||
|
||||
// Add client credentials for OAuth2
|
||||
const clientIdVar = getEnvVarName(name, 'OAUTH_CLIENT_ID');
|
||||
const clientSecretVar = getEnvVarName(name, 'OAUTH_CLIENT_SECRET');
|
||||
docs += `- \`${clientIdVar}\`: Client ID for OAuth2 authentication (${flowTypes.join(', ')} flow)\n`;
|
||||
docs += `- \`${clientSecretVar}\`: Client secret for OAuth2 authentication\n`;
|
||||
|
||||
// Add OAuth token for manual setting
|
||||
const tokenVar = getEnvVarName(name, 'OAUTH_TOKEN');
|
||||
docs += `- \`${tokenVar}\`: OAuth2 token (if not using automatic token acquisition)\n`;
|
||||
|
||||
// Add scopes env var
|
||||
const scopesVar = getEnvVarName(name, 'OAUTH_SCOPES');
|
||||
docs += `- \`${scopesVar}\`: Space-separated list of OAuth2 scopes to request\n`;
|
||||
|
||||
// If available, list flow-specific details
|
||||
if (scheme.flows?.clientCredentials) {
|
||||
docs += ` Client Credentials Flow Token URL: ${scheme.flows.clientCredentials.tokenUrl}\n`;
|
||||
|
||||
// List available scopes if defined
|
||||
if (scheme.flows.clientCredentials.scopes && Object.keys(scheme.flows.clientCredentials.scopes).length > 0) {
|
||||
docs += ` Available scopes:\n`;
|
||||
for (const [scope, description] of Object.entries(scheme.flows.clientCredentials.scopes)) {
|
||||
docs += ` - \`${scope}\`: ${description}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (scheme.type === 'openIdConnect') {
|
||||
const tokenVar = getEnvVarName(name, 'OPENID_TOKEN');
|
||||
docs += `- \`${tokenVar}\`: OpenID Connect token\n`;
|
||||
if (scheme.openIdConnectUrl) {
|
||||
docs += ` OpenID Connect Discovery URL: ${scheme.openIdConnectUrl}\n`;
|
||||
}
|
||||
}
|
||||
if (!securitySchemes) return 'No security schemes defined in the OpenAPI spec.';
|
||||
|
||||
let docs = '';
|
||||
|
||||
for (const [name, schemeOrRef] of Object.entries(securitySchemes)) {
|
||||
if ('$ref' in schemeOrRef) {
|
||||
docs += `- \`${name}\`: Referenced security scheme (reference not resolved)\n`;
|
||||
continue;
|
||||
}
|
||||
|
||||
return docs;
|
||||
}
|
||||
|
||||
const scheme = schemeOrRef;
|
||||
|
||||
if (scheme.type === 'apiKey') {
|
||||
const envVar = getEnvVarName(name, 'API_KEY');
|
||||
docs += `- \`${envVar}\`: API key for ${scheme.name} (in ${scheme.in})\n`;
|
||||
} else if (scheme.type === 'http') {
|
||||
if (scheme.scheme?.toLowerCase() === 'bearer') {
|
||||
const envVar = getEnvVarName(name, 'BEARER_TOKEN');
|
||||
docs += `- \`${envVar}\`: Bearer token for authentication\n`;
|
||||
} else if (scheme.scheme?.toLowerCase() === 'basic') {
|
||||
const usernameEnvVar = getEnvVarName(name, 'BASIC_USERNAME');
|
||||
const passwordEnvVar = getEnvVarName(name, 'BASIC_PASSWORD');
|
||||
docs += `- \`${usernameEnvVar}\`: Username for Basic authentication\n`;
|
||||
docs += `- \`${passwordEnvVar}\`: Password for Basic authentication\n`;
|
||||
}
|
||||
} else if (scheme.type === 'oauth2') {
|
||||
const flowTypes = scheme.flows ? Object.keys(scheme.flows) : ['unknown'];
|
||||
|
||||
// Add client credentials for OAuth2
|
||||
const clientIdVar = getEnvVarName(name, 'OAUTH_CLIENT_ID');
|
||||
const clientSecretVar = getEnvVarName(name, 'OAUTH_CLIENT_SECRET');
|
||||
docs += `- \`${clientIdVar}\`: Client ID for OAuth2 authentication (${flowTypes.join(', ')} flow)\n`;
|
||||
docs += `- \`${clientSecretVar}\`: Client secret for OAuth2 authentication\n`;
|
||||
|
||||
// Add OAuth token for manual setting
|
||||
const tokenVar = getEnvVarName(name, 'OAUTH_TOKEN');
|
||||
docs += `- \`${tokenVar}\`: OAuth2 token (if not using automatic token acquisition)\n`;
|
||||
|
||||
// Add scopes env var
|
||||
const scopesVar = getEnvVarName(name, 'OAUTH_SCOPES');
|
||||
docs += `- \`${scopesVar}\`: Space-separated list of OAuth2 scopes to request\n`;
|
||||
|
||||
// If available, list flow-specific details
|
||||
if (scheme.flows?.clientCredentials) {
|
||||
docs += ` Client Credentials Flow Token URL: ${scheme.flows.clientCredentials.tokenUrl}\n`;
|
||||
|
||||
// List available scopes if defined
|
||||
if (
|
||||
scheme.flows.clientCredentials.scopes &&
|
||||
Object.keys(scheme.flows.clientCredentials.scopes).length > 0
|
||||
) {
|
||||
docs += ` Available scopes:\n`;
|
||||
for (const [scope, description] of Object.entries(
|
||||
scheme.flows.clientCredentials.scopes
|
||||
)) {
|
||||
docs += ` - \`${scope}\`: ${description}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (scheme.type === 'openIdConnect') {
|
||||
const tokenVar = getEnvVarName(name, 'OPENID_TOKEN');
|
||||
docs += `- \`${tokenVar}\`: OpenID Connect token\n`;
|
||||
if (scheme.openIdConnectUrl) {
|
||||
docs += ` OpenID Connect Discovery URL: ${scheme.openIdConnectUrl}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return docs;
|
||||
}
|
||||
|
||||
114
src/utils/url.ts
114
src/utils/url.ts
@ -5,97 +5,103 @@ import { OpenAPIV3 } from 'openapi-types';
|
||||
|
||||
/**
|
||||
* Determines the base URL from the OpenAPI document or CLI options
|
||||
*
|
||||
*
|
||||
* @param api OpenAPI document
|
||||
* @param cmdLineBaseUrl Optional base URL from command line options
|
||||
* @returns The determined base URL or null if none is available
|
||||
*/
|
||||
export function determineBaseUrl(api: OpenAPIV3.Document, cmdLineBaseUrl?: string): string | null {
|
||||
// Command line option takes precedence
|
||||
if (cmdLineBaseUrl) {
|
||||
return normalizeUrl(cmdLineBaseUrl);
|
||||
}
|
||||
|
||||
// Single server in OpenAPI spec
|
||||
if (api.servers && api.servers.length === 1 && api.servers[0].url) {
|
||||
return normalizeUrl(api.servers[0].url);
|
||||
}
|
||||
|
||||
// Multiple servers - use first one with warning
|
||||
if (api.servers && api.servers.length > 1) {
|
||||
console.warn(`Multiple servers found. Using first: "${api.servers[0].url}". Use --base-url to override.`);
|
||||
return normalizeUrl(api.servers[0].url);
|
||||
}
|
||||
|
||||
// No server information available
|
||||
return null;
|
||||
// Command line option takes precedence
|
||||
if (cmdLineBaseUrl) {
|
||||
return normalizeUrl(cmdLineBaseUrl);
|
||||
}
|
||||
|
||||
// Single server in OpenAPI spec
|
||||
if (api.servers && api.servers.length === 1 && api.servers[0].url) {
|
||||
return normalizeUrl(api.servers[0].url);
|
||||
}
|
||||
|
||||
// Multiple servers - use first one with warning
|
||||
if (api.servers && api.servers.length > 1) {
|
||||
console.warn(
|
||||
`Multiple servers found. Using first: "${api.servers[0].url}". Use --base-url to override.`
|
||||
);
|
||||
return normalizeUrl(api.servers[0].url);
|
||||
}
|
||||
|
||||
// No server information available
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a URL by removing trailing slashes
|
||||
*
|
||||
*
|
||||
* @param url URL to normalize
|
||||
* @returns Normalized URL
|
||||
*/
|
||||
export function normalizeUrl(url: string): string {
|
||||
return url.replace(/\/$/, '');
|
||||
return url.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins URL segments handling slashes correctly
|
||||
*
|
||||
*
|
||||
* @param baseUrl Base URL
|
||||
* @param path Path to append
|
||||
* @returns Joined URL
|
||||
*/
|
||||
export function joinUrl(baseUrl: string, path: string): string {
|
||||
if (!baseUrl) return path;
|
||||
if (!path) return baseUrl;
|
||||
|
||||
const normalizedBase = normalizeUrl(baseUrl);
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
if (!baseUrl) return path;
|
||||
if (!path) return baseUrl;
|
||||
|
||||
const normalizedBase = normalizeUrl(baseUrl);
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a URL with query parameters
|
||||
*
|
||||
*
|
||||
* @param baseUrl Base URL
|
||||
* @param queryParams Query parameters
|
||||
* @returns URL with query parameters
|
||||
*/
|
||||
export function buildUrlWithQuery(baseUrl: string, queryParams: Record<string, any>): string {
|
||||
if (!Object.keys(queryParams).length) return baseUrl;
|
||||
|
||||
const url = new URL(baseUrl.startsWith('http') ? baseUrl : `http://localhost${baseUrl.startsWith('/') ? '' : '/'}${baseUrl}`);
|
||||
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(item => url.searchParams.append(key, String(item)));
|
||||
} else {
|
||||
url.searchParams.append(key, String(value));
|
||||
}
|
||||
if (!Object.keys(queryParams).length) return baseUrl;
|
||||
|
||||
const url = new URL(
|
||||
baseUrl.startsWith('http')
|
||||
? baseUrl
|
||||
: `http://localhost${baseUrl.startsWith('/') ? '' : '/'}${baseUrl}`
|
||||
);
|
||||
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => url.searchParams.append(key, String(item)));
|
||||
} else {
|
||||
url.searchParams.append(key, String(value));
|
||||
}
|
||||
|
||||
// Remove http://localhost if we added it
|
||||
return baseUrl.startsWith('http') ? url.toString() : url.pathname + url.search;
|
||||
}
|
||||
|
||||
// Remove http://localhost if we added it
|
||||
return baseUrl.startsWith('http') ? url.toString() : url.pathname + url.search;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts path parameters from a URL template
|
||||
*
|
||||
*
|
||||
* @param urlTemplate URL template with {param} placeholders
|
||||
* @returns Array of parameter names
|
||||
*/
|
||||
export function extractPathParams(urlTemplate: string): string[] {
|
||||
const paramRegex = /{([^}]+)}/g;
|
||||
const params: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = paramRegex.exec(urlTemplate)) !== null) {
|
||||
params.push(match[1]);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
const paramRegex = /{([^}]+)}/g;
|
||||
const params: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = paramRegex.exec(urlTemplate)) !== null) {
|
||||
params.push(match[1]);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user