openapi-mcp-generator
Version:
Generates MCP server code from OpenAPI specifications
558 lines (513 loc) • 23.9 kB
JavaScript
/**
* 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, type) {
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) {
const schemeName = 'schemeName'; // Placeholder, will be replaced in template
return `
if (scheme?.type === 'apiKey') {
const apiKey = process.env[\`${getEnvVarName(schemeName, 'API_KEY')}\`];
if (apiKey) {
if (scheme.in === 'header') {
headers[scheme.name.toLowerCase()] = apiKey;
}
else if (scheme.in === 'query') {
queryParams[scheme.name] = apiKey;
}
else if (scheme.in === 'cookie') {
headers['cookie'] = \`\${scheme.name}=\${apiKey}\${headers['cookie'] ? \`; \${headers['cookie']}\` : ''}\`;
}
}
}`;
}
/**
* Generates code for handling HTTP security (Bearer/Basic)
*
* @returns Generated code
*/
export function generateHttpSecurityCode() {
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')}\`];
if (token) {
headers['authorization'] = \`Bearer \${token}\`;
}
}
else if (scheme.scheme?.toLowerCase() === 'basic') {
const username = process.env[\`${getEnvVarName(schemeName, 'BASIC_USERNAME')}\`];
const password = process.env[\`${getEnvVarName(schemeName, 'BASIC_PASSWORD')}\`];
if (username && password) {
headers['authorization'] = \`Basic \${Buffer.from(\`\${username}:\${password}\`).toString('base64')}\`;
}
}
}`;
}
/**
* Generates code for OAuth2 token acquisition
*
* @returns Generated code for OAuth2 token acquisition
*/
export function generateOAuth2TokenAcquisitionCode() {
return `
/**
* 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[\`${getEnvVarName('schemeName', 'OAUTH_CLIENT_ID')}\`];
const clientSecret = process.env[\`${getEnvVarName('schemeName', 'OAUTH_CLIENT_SECRET')}\`];
const scopes = process.env[\`${getEnvVarName('schemeName', 'OAUTH_SCOPES')}\`];
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;
}
}
`;
}
/**
* 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) {
// 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 => {
// 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.\`);
}
`;
// Generate complete execute API tool function
return `
${oauth2TokenAcquisitionCode}
/**
* 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;
}
${securityCode}
// 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 }] };
}
}
`;
}
/**
* Gets security scheme documentation for README
*
* @param securitySchemes Security schemes from OpenAPI spec
* @returns Documentation for security schemes
*/
export function getSecuritySchemesDocs(securitySchemes) {
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`;
}
}
}
return docs;
}
//# sourceMappingURL=security.js.map