feat: add passthrough headers support for OpenAPI client and MCP protocol (#345)

This commit is contained in:
samanhappy
2025-09-20 17:12:20 +08:00
committed by GitHub
parent ba50a78879
commit 66b6053f7f
14 changed files with 464 additions and 17 deletions

View File

@@ -121,6 +121,66 @@ See the `examples/openapi-schema-config.json` file for complete configuration ex
- **Validation**: Enhanced validation logic in server controllers
- **Type Safety**: Updated TypeScript interfaces for both input modes
## Header Passthrough Support
MCPHub supports passing through specific headers from tool call requests to upstream OpenAPI endpoints. This is useful for authentication tokens, API keys, and other request-specific headers.
### Configuration
Add `passthroughHeaders` to your OpenAPI configuration:
```json
{
"type": "openapi",
"openapi": {
"url": "https://api.example.com/openapi.json",
"version": "3.1.0",
"passthroughHeaders": ["Authorization", "X-API-Key", "X-Custom-Header"],
"security": {
"type": "apiKey",
"apiKey": {
"name": "X-API-Key",
"in": "header",
"value": "your-api-key"
}
}
}
}
```
### How It Works
1. **Configuration**: List header names in the `passthroughHeaders` array
2. **Tool Calls**: When calling tools via HTTP API, include headers in the request
3. **Passthrough**: Only configured headers are forwarded to the upstream API
4. **Case Insensitive**: Header matching is case-insensitive for flexibility
### Example Usage
```bash
# Call an OpenAPI tool with passthrough headers
curl -X POST "http://localhost:3000/api/tools/myapi/createUser" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token" \
-H "X-API-Key: your-api-key" \
-H "X-Custom-Header: custom-value" \
-d '{"name": "John Doe", "email": "john@example.com"}'
```
In this example:
- If `passthroughHeaders` includes `["Authorization", "X-API-Key"]`
- Only `Authorization` and `X-API-Key` headers will be forwarded
- `X-Custom-Header` will be ignored (not in passthrough list)
- `Content-Type` is handled by the OpenAPI operation definition
### Security Considerations
- **Whitelist Only**: Only explicitly configured headers are passed through
- **Sensitive Data**: Be careful with headers containing sensitive information
- **Validation**: Upstream APIs should validate all received headers
- **Logging**: Headers may appear in logs - consider this for sensitive data
## Security Considerations
When using JSON schemas:

View File

@@ -65,13 +65,16 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
oauth2Token: initialData.config.openapi.security?.oauth2?.token || '',
// OpenID Connect initialization
openIdConnectUrl: initialData.config.openapi.security?.openIdConnect?.url || '',
openIdConnectToken: initialData.config.openapi.security?.openIdConnect?.token || ''
openIdConnectToken: initialData.config.openapi.security?.openIdConnect?.token || '',
// Passthrough headers initialization
passthroughHeaders: initialData.config.openapi.passthroughHeaders ? initialData.config.openapi.passthroughHeaders.join(', ') : '',
} : {
inputMode: 'url',
url: '',
schema: '',
version: '3.1.0',
securityType: 'none'
securityType: 'none',
passthroughHeaders: '',
}
})
@@ -235,6 +238,14 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
};
}
// Add passthrough headers if provided
if (formData.openapi?.passthroughHeaders && formData.openapi.passthroughHeaders.trim()) {
openapi.passthroughHeaders = formData.openapi.passthroughHeaders
.split(',')
.map(header => header.trim())
.filter(header => header.length > 0);
}
return openapi;
})(),
...(Object.keys(headers).length > 0 ? { headers } : {})
@@ -616,6 +627,24 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</div>
)}
{/* Passthrough Headers Configuration */}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
{t('server.openapi.passthroughHeaders')}
</label>
<input
type="text"
value={formData.openapi?.passthroughHeaders || ''}
onChange={(e) => setFormData(prev => ({
...prev,
openapi: { ...prev.openapi, passthroughHeaders: e.target.value, url: prev.openapi?.url || '' }
}))}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="Authorization, X-API-Key, X-Custom-Header"
/>
<p className="text-xs text-gray-500 mt-1">{t('server.openapi.passthroughHeadersHelp')}</p>
</div>
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<label className="block text-gray-700 text-sm font-bold">

View File

@@ -127,6 +127,7 @@ export interface ServerConfig {
schema?: Record<string, any>; // Complete OpenAPI JSON schema
version?: string; // OpenAPI version (default: '3.1.0')
security?: OpenAPISecurityConfig; // Security configuration for API calls
passthroughHeaders?: string[]; // Header names to pass through from tool call requests to upstream OpenAPI endpoints
};
}
@@ -232,6 +233,8 @@ export interface ServerFormData {
openIdConnectClientId?: string;
openIdConnectClientSecret?: string;
openIdConnectToken?: string;
// Passthrough headers
passthroughHeaders?: string; // Comma-separated list of header names
};
}

View File

@@ -159,7 +159,9 @@
"openIdConnectToken": "ID Token",
"apiKeyInHeader": "Header",
"apiKeyInQuery": "Query",
"apiKeyInCookie": "Cookie"
"apiKeyInCookie": "Cookie",
"passthroughHeaders": "Passthrough Headers",
"passthroughHeadersHelp": "Comma-separated list of header names to pass through from tool call requests to upstream OpenAPI endpoints (e.g., Authorization, X-API-Key)"
}
},
"status": {

View File

@@ -159,7 +159,9 @@
"openIdConnectToken": "Jeton d'identification",
"apiKeyInHeader": "En-tête",
"apiKeyInQuery": "Requête",
"apiKeyInCookie": "Cookie"
"apiKeyInCookie": "Cookie",
"passthroughHeaders": "En-têtes de transmission",
"passthroughHeadersHelp": "Liste séparée par des virgules des noms d'en-têtes à transmettre des requêtes d'appel d'outils vers les points de terminaison OpenAPI en amont (par ex. : Authorization, X-API-Key)"
}
},
"status": {
@@ -618,4 +620,4 @@
"serverToolsUpdated": "Outils du serveur mis à jour avec succès"
}
}
}
}

View File

@@ -159,7 +159,9 @@
"openIdConnectToken": "ID 令牌",
"apiKeyInHeader": "请求头",
"apiKeyInQuery": "查询",
"apiKeyInCookie": "Cookie"
"apiKeyInCookie": "Cookie",
"passthroughHeaders": "透传请求头",
"passthroughHeadersHelp": "要从工具调用请求透传到上游OpenAPI接口的请求头名称列表用逗号分隔Authorization, X-API-Key"
}
},
"status": {

View File

@@ -299,7 +299,11 @@ export class OpenAPIClient {
return schema;
}
async callTool(toolName: string, args: Record<string, unknown>): Promise<unknown> {
async callTool(
toolName: string,
args: Record<string, unknown>,
passthroughHeaders?: Record<string, string>,
): Promise<unknown> {
const tool = this.tools.find((t) => t.name === toolName);
if (!tool) {
throw new Error(`Tool '${toolName}' not found`);
@@ -340,18 +344,32 @@ export class OpenAPIClient {
requestConfig.data = args.body;
}
// Collect all headers to be sent
const allHeaders: Record<string, string> = {};
// Add headers if any header parameters are defined
const headerParams = tool.parameters?.filter((p) => p.in === 'header') || [];
if (headerParams.length > 0) {
requestConfig.headers = {};
for (const param of headerParams) {
const value = args[param.name];
if (value !== undefined) {
requestConfig.headers[param.name] = String(value);
for (const param of headerParams) {
const value = args[param.name];
if (value !== undefined) {
allHeaders[param.name] = String(value);
}
}
// Add passthrough headers based on configuration
if (passthroughHeaders && this.config.openapi?.passthroughHeaders) {
for (const headerName of this.config.openapi.passthroughHeaders) {
if (passthroughHeaders[headerName]) {
allHeaders[headerName] = passthroughHeaders[headerName];
}
}
}
// Set headers if any were collected
if (Object.keys(allHeaders).length > 0) {
requestConfig.headers = allHeaders;
}
const response = await this.httpClient.request(requestConfig);
return response.data;
} catch (error) {

View File

@@ -201,6 +201,7 @@ export const executeToolViaOpenAPI = async (req: Request, res: Response): Promis
const extra = {
sessionId: (req.headers['x-session-id'] as string) || 'openapi-session',
server: serverName,
headers: req.headers, // Pass all request headers for potential passthrough
};
const result = await handleCallToolRequest(mockRequest, extra);

View File

@@ -61,6 +61,7 @@ export const callTool = async (req: Request, res: Response): Promise<void> => {
const extra = {
sessionId: req.headers['x-session-id'] || 'api-session',
server: server || undefined,
headers: req.headers, // Include request headers for passthrough
};
const result = (await handleCallToolRequest(mockRequest, extra)) as ToolCallResult;

View File

@@ -18,6 +18,7 @@ import { getGroup } from './sseService.js';
import { getServersInGroup, getServerConfigInGroup } from './groupService.js';
import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js';
import { OpenAPIClient } from '../clients/openapi.js';
import { RequestContextService } from './requestContextService.js';
import { getDataService } from './services.js';
import { getServerDao, ServerConfigWithName } from '../dao/index.js';
@@ -403,6 +404,7 @@ export const initializeClientsFromSettings = async (
prompts: [],
createTime: Date.now(),
enabled: conf.enabled === undefined ? true : conf.enabled,
config: conf, // Store reference to original config for OpenAPI passthrough headers
};
serverInfos.push(serverInfo);
@@ -487,6 +489,7 @@ export const initializeClientsFromSettings = async (
transport,
options: requestOptions,
createTime: Date.now(),
config: conf, // Store reference to original config
};
serverInfos.push(serverInfo);
@@ -1036,7 +1039,34 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
? toolName.replace(`${targetServerInfo.name}-`, '')
: toolName;
const result = await openApiClient.callTool(cleanToolName, finalArgs);
// Extract passthrough headers from extra or request context
let passthroughHeaders: Record<string, string> | undefined;
let requestHeaders: Record<string, string | string[] | undefined> | null = null;
// Try to get headers from extra parameter first (if available)
if (extra?.headers) {
requestHeaders = extra.headers;
} else {
// Fallback to request context service
const requestContextService = RequestContextService.getInstance();
requestHeaders = requestContextService.getHeaders();
}
if (requestHeaders && targetServerInfo.config?.openapi?.passthroughHeaders) {
passthroughHeaders = {};
for (const headerName of targetServerInfo.config.openapi.passthroughHeaders) {
// Handle different header name cases (Express normalizes headers to lowercase)
const headerValue =
requestHeaders[headerName] || requestHeaders[headerName.toLowerCase()];
if (headerValue) {
passthroughHeaders[headerName] = Array.isArray(headerValue)
? headerValue[0]
: String(headerValue);
}
}
}
const result = await openApiClient.callTool(cleanToolName, finalArgs, passthroughHeaders);
console.log(`OpenAPI tool invocation result: ${JSON.stringify(result)}`);
return {
@@ -1099,7 +1129,38 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
`Invoking OpenAPI tool '${cleanToolName}' on server '${serverInfo.name}' with arguments: ${JSON.stringify(request.params.arguments)}`,
);
const result = await openApiClient.callTool(cleanToolName, request.params.arguments || {});
// Extract passthrough headers from extra or request context
let passthroughHeaders: Record<string, string> | undefined;
let requestHeaders: Record<string, string | string[] | undefined> | null = null;
// Try to get headers from extra parameter first (if available)
if (extra?.headers) {
requestHeaders = extra.headers;
} else {
// Fallback to request context service
const requestContextService = RequestContextService.getInstance();
requestHeaders = requestContextService.getHeaders();
}
if (requestHeaders && serverInfo.config?.openapi?.passthroughHeaders) {
passthroughHeaders = {};
for (const headerName of serverInfo.config.openapi.passthroughHeaders) {
// Handle different header name cases (Express normalizes headers to lowercase)
const headerValue =
requestHeaders[headerName] || requestHeaders[headerName.toLowerCase()];
if (headerValue) {
passthroughHeaders[headerName] = Array.isArray(headerValue)
? headerValue[0]
: String(headerValue);
}
}
}
const result = await openApiClient.callTool(
cleanToolName,
request.params.arguments || {},
passthroughHeaders,
);
console.log(`OpenAPI tool invocation result: ${JSON.stringify(result)}`);
return {

View File

@@ -0,0 +1,105 @@
import { Request } from 'express';
/**
* Request context interface for MCP request handling
*/
export interface RequestContext {
headers: Record<string, string | string[] | undefined>;
sessionId?: string;
userAgent?: string;
remoteAddress?: string;
}
/**
* Service for managing request context during MCP request processing
* This allows MCP request handlers to access HTTP headers and other request metadata
*/
export class RequestContextService {
private static instance: RequestContextService;
private requestContext: RequestContext | null = null;
private constructor() {}
public static getInstance(): RequestContextService {
if (!RequestContextService.instance) {
RequestContextService.instance = new RequestContextService();
}
return RequestContextService.instance;
}
/**
* Set the current request context from Express request
*/
public setRequestContext(req: Request): void {
this.requestContext = {
headers: req.headers,
sessionId: (req.headers['mcp-session-id'] as string) || undefined,
userAgent: req.headers['user-agent'] as string,
remoteAddress: req.ip || req.socket?.remoteAddress,
};
}
/**
* Set request context from custom data
*/
public setCustomRequestContext(context: RequestContext): void {
this.requestContext = context;
}
/**
* Get the current request context
*/
public getRequestContext(): RequestContext | null {
return this.requestContext;
}
/**
* Get headers from the current request context
*/
public getHeaders(): Record<string, string | string[] | undefined> | null {
return this.requestContext?.headers || null;
}
/**
* Get a specific header value (case-insensitive)
*/
public getHeader(name: string): string | string[] | undefined {
if (!this.requestContext?.headers) {
return undefined;
}
// Try exact match first
if (this.requestContext.headers[name]) {
return this.requestContext.headers[name];
}
// Try lowercase match (Express normalizes headers to lowercase)
const lowerName = name.toLowerCase();
if (this.requestContext.headers[lowerName]) {
return this.requestContext.headers[lowerName];
}
// Try case-insensitive search
for (const [key, value] of Object.entries(this.requestContext.headers)) {
if (key.toLowerCase() === lowerName) {
return value;
}
}
return undefined;
}
/**
* Clear the current request context
*/
public clearRequestContext(): void {
this.requestContext = null;
}
/**
* Get session ID from current request context
*/
public getSessionId(): string | undefined {
return this.requestContext?.sessionId;
}
}

View File

@@ -8,6 +8,7 @@ import { deleteMcpServer, getMcpServer } from './mcpService.js';
import { loadSettings } from '../config/index.js';
import config from '../config/index.js';
import { UserContextService } from './userContextService.js';
import { RequestContextService } from './requestContextService.js';
const transports: { [sessionId: string]: { transport: Transport; group: string } } = {};
@@ -131,7 +132,16 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
`Received message for sessionId: ${sessionId} in group: ${group}${username ? ` for user: ${username}` : ''}`,
);
await (transport as SSEServerTransport).handlePostMessage(req, res);
// Set request context for MCP handlers to access HTTP headers
const requestContextService = RequestContextService.getInstance();
requestContextService.setRequestContext(req);
try {
await (transport as SSEServerTransport).handlePostMessage(req, res);
} finally {
// Clean up request context after handling
requestContextService.clearRequestContext();
}
};
export const handleMcpPostRequest = async (req: Request, res: Response): Promise<void> => {
@@ -202,7 +212,17 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
}
console.log(`Handling request using transport with type ${transport.constructor.name}`);
await transport.handleRequest(req, res, req.body);
// Set request context for MCP handlers to access HTTP headers
const requestContextService = RequestContextService.getInstance();
requestContextService.setRequestContext(req);
try {
await transport.handleRequest(req, res, req.body);
} finally {
// Clean up request context after handling
requestContextService.clearRequestContext();
}
};
export const handleMcpOtherRequest = async (req: Request, res: Response) => {

View File

@@ -186,6 +186,7 @@ export interface ServerConfig {
schema?: Record<string, any>; // Complete OpenAPI JSON schema
version?: string; // OpenAPI version (default: '3.1.0')
security?: OpenAPISecurityConfig; // Security configuration for API calls
passthroughHeaders?: string[]; // Header names to pass through from tool call requests to upstream OpenAPI endpoints
};
}
@@ -236,6 +237,7 @@ export interface ServerInfo {
createTime: number; // Timestamp of when the server was created
enabled?: boolean; // Flag to indicate if the server is enabled
keepAliveIntervalId?: NodeJS.Timeout; // Timer ID for keep-alive ping interval
config?: ServerConfig; // Reference to the original server configuration for OpenAPI passthrough headers
}
// Details about a tool available on the server

View File

@@ -0,0 +1,141 @@
import { RequestContextService } from '../../src/services/requestContextService.js';
import { Request } from 'express';
describe('RequestContextService', () => {
let service: RequestContextService;
beforeEach(() => {
service = RequestContextService.getInstance();
service.clearRequestContext();
});
afterEach(() => {
service.clearRequestContext();
});
it('should be a singleton', () => {
const service1 = RequestContextService.getInstance();
const service2 = RequestContextService.getInstance();
expect(service1).toBe(service2);
});
it('should set and get request context from Express request', () => {
const mockRequest = {
headers: {
authorization: 'Bearer test-token',
'x-api-key': 'test-api-key',
'user-agent': 'test-agent',
},
ip: '127.0.0.1',
connection: { remoteAddress: '127.0.0.1' },
} as unknown as Request;
service.setRequestContext(mockRequest);
const context = service.getRequestContext();
expect(context).toBeTruthy();
expect(context?.headers).toEqual(mockRequest.headers);
expect(context?.userAgent).toBe('test-agent');
expect(context?.remoteAddress).toBe('127.0.0.1');
});
it('should get specific headers case-insensitively', () => {
const mockRequest = {
headers: {
authorization: 'Bearer test-token',
'X-API-Key': 'test-api-key',
'content-type': 'application/json',
},
ip: '127.0.0.1',
connection: { remoteAddress: '127.0.0.1' },
} as unknown as Request;
service.setRequestContext(mockRequest);
// Test exact match
expect(service.getHeader('authorization')).toBe('Bearer test-token');
expect(service.getHeader('X-API-Key')).toBe('test-api-key');
// Test case-insensitive match
expect(service.getHeader('Authorization')).toBe('Bearer test-token');
expect(service.getHeader('x-api-key')).toBe('test-api-key');
expect(service.getHeader('CONTENT-TYPE')).toBe('application/json');
// Test non-existent header
expect(service.getHeader('non-existent')).toBeUndefined();
});
it('should handle array header values', () => {
const mockRequest = {
headers: {
accept: ['application/json', 'text/html'],
authorization: 'Bearer test-token',
},
ip: '127.0.0.1',
connection: { remoteAddress: '127.0.0.1' },
} as unknown as Request;
service.setRequestContext(mockRequest);
const acceptHeader = service.getHeader('accept');
expect(acceptHeader).toEqual(['application/json', 'text/html']);
const authHeader = service.getHeader('authorization');
expect(authHeader).toBe('Bearer test-token');
});
it('should extract session ID from mcp-session-id header', () => {
const mockRequest = {
headers: {
'mcp-session-id': 'test-session-123',
authorization: 'Bearer test-token',
},
ip: '127.0.0.1',
connection: { remoteAddress: '127.0.0.1' },
} as unknown as Request;
service.setRequestContext(mockRequest);
expect(service.getSessionId()).toBe('test-session-123');
});
it('should handle custom request context', () => {
const customContext = {
headers: {
'custom-header': 'custom-value',
authorization: 'Bearer custom-token',
},
sessionId: 'custom-session',
userAgent: 'custom-agent',
remoteAddress: '192.168.1.1',
};
service.setCustomRequestContext(customContext);
const context = service.getRequestContext();
expect(context).toEqual(customContext);
expect(service.getHeader('custom-header')).toBe('custom-value');
expect(service.getSessionId()).toBe('custom-session');
});
it('should return null when no context is set', () => {
expect(service.getRequestContext()).toBeNull();
expect(service.getHeaders()).toBeNull();
expect(service.getHeader('any-header')).toBeUndefined();
expect(service.getSessionId()).toBeUndefined();
});
it('should clear request context', () => {
const mockRequest = {
headers: { authorization: 'Bearer test-token' },
ip: '127.0.0.1',
connection: { remoteAddress: '127.0.0.1' },
} as unknown as Request;
service.setRequestContext(mockRequest);
expect(service.getRequestContext()).toBeTruthy();
service.clearRequestContext();
expect(service.getRequestContext()).toBeNull();
});
});