iwyrkore f2a580a0a8 Added passthroughAuth option, --passthrough-auth on the commandline.
If passthroughAuth is true, auth headers in MCP call tool requests are passed through to the API requests as specified by the OpenAPI spec. Scheme types http (bearer or basic), apiKey (header, query param, or cookie), and openIdConnect bearer tokens (passed through) as supported.
2025-08-26 15:56:40 -04:00

611 lines
25 KiB
TypeScript

/**
* Security handling utilities for OpenAPI to MCP generator
*/
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'
): string {
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 `
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(): string {
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(): string {
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?: OpenAPIV3.ComponentsObject['securitySchemes']
): string {
// Generate OAuth2 token acquisition function
const oauth2TokenAcquisitionCode = generateOAuth2TokenAcquisitionCode();
// Generate security handling code for checking, applying security
const securityCode = `
function getHeaderValue(headers: IsomorphicHeaders | undefined, key: string): string | undefined {
const value = headers?.[key];
return Array.isArray(value) ? value[0] : value;
}
// 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()}\`] || getHeaderValue(sessionHeaders,scheme.name.toLowerCase()));
}
// 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()}\`] || getHeaderValue(sessionHeaders,'authorization')?.startsWith('Bearer '));
}
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()}\`]) ||
getHeaderValue(sessionHeaders,'authorization')?.startsWith('Basic '));
}
}
// 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()}\`] || getHeaderValue(sessionHeaders,'authorization')?.startsWith('Bearer '));
}
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()}\`] || getHeaderValue(sessionHeaders,scheme.name.toLowerCase()));
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 (getHeaderValue(sessionHeaders,'authorization')?.startsWith('Bearer ')) {
headers['authorization'] = getHeaderValue(sessionHeaders,'authorization')!;
console.error(\`Applied Bearer token for '\${schemeName}' from session headers\`);
}
}
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}'\`);
} else if (getHeaderValue(sessionHeaders,'authorization')?.startsWith('Basic ')) {
headers['authorization'] = getHeaderValue(sessionHeaders,'authorization')!;
console.error(\`Applied Basic authentication for '\${schemeName}' from session headers\`);
}
}
}
// 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(', ')}\`);
}
} else if (getHeaderValue(sessionHeaders,'authorization')?.startsWith('Bearer ')) {
headers['authorization'] = getHeaderValue(sessionHeaders,'authorization')!;
console.error(\`Applied OpenID Connect token for '\${schemeName}' from session headers\`);
}
}
}
}
// 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>,
sessionHeaders: IsomorphicHeaders | undefined
): 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?: 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`;
}
}
}
return docs;
}