1137 lines
36 KiB
JavaScript
1137 lines
36 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* MCP Server generated from OpenAPI spec for swagger-petstore---openapi-3-0 v1.0.26
|
|
* Generated on: 2025-04-26T16:34:48.638Z
|
|
*/
|
|
|
|
// Load environment variables from .env file
|
|
import dotenv from 'dotenv';
|
|
dotenv.config();
|
|
|
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
import {
|
|
CallToolRequestSchema,
|
|
ListToolsRequestSchema,
|
|
type Tool,
|
|
type CallToolResult,
|
|
type CallToolRequest,
|
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
import { setupStreamableHttpServer } from './streamable-http.js';
|
|
|
|
import { z, ZodError } from 'zod';
|
|
import { jsonSchemaToZod } from 'json-schema-to-zod';
|
|
import axios, { type AxiosRequestConfig, type AxiosError } from 'axios';
|
|
|
|
/**
|
|
* Type definition for JSON objects
|
|
*/
|
|
type JsonObject = Record<string, any>;
|
|
|
|
/**
|
|
* Interface for MCP Tool Definition
|
|
*/
|
|
interface McpToolDefinition {
|
|
name: string;
|
|
description: string;
|
|
inputSchema: any;
|
|
method: string;
|
|
pathTemplate: string;
|
|
executionParameters: { name: string; in: string }[];
|
|
requestBodyContentType?: string;
|
|
securityRequirements: any[];
|
|
}
|
|
|
|
/**
|
|
* Server configuration
|
|
*/
|
|
export const SERVER_NAME = 'swagger-petstore---openapi-3-0';
|
|
export const SERVER_VERSION = '1.0.26';
|
|
export const API_BASE_URL = 'https://petstore3.swagger.io/api/v3';
|
|
|
|
/**
|
|
* MCP Server instance
|
|
*/
|
|
const server = new Server(
|
|
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
{ capabilities: { tools: {} } }
|
|
);
|
|
|
|
/**
|
|
* Map of tool definitions by name
|
|
*/
|
|
const toolDefinitionMap: Map<string, McpToolDefinition> = new Map([
|
|
[
|
|
'updatepet',
|
|
{
|
|
name: 'updatepet',
|
|
description: `Update an existing pet by Id.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
requestBody: {
|
|
required: ['name', 'photoUrls'],
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'number', format: 'int64' },
|
|
name: { type: 'string' },
|
|
category: {
|
|
type: 'object',
|
|
properties: { id: { type: 'number', format: 'int64' }, name: { type: 'string' } },
|
|
},
|
|
photoUrls: { type: 'array', items: { type: 'string' } },
|
|
tags: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: { id: { type: 'number', format: 'int64' }, name: { type: 'string' } },
|
|
},
|
|
},
|
|
status: {
|
|
type: 'string',
|
|
description: 'pet status in the store',
|
|
enum: ['available', 'pending', 'sold'],
|
|
},
|
|
},
|
|
description: 'Update an existent pet in the store',
|
|
},
|
|
},
|
|
required: ['requestBody'],
|
|
},
|
|
method: 'put',
|
|
pathTemplate: '/pet',
|
|
executionParameters: [],
|
|
requestBodyContentType: 'application/json',
|
|
securityRequirements: [{ petstore_auth: ['write:pets', 'read:pets'] }],
|
|
},
|
|
],
|
|
[
|
|
'addpet',
|
|
{
|
|
name: 'addpet',
|
|
description: `Add a new pet to the store.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
requestBody: {
|
|
required: ['name', 'photoUrls'],
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'number', format: 'int64' },
|
|
name: { type: 'string' },
|
|
category: {
|
|
type: 'object',
|
|
properties: { id: { type: 'number', format: 'int64' }, name: { type: 'string' } },
|
|
},
|
|
photoUrls: { type: 'array', items: { type: 'string' } },
|
|
tags: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: { id: { type: 'number', format: 'int64' }, name: { type: 'string' } },
|
|
},
|
|
},
|
|
status: {
|
|
type: 'string',
|
|
description: 'pet status in the store',
|
|
enum: ['available', 'pending', 'sold'],
|
|
},
|
|
},
|
|
description: 'Create a new pet in the store',
|
|
},
|
|
},
|
|
required: ['requestBody'],
|
|
},
|
|
method: 'post',
|
|
pathTemplate: '/pet',
|
|
executionParameters: [],
|
|
requestBodyContentType: 'application/json',
|
|
securityRequirements: [{ petstore_auth: ['write:pets', 'read:pets'] }],
|
|
},
|
|
],
|
|
[
|
|
'findpetsbystatus',
|
|
{
|
|
name: 'findpetsbystatus',
|
|
description: `Multiple status values can be provided with comma separated strings.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
status: {
|
|
type: 'string',
|
|
default: 'available',
|
|
enum: ['available', 'pending', 'sold'],
|
|
description: 'Status values that need to be considered for filter',
|
|
},
|
|
},
|
|
},
|
|
method: 'get',
|
|
pathTemplate: '/pet/findByStatus',
|
|
executionParameters: [{ name: 'status', in: 'query' }],
|
|
requestBodyContentType: undefined,
|
|
securityRequirements: [{ petstore_auth: ['write:pets', 'read:pets'] }],
|
|
},
|
|
],
|
|
[
|
|
'findpetsbytags',
|
|
{
|
|
name: 'findpetsbytags',
|
|
description: `Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
tags: { type: 'array', items: { type: 'string' }, description: 'Tags to filter by' },
|
|
},
|
|
},
|
|
method: 'get',
|
|
pathTemplate: '/pet/findByTags',
|
|
executionParameters: [{ name: 'tags', in: 'query' }],
|
|
requestBodyContentType: undefined,
|
|
securityRequirements: [{ petstore_auth: ['write:pets', 'read:pets'] }],
|
|
},
|
|
],
|
|
[
|
|
'getpetbyid',
|
|
{
|
|
name: 'getpetbyid',
|
|
description: `Returns a single pet.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
petId: { type: 'number', format: 'int64', description: 'ID of pet to return' },
|
|
},
|
|
required: ['petId'],
|
|
},
|
|
method: 'get',
|
|
pathTemplate: '/pet/{petId}',
|
|
executionParameters: [{ name: 'petId', in: 'path' }],
|
|
requestBodyContentType: undefined,
|
|
securityRequirements: [{ api_key: [] }, { petstore_auth: ['write:pets', 'read:pets'] }],
|
|
},
|
|
],
|
|
[
|
|
'updatepetwithform',
|
|
{
|
|
name: 'updatepetwithform',
|
|
description: `Updates a pet resource based on the form data.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
petId: {
|
|
type: 'number',
|
|
format: 'int64',
|
|
description: 'ID of pet that needs to be updated',
|
|
},
|
|
name: { type: 'string', description: 'Name of pet that needs to be updated' },
|
|
status: { type: 'string', description: 'Status of pet that needs to be updated' },
|
|
},
|
|
required: ['petId'],
|
|
},
|
|
method: 'post',
|
|
pathTemplate: '/pet/{petId}',
|
|
executionParameters: [
|
|
{ name: 'petId', in: 'path' },
|
|
{ name: 'name', in: 'query' },
|
|
{ name: 'status', in: 'query' },
|
|
],
|
|
requestBodyContentType: undefined,
|
|
securityRequirements: [{ petstore_auth: ['write:pets', 'read:pets'] }],
|
|
},
|
|
],
|
|
[
|
|
'deletepet',
|
|
{
|
|
name: 'deletepet',
|
|
description: `Delete a pet.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
api_key: { type: 'string' },
|
|
petId: { type: 'number', format: 'int64', description: 'Pet id to delete' },
|
|
},
|
|
required: ['petId'],
|
|
},
|
|
method: 'delete',
|
|
pathTemplate: '/pet/{petId}',
|
|
executionParameters: [
|
|
{ name: 'api_key', in: 'header' },
|
|
{ name: 'petId', in: 'path' },
|
|
],
|
|
requestBodyContentType: undefined,
|
|
securityRequirements: [{ petstore_auth: ['write:pets', 'read:pets'] }],
|
|
},
|
|
],
|
|
[
|
|
'uploadfile',
|
|
{
|
|
name: 'uploadfile',
|
|
description: `Upload image of the pet.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
petId: { type: 'number', format: 'int64', description: 'ID of pet to update' },
|
|
additionalMetadata: { type: 'string', description: 'Additional Metadata' },
|
|
requestBody: {
|
|
type: 'string',
|
|
description: 'Request body (content type: application/octet-stream)',
|
|
},
|
|
},
|
|
required: ['petId'],
|
|
},
|
|
method: 'post',
|
|
pathTemplate: '/pet/{petId}/uploadImage',
|
|
executionParameters: [
|
|
{ name: 'petId', in: 'path' },
|
|
{ name: 'additionalMetadata', in: 'query' },
|
|
],
|
|
requestBodyContentType: 'application/octet-stream',
|
|
securityRequirements: [{ petstore_auth: ['write:pets', 'read:pets'] }],
|
|
},
|
|
],
|
|
[
|
|
'getinventory',
|
|
{
|
|
name: 'getinventory',
|
|
description: `Returns a map of status codes to quantities.`,
|
|
inputSchema: { type: 'object', properties: {} },
|
|
method: 'get',
|
|
pathTemplate: '/store/inventory',
|
|
executionParameters: [],
|
|
requestBodyContentType: undefined,
|
|
securityRequirements: [{ api_key: [] }],
|
|
},
|
|
],
|
|
[
|
|
'placeorder',
|
|
{
|
|
name: 'placeorder',
|
|
description: `Place a new order in the store.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
requestBody: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'number', format: 'int64' },
|
|
petId: { type: 'number', format: 'int64' },
|
|
quantity: { type: 'number', format: 'int32' },
|
|
shipDate: { type: 'string', format: 'date-time' },
|
|
status: {
|
|
type: 'string',
|
|
description: 'Order Status',
|
|
enum: ['placed', 'approved', 'delivered'],
|
|
},
|
|
complete: { type: 'boolean' },
|
|
},
|
|
description: 'The JSON request body.',
|
|
},
|
|
},
|
|
},
|
|
method: 'post',
|
|
pathTemplate: '/store/order',
|
|
executionParameters: [],
|
|
requestBodyContentType: 'application/json',
|
|
securityRequirements: [],
|
|
},
|
|
],
|
|
[
|
|
'getorderbyid',
|
|
{
|
|
name: 'getorderbyid',
|
|
description: `For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
orderId: {
|
|
type: 'number',
|
|
format: 'int64',
|
|
description: 'ID of order that needs to be fetched',
|
|
},
|
|
},
|
|
required: ['orderId'],
|
|
},
|
|
method: 'get',
|
|
pathTemplate: '/store/order/{orderId}',
|
|
executionParameters: [{ name: 'orderId', in: 'path' }],
|
|
requestBodyContentType: undefined,
|
|
securityRequirements: [],
|
|
},
|
|
],
|
|
[
|
|
'deleteorder',
|
|
{
|
|
name: 'deleteorder',
|
|
description: `For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
orderId: {
|
|
type: 'number',
|
|
format: 'int64',
|
|
description: 'ID of the order that needs to be deleted',
|
|
},
|
|
},
|
|
required: ['orderId'],
|
|
},
|
|
method: 'delete',
|
|
pathTemplate: '/store/order/{orderId}',
|
|
executionParameters: [{ name: 'orderId', in: 'path' }],
|
|
requestBodyContentType: undefined,
|
|
securityRequirements: [],
|
|
},
|
|
],
|
|
[
|
|
'createuser',
|
|
{
|
|
name: 'createuser',
|
|
description: `This can only be done by the logged in user.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
requestBody: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'number', format: 'int64' },
|
|
username: { type: 'string' },
|
|
firstName: { type: 'string' },
|
|
lastName: { type: 'string' },
|
|
email: { type: 'string' },
|
|
password: { type: 'string' },
|
|
phone: { type: 'string' },
|
|
userStatus: { type: 'number', description: 'User Status', format: 'int32' },
|
|
},
|
|
description: 'Created user object',
|
|
},
|
|
},
|
|
},
|
|
method: 'post',
|
|
pathTemplate: '/user',
|
|
executionParameters: [],
|
|
requestBodyContentType: 'application/json',
|
|
securityRequirements: [],
|
|
},
|
|
],
|
|
[
|
|
'createuserswithlistinput',
|
|
{
|
|
name: 'createuserswithlistinput',
|
|
description: `Creates list of users with given input array.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
requestBody: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'number', format: 'int64' },
|
|
username: { type: 'string' },
|
|
firstName: { type: 'string' },
|
|
lastName: { type: 'string' },
|
|
email: { type: 'string' },
|
|
password: { type: 'string' },
|
|
phone: { type: 'string' },
|
|
userStatus: { type: 'number', description: 'User Status', format: 'int32' },
|
|
},
|
|
},
|
|
description: 'The JSON request body.',
|
|
},
|
|
},
|
|
},
|
|
method: 'post',
|
|
pathTemplate: '/user/createWithList',
|
|
executionParameters: [],
|
|
requestBodyContentType: 'application/json',
|
|
securityRequirements: [],
|
|
},
|
|
],
|
|
[
|
|
'loginuser',
|
|
{
|
|
name: 'loginuser',
|
|
description: `Log into the system.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
username: { type: 'string', description: 'The user name for login' },
|
|
password: { type: 'string', description: 'The password for login in clear text' },
|
|
},
|
|
},
|
|
method: 'get',
|
|
pathTemplate: '/user/login',
|
|
executionParameters: [
|
|
{ name: 'username', in: 'query' },
|
|
{ name: 'password', in: 'query' },
|
|
],
|
|
requestBodyContentType: undefined,
|
|
securityRequirements: [],
|
|
},
|
|
],
|
|
[
|
|
'logoutuser',
|
|
{
|
|
name: 'logoutuser',
|
|
description: `Log user out of the system.`,
|
|
inputSchema: { type: 'object', properties: {} },
|
|
method: 'get',
|
|
pathTemplate: '/user/logout',
|
|
executionParameters: [],
|
|
requestBodyContentType: undefined,
|
|
securityRequirements: [],
|
|
},
|
|
],
|
|
[
|
|
'getuserbyname',
|
|
{
|
|
name: 'getuserbyname',
|
|
description: `Get user detail based on username.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
username: {
|
|
type: 'string',
|
|
description: 'The name that needs to be fetched. Use user1 for testing',
|
|
},
|
|
},
|
|
required: ['username'],
|
|
},
|
|
method: 'get',
|
|
pathTemplate: '/user/{username}',
|
|
executionParameters: [{ name: 'username', in: 'path' }],
|
|
requestBodyContentType: undefined,
|
|
securityRequirements: [],
|
|
},
|
|
],
|
|
[
|
|
'updateuser',
|
|
{
|
|
name: 'updateuser',
|
|
description: `This can only be done by the logged in user.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
username: { type: 'string', description: 'name that need to be deleted' },
|
|
requestBody: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'number', format: 'int64' },
|
|
username: { type: 'string' },
|
|
firstName: { type: 'string' },
|
|
lastName: { type: 'string' },
|
|
email: { type: 'string' },
|
|
password: { type: 'string' },
|
|
phone: { type: 'string' },
|
|
userStatus: { type: 'number', description: 'User Status', format: 'int32' },
|
|
},
|
|
description: 'Update an existent user in the store',
|
|
},
|
|
},
|
|
required: ['username'],
|
|
},
|
|
method: 'put',
|
|
pathTemplate: '/user/{username}',
|
|
executionParameters: [{ name: 'username', in: 'path' }],
|
|
requestBodyContentType: 'application/json',
|
|
securityRequirements: [],
|
|
},
|
|
],
|
|
[
|
|
'deleteuser',
|
|
{
|
|
name: 'deleteuser',
|
|
description: `This can only be done by the logged in user.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
username: { type: 'string', description: 'The name that needs to be deleted' },
|
|
},
|
|
required: ['username'],
|
|
},
|
|
method: 'delete',
|
|
pathTemplate: '/user/{username}',
|
|
executionParameters: [{ name: 'username', in: 'path' }],
|
|
requestBodyContentType: undefined,
|
|
securityRequirements: [],
|
|
},
|
|
],
|
|
]);
|
|
|
|
/**
|
|
* Security schemes from the OpenAPI spec
|
|
*/
|
|
const securitySchemes = {
|
|
petstore_auth: {
|
|
type: 'oauth2',
|
|
flows: {
|
|
implicit: {
|
|
authorizationUrl: 'https://petstore3.swagger.io/oauth/authorize',
|
|
scopes: {
|
|
'write:pets': 'modify pets in your account',
|
|
'read:pets': 'read your pets',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
api_key: {
|
|
type: 'apiKey',
|
|
name: 'api_key',
|
|
in: 'header',
|
|
},
|
|
};
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
const toolsForClient: Tool[] = Array.from(toolDefinitionMap.values()).map((def) => ({
|
|
name: def.name,
|
|
description: def.description,
|
|
inputSchema: def.inputSchema,
|
|
}));
|
|
return { tools: toolsForClient };
|
|
});
|
|
|
|
server.setRequestHandler(
|
|
CallToolRequestSchema,
|
|
async (request: CallToolRequest): Promise<CallToolResult> => {
|
|
const { name: toolName, arguments: toolArgs } = request.params;
|
|
const toolDefinition = toolDefinitionMap.get(toolName);
|
|
if (!toolDefinition) {
|
|
console.error(`Error: Unknown tool requested: ${toolName}`);
|
|
return { content: [{ type: 'text', text: `Error: Unknown tool requested: ${toolName}` }] };
|
|
}
|
|
return await executeApiTool(toolName, toolDefinition, toolArgs ?? {}, securitySchemes);
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Type definition for cached OAuth tokens
|
|
*/
|
|
interface TokenCacheEntry {
|
|
token: string;
|
|
expiresAt: number;
|
|
}
|
|
|
|
/**
|
|
* Declare global __oauthTokenCache property for TypeScript
|
|
*/
|
|
declare global {
|
|
var __oauthTokenCache: Record<string, TokenCacheEntry> | undefined;
|
|
}
|
|
|
|
/**
|
|
* Acquires an OAuth2 token using client credentials flow
|
|
*
|
|
* @param schemeName Name of the security scheme
|
|
* @param scheme OAuth2 security scheme
|
|
* @returns Acquired token or null if unable to acquire
|
|
*/
|
|
async function acquireOAuth2Token(
|
|
schemeName: string,
|
|
scheme: any
|
|
): Promise<string | null | undefined> {
|
|
try {
|
|
// Check if we have the necessary credentials
|
|
const clientId = process.env[`OAUTH_CLIENT_ID_SCHEMENAME`];
|
|
const clientSecret = process.env[`OAUTH_CLIENT_SECRET_SCHEMENAME`];
|
|
const scopes = process.env[`OAUTH_SCOPES_SCHEMENAME`];
|
|
|
|
if (!clientId || !clientSecret) {
|
|
console.error(`Missing client credentials for OAuth2 scheme '${schemeName}'`);
|
|
return null;
|
|
}
|
|
|
|
// Initialize token cache if needed
|
|
if (typeof global.__oauthTokenCache === 'undefined') {
|
|
global.__oauthTokenCache = {};
|
|
}
|
|
|
|
// Check if we have a cached token
|
|
const cacheKey = `${schemeName}_${clientId}`;
|
|
const cachedToken = global.__oauthTokenCache[cacheKey];
|
|
const now = Date.now();
|
|
|
|
if (cachedToken && cachedToken.expiresAt > now) {
|
|
console.error(
|
|
`Using cached OAuth2 token for '${schemeName}' (expires in ${Math.floor((cachedToken.expiresAt - now) / 1000)} seconds)`
|
|
);
|
|
return cachedToken.token;
|
|
}
|
|
|
|
// Determine token URL based on flow type
|
|
let tokenUrl = '';
|
|
if (scheme.flows?.clientCredentials?.tokenUrl) {
|
|
tokenUrl = scheme.flows.clientCredentials.tokenUrl;
|
|
console.error(`Using client credentials flow for '${schemeName}'`);
|
|
} else if (scheme.flows?.password?.tokenUrl) {
|
|
tokenUrl = scheme.flows.password.tokenUrl;
|
|
console.error(`Using password flow for '${schemeName}'`);
|
|
} else {
|
|
console.error(`No supported OAuth2 flow found for '${schemeName}'`);
|
|
return null;
|
|
}
|
|
|
|
// Prepare the token request
|
|
let formData = new URLSearchParams();
|
|
formData.append('grant_type', 'client_credentials');
|
|
|
|
// Add scopes if specified
|
|
if (scopes) {
|
|
formData.append('scope', scopes);
|
|
}
|
|
|
|
console.error(`Requesting OAuth2 token from ${tokenUrl}`);
|
|
|
|
// Make the token request
|
|
const response = await axios({
|
|
method: 'POST',
|
|
url: tokenUrl,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
|
|
},
|
|
data: formData.toString(),
|
|
});
|
|
|
|
// Process the response
|
|
if (response.data?.access_token) {
|
|
const token = response.data.access_token;
|
|
const expiresIn = response.data.expires_in || 3600; // Default to 1 hour
|
|
|
|
// Cache the token
|
|
global.__oauthTokenCache[cacheKey] = {
|
|
token,
|
|
expiresAt: now + expiresIn * 1000 - 60000, // Expire 1 minute early
|
|
};
|
|
|
|
console.error(
|
|
`Successfully acquired OAuth2 token for '${schemeName}' (expires in ${expiresIn} seconds)`
|
|
);
|
|
return token;
|
|
} else {
|
|
console.error(
|
|
`Failed to acquire OAuth2 token for '${schemeName}': No access_token in response`
|
|
);
|
|
return null;
|
|
}
|
|
} catch (error: unknown) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
console.error(`Error acquiring OAuth2 token for '${schemeName}':`, errorMessage);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes an API tool with the provided arguments
|
|
*
|
|
* @param toolName Name of the tool to execute
|
|
* @param definition Tool definition
|
|
* @param toolArgs Arguments provided by the user
|
|
* @param allSecuritySchemes Security schemes from the OpenAPI spec
|
|
* @returns Call tool result
|
|
*/
|
|
async function executeApiTool(
|
|
toolName: string,
|
|
definition: McpToolDefinition,
|
|
toolArgs: JsonObject,
|
|
allSecuritySchemes: Record<string, any>
|
|
): Promise<CallToolResult> {
|
|
try {
|
|
// Validate arguments against the input schema
|
|
let validatedArgs: JsonObject;
|
|
try {
|
|
const zodSchema = getZodSchemaFromJsonSchema(definition.inputSchema, toolName);
|
|
const argsToParse = typeof toolArgs === 'object' && toolArgs !== null ? toolArgs : {};
|
|
validatedArgs = zodSchema.parse(argsToParse);
|
|
} catch (error: unknown) {
|
|
if (error instanceof ZodError) {
|
|
const validationErrorMessage = `Invalid arguments for tool '${toolName}': ${error.errors.map((e) => `${e.path.join('.')} (${e.code}): ${e.message}`).join(', ')}`;
|
|
return { content: [{ type: 'text', text: validationErrorMessage }] };
|
|
} else {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
content: [
|
|
{ type: 'text', text: `Internal error during validation setup: ${errorMessage}` },
|
|
],
|
|
};
|
|
}
|
|
}
|
|
|
|
// Prepare URL, query parameters, headers, and request body
|
|
let urlPath = definition.pathTemplate;
|
|
const queryParams: Record<string, any> = {};
|
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
let requestBodyData: any = undefined;
|
|
|
|
// Apply parameters to the URL path, query, or headers
|
|
definition.executionParameters.forEach((param) => {
|
|
const value = validatedArgs[param.name];
|
|
if (typeof value !== 'undefined' && value !== null) {
|
|
if (param.in === 'path') {
|
|
urlPath = urlPath.replace(`{${param.name}}`, encodeURIComponent(String(value)));
|
|
} else if (param.in === 'query') {
|
|
queryParams[param.name] = value;
|
|
} else if (param.in === 'header') {
|
|
headers[param.name.toLowerCase()] = String(value);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Ensure all path parameters are resolved
|
|
if (urlPath.includes('{')) {
|
|
throw new Error(`Failed to resolve path parameters: ${urlPath}`);
|
|
}
|
|
|
|
// Construct the full URL
|
|
const requestUrl = API_BASE_URL ? `${API_BASE_URL}${urlPath}` : urlPath;
|
|
|
|
// Handle request body if needed
|
|
if (definition.requestBodyContentType && typeof validatedArgs['requestBody'] !== 'undefined') {
|
|
requestBodyData = validatedArgs['requestBody'];
|
|
headers['content-type'] = definition.requestBodyContentType;
|
|
}
|
|
|
|
// Apply security requirements if available
|
|
// Security requirements use OR between array items and AND within each object
|
|
const appliedSecurity = definition.securityRequirements?.find((req) => {
|
|
// Try each security requirement (combined with OR)
|
|
return Object.entries(req).every(([schemeName, scopesArray]) => {
|
|
const scheme = allSecuritySchemes[schemeName];
|
|
if (!scheme) return false;
|
|
|
|
// API Key security (header, query, cookie)
|
|
if (scheme.type === 'apiKey') {
|
|
return !!process.env[`API_KEY_${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`];
|
|
}
|
|
|
|
// HTTP security (basic, bearer)
|
|
if (scheme.type === 'http') {
|
|
if (scheme.scheme?.toLowerCase() === 'bearer') {
|
|
return !!process.env[
|
|
`BEARER_TOKEN_${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`
|
|
];
|
|
} else if (scheme.scheme?.toLowerCase() === 'basic') {
|
|
return (
|
|
!!process.env[
|
|
`BASIC_USERNAME_${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`
|
|
] &&
|
|
!!process.env[
|
|
`BASIC_PASSWORD_${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
// OAuth2 security
|
|
if (scheme.type === 'oauth2') {
|
|
// Check for pre-existing token
|
|
if (
|
|
process.env[`OAUTH_TOKEN_${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`]
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// Check for client credentials for auto-acquisition
|
|
if (
|
|
process.env[
|
|
`OAUTH_CLIENT_ID_${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`
|
|
] &&
|
|
process.env[
|
|
`OAUTH_CLIENT_SECRET_${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`
|
|
]
|
|
) {
|
|
// Verify we have a supported flow
|
|
if (scheme.flows?.clientCredentials || scheme.flows?.password) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// OpenID Connect
|
|
if (scheme.type === 'openIdConnect') {
|
|
return !!process.env[
|
|
`OPENID_TOKEN_${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`
|
|
];
|
|
}
|
|
|
|
return false;
|
|
});
|
|
});
|
|
|
|
// If we found matching security scheme(s), apply them
|
|
if (appliedSecurity) {
|
|
// Apply each security scheme from this requirement (combined with AND)
|
|
for (const [schemeName, scopesArray] of Object.entries(appliedSecurity)) {
|
|
const scheme = allSecuritySchemes[schemeName];
|
|
|
|
// API Key security
|
|
if (scheme?.type === 'apiKey') {
|
|
const apiKey =
|
|
process.env[`API_KEY_${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`];
|
|
if (apiKey) {
|
|
if (scheme.in === 'header') {
|
|
headers[scheme.name.toLowerCase()] = apiKey;
|
|
console.error(`Applied API key '${schemeName}' in header '${scheme.name}'`);
|
|
} else if (scheme.in === 'query') {
|
|
queryParams[scheme.name] = apiKey;
|
|
console.error(`Applied API key '${schemeName}' in query parameter '${scheme.name}'`);
|
|
} else if (scheme.in === 'cookie') {
|
|
// Add the cookie, preserving other cookies if they exist
|
|
headers['cookie'] =
|
|
`${scheme.name}=${apiKey}${headers['cookie'] ? `; ${headers['cookie']}` : ''}`;
|
|
console.error(`Applied API key '${schemeName}' in cookie '${scheme.name}'`);
|
|
}
|
|
}
|
|
}
|
|
// HTTP security (Bearer or Basic)
|
|
else if (scheme?.type === 'http') {
|
|
if (scheme.scheme?.toLowerCase() === 'bearer') {
|
|
const token =
|
|
process.env[`BEARER_TOKEN_${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`];
|
|
if (token) {
|
|
headers['authorization'] = `Bearer ${token}`;
|
|
console.error(`Applied Bearer token for '${schemeName}'`);
|
|
}
|
|
} else if (scheme.scheme?.toLowerCase() === 'basic') {
|
|
const username =
|
|
process.env[
|
|
`BASIC_USERNAME_${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`
|
|
];
|
|
const password =
|
|
process.env[
|
|
`BASIC_PASSWORD_${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`
|
|
];
|
|
if (username && password) {
|
|
headers['authorization'] =
|
|
`Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
|
console.error(`Applied Basic authentication for '${schemeName}'`);
|
|
}
|
|
}
|
|
}
|
|
// OAuth2 security
|
|
else if (scheme?.type === 'oauth2') {
|
|
// First try to use a pre-provided token
|
|
let token =
|
|
process.env[`OAUTH_TOKEN_${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`];
|
|
|
|
// If no token but we have client credentials, try to acquire a token
|
|
if (!token && (scheme.flows?.clientCredentials || scheme.flows?.password)) {
|
|
console.error(`Attempting to acquire OAuth token for '${schemeName}'`);
|
|
token = (await acquireOAuth2Token(schemeName, scheme)) ?? '';
|
|
}
|
|
|
|
// Apply token if available
|
|
if (token) {
|
|
headers['authorization'] = `Bearer ${token}`;
|
|
console.error(`Applied OAuth2 token for '${schemeName}'`);
|
|
|
|
// List the scopes that were requested, if any
|
|
const scopes = scopesArray as string[];
|
|
if (scopes && scopes.length > 0) {
|
|
console.error(`Requested scopes: ${scopes.join(', ')}`);
|
|
}
|
|
}
|
|
}
|
|
// OpenID Connect
|
|
else if (scheme?.type === 'openIdConnect') {
|
|
const token =
|
|
process.env[`OPENID_TOKEN_${schemeName.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`];
|
|
if (token) {
|
|
headers['authorization'] = `Bearer ${token}`;
|
|
console.error(`Applied OpenID Connect token for '${schemeName}'`);
|
|
|
|
// List the scopes that were requested, if any
|
|
const scopes = scopesArray as string[];
|
|
if (scopes && scopes.length > 0) {
|
|
console.error(`Requested scopes: ${scopes.join(', ')}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Log warning if security is required but not available
|
|
else if (definition.securityRequirements?.length > 0) {
|
|
// First generate a more readable representation of the security requirements
|
|
const securityRequirementsString = definition.securityRequirements
|
|
.map((req) => {
|
|
const parts = Object.entries(req)
|
|
.map(([name, scopesArray]) => {
|
|
const scopes = scopesArray as string[];
|
|
if (scopes.length === 0) return name;
|
|
return `${name} (scopes: ${scopes.join(', ')})`;
|
|
})
|
|
.join(' AND ');
|
|
return `[${parts}]`;
|
|
})
|
|
.join(' OR ');
|
|
|
|
console.warn(
|
|
`Tool '${toolName}' requires security: ${securityRequirementsString}, but no suitable credentials found.`
|
|
);
|
|
}
|
|
|
|
// Prepare the axios request configuration
|
|
const config: AxiosRequestConfig = {
|
|
method: definition.method.toUpperCase(),
|
|
url: requestUrl,
|
|
params: queryParams,
|
|
headers: headers,
|
|
...(requestBodyData !== undefined && { data: requestBodyData }),
|
|
};
|
|
|
|
// Log request info to stderr (doesn't affect MCP output)
|
|
console.error(`Executing tool "${toolName}": ${config.method} ${config.url}`);
|
|
|
|
// Execute the request
|
|
const response = await axios(config);
|
|
|
|
// Process and format the response
|
|
let responseText = '';
|
|
const contentType = response.headers['content-type']?.toLowerCase() || '';
|
|
|
|
// Handle JSON responses
|
|
if (
|
|
contentType.includes('application/json') &&
|
|
typeof response.data === 'object' &&
|
|
response.data !== null
|
|
) {
|
|
try {
|
|
responseText = JSON.stringify(response.data, null, 2);
|
|
} catch (e) {
|
|
responseText = '[Stringify Error]';
|
|
}
|
|
}
|
|
// Handle string responses
|
|
else if (typeof response.data === 'string') {
|
|
responseText = response.data;
|
|
}
|
|
// Handle other response types
|
|
else if (response.data !== undefined && response.data !== null) {
|
|
responseText = String(response.data);
|
|
}
|
|
// Handle empty responses
|
|
else {
|
|
responseText = `(Status: ${response.status} - No body content)`;
|
|
}
|
|
|
|
// Return formatted response
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `API Response (Status: ${response.status}):\n${responseText}`,
|
|
},
|
|
],
|
|
};
|
|
} catch (error: unknown) {
|
|
// Handle errors during execution
|
|
let errorMessage: string;
|
|
|
|
// Format Axios errors specially
|
|
if (axios.isAxiosError(error)) {
|
|
errorMessage = formatApiError(error);
|
|
}
|
|
// Handle standard errors
|
|
else if (error instanceof Error) {
|
|
errorMessage = error.message;
|
|
}
|
|
// Handle unexpected error types
|
|
else {
|
|
errorMessage = 'Unexpected error: ' + String(error);
|
|
}
|
|
|
|
// Log error to stderr
|
|
console.error(`Error during execution of tool '${toolName}':`, errorMessage);
|
|
|
|
// Return error message to client
|
|
return { content: [{ type: 'text', text: errorMessage }] };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main function to start the server
|
|
*/
|
|
async function main() {
|
|
// Set up StreamableHTTP transport
|
|
try {
|
|
await setupStreamableHttpServer(server, 3000);
|
|
} catch (error) {
|
|
console.error('Error setting up StreamableHTTP server:', error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup function for graceful shutdown
|
|
*/
|
|
async function cleanup() {
|
|
console.error('Shutting down MCP server...');
|
|
process.exit(0);
|
|
}
|
|
|
|
// Register signal handlers
|
|
process.on('SIGINT', cleanup);
|
|
process.on('SIGTERM', cleanup);
|
|
|
|
// Start the server
|
|
main().catch((error) => {
|
|
console.error('Fatal error in main execution:', error);
|
|
process.exit(1);
|
|
});
|
|
|
|
/**
|
|
* Formats API errors for better readability
|
|
*
|
|
* @param error Axios error
|
|
* @returns Formatted error message
|
|
*/
|
|
function formatApiError(error: AxiosError): string {
|
|
let message = 'API request failed.';
|
|
if (error.response) {
|
|
message = `API Error: Status ${error.response.status} (${error.response.statusText || 'Status text not available'}). `;
|
|
const responseData = error.response.data;
|
|
const MAX_LEN = 200;
|
|
if (typeof responseData === 'string') {
|
|
message += `Response: ${responseData.substring(0, MAX_LEN)}${responseData.length > MAX_LEN ? '...' : ''}`;
|
|
} else if (responseData) {
|
|
try {
|
|
const jsonString = JSON.stringify(responseData);
|
|
message += `Response: ${jsonString.substring(0, MAX_LEN)}${jsonString.length > MAX_LEN ? '...' : ''}`;
|
|
} catch {
|
|
message += 'Response: [Could not serialize data]';
|
|
}
|
|
} else {
|
|
message += 'No response body received.';
|
|
}
|
|
} else if (error.request) {
|
|
message = 'API Network Error: No response received from server.';
|
|
if (error.code) message += ` (Code: ${error.code})`;
|
|
} else {
|
|
message += `API Request Setup Error: ${error.message}`;
|
|
}
|
|
return message;
|
|
}
|
|
|
|
/**
|
|
* Converts a JSON Schema to a Zod schema for runtime validation
|
|
*
|
|
* @param jsonSchema JSON Schema
|
|
* @param toolName Tool name for error reporting
|
|
* @returns Zod schema
|
|
*/
|
|
function getZodSchemaFromJsonSchema(jsonSchema: any, toolName: string): z.ZodTypeAny {
|
|
if (typeof jsonSchema !== 'object' || jsonSchema === null) {
|
|
return z.object({}).passthrough();
|
|
}
|
|
try {
|
|
const zodSchemaString = jsonSchemaToZod(jsonSchema);
|
|
const zodSchema = eval(zodSchemaString);
|
|
if (typeof zodSchema?.parse !== 'function') {
|
|
throw new Error('Eval did not produce a valid Zod schema.');
|
|
}
|
|
return zodSchema as z.ZodTypeAny;
|
|
} catch (err: any) {
|
|
console.error(`Failed to generate/evaluate Zod schema for '${toolName}':`, err);
|
|
return z.object({}).passthrough();
|
|
}
|
|
}
|