@h1deya/langchain-mcp-tools
Version:
MCP To LangChain Tools Conversion Utility
249 lines (248 loc) • 11.2 kB
JavaScript
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport, } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
/**
* Creates Streamable HTTP transport options from configuration.
* Consolidates repeated option configuration logic into a single reusable function.
*
* @param config - URL-based server configuration
* @param logger - Logger instance for recording authentication setup
* @param serverName - Server name for logging context
* @returns Configured StreamableHTTPClientTransportOptions or undefined if no options needed
*
* @internal This function is meant to be used internally by transport creation functions
*/
function createStreamableHttpOptions(config, logger, serverName) {
const options = {};
if (config.streamableHTTPOptions) {
if (config.streamableHTTPOptions.authProvider) {
options.authProvider = config.streamableHTTPOptions.authProvider;
logger.info(`MCP server "${serverName}": configuring Streamable HTTP with authentication provider`);
}
if (config.streamableHTTPOptions.requestInit) {
options.requestInit = config.streamableHTTPOptions.requestInit;
}
if (config.streamableHTTPOptions.reconnectionOptions) {
options.reconnectionOptions = config.streamableHTTPOptions.reconnectionOptions;
}
if (config.streamableHTTPOptions.sessionId) {
options.sessionId = config.streamableHTTPOptions.sessionId;
}
}
else if (config.headers) {
options.requestInit = { headers: config.headers };
}
return Object.keys(options).length > 0 ? options : undefined;
}
/**
* Creates SSE transport options from configuration.
* Consolidates repeated option configuration logic into a single reusable function.
*
* @param config - URL-based server configuration
* @param logger - Logger instance for recording authentication setup
* @param serverName - Server name for logging context
* @returns Configured SSEClientTransportOptions or undefined if no options needed
*
* @internal This function is meant to be used internally by transport creation functions
*/
function createSseOptions(config, logger, serverName) {
const options = {};
if (config.sseOptions) {
if (config.sseOptions.authProvider) {
options.authProvider = config.sseOptions.authProvider;
logger.info(`MCP server "${serverName}": configuring SSE with authentication provider`);
}
if (config.sseOptions.eventSourceInit) {
options.eventSourceInit = config.sseOptions.eventSourceInit;
}
if (config.sseOptions.requestInit) {
options.requestInit = config.sseOptions.requestInit;
}
else if (config.headers) {
options.requestInit = { headers: config.headers };
}
}
return Object.keys(options).length > 0 ? options : undefined;
}
/**
* Determines if an error represents a 4xx HTTP status code.
* Used to decide whether to fall back from Streamable HTTP to SSE transport.
*
* @param error - The error to check
* @returns true if the error represents a 4xx HTTP status
*
* @internal This function is meant to be used internally by createHttpTransportWithFallback
*/
function is4xxError(error) {
if (!error || typeof error !== "object") {
return false;
}
// Record<string, unknown> is the TypeScript-approved way to handle
// "object but we don't know its shape" scenarios.
const errorObj = error;
// Check if it's a fetch Response error with status
if (typeof errorObj.status === "number") {
return errorObj.status >= 400 && errorObj.status < 500;
}
// Check if it's wrapped in a Response object
if (errorObj.response &&
typeof errorObj.response === "object" &&
errorObj.response !== null) {
const response = errorObj.response;
if (typeof response.status === "number") {
return response.status >= 400 && response.status < 500;
}
}
// Check for error messages that typically indicate 4xx errors
const message = errorObj.message || errorObj.toString();
if (typeof message === "string") {
return /4[0-9]{2}/.test(message) ||
message.includes("Bad Request") ||
message.includes("Unauthorized") ||
message.includes("Forbidden") ||
message.includes("Not Found") ||
message.includes("Method Not Allowed");
}
return false;
}
/**
* Tests MCP server transport support using direct POST InitializeRequest.
* Follows the official MCP specification's recommended approach for backwards compatibility.
*
* See: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility
*
* @param url - The URL to test
* @param config - URL-based server configuration
* @param logger - Logger instance for recording test attempts
* @param serverName - Server name for logging context
* @returns A promise that resolves to the detected transport type
*
* @internal This function is meant to be used internally by createHttpTransportWithFallback
*/
async function testTransportSupport(url, config, logger, serverName) {
logger.debug(`MCP server "${serverName}": testing Streamable HTTP support`);
// Create InitializeRequest as per MCP specification
const initRequest = {
jsonrpc: "2.0",
id: `transport-test-${Date.now()}`,
method: "initialize",
params: {
protocolVersion: "2024-11-05", // MCP Protocol version specified by the MCP specification for transport detection
capabilities: {},
clientInfo: {
name: "mcp-transport-test",
version: "1.0.0"
}
}
};
// Prepare headers as required by MCP spec
const headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream" // Required by spec
};
// Add authentication headers if available
if (config.streamableHTTPOptions?.authProvider) {
try {
const tokens = await config.streamableHTTPOptions.authProvider.tokens();
if (tokens?.access_token) {
headers["Authorization"] = `${tokens.token_type || "Bearer"} ${tokens.access_token}`;
logger.debug(`MCP server "${serverName}": added authentication to transport test`);
}
}
catch (authError) {
logger.debug(`MCP server "${serverName}": authentication setup failed for transport test:`, authError);
}
}
// Merge custom headers from config
if (config.streamableHTTPOptions?.requestInit?.headers) {
Object.assign(headers, config.streamableHTTPOptions.requestInit.headers);
}
if (config.sseOptions?.requestInit?.headers) {
Object.assign(headers, config.sseOptions.requestInit.headers);
}
if (config.headers) {
Object.assign(headers, config.headers);
}
try {
logger.debug(`MCP server "${serverName}": POST InitializeRequest to test Streamable HTTP support`);
// POST InitializeRequest directly to test Streamable HTTP support
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify(initRequest),
...config.streamableHTTPOptions?.requestInit
});
logger.debug(`MCP server "${serverName}": transport test response: ${response.status} ${response.statusText}`);
if (response.ok) {
// Success indicates Streamable HTTP support
logger.info(`MCP server "${serverName}": detected Streamable HTTP transport support`);
return "streamable_http";
}
else if (response.status >= 400 && response.status < 500) {
// 4xx error indicates fallback to SSE per MCP spec
logger.info(`MCP server "${serverName}": Streamable HTTP test received ${response.status}, falling back to SSE transport`);
return "sse";
}
else {
// Other errors should be re-thrown
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
}
catch (error) {
// Network errors or other issues
logger.debug(`MCP server "${serverName}": transport test failed:`, error);
// Check if it's a 4xx-like error
if (is4xxError(error)) {
logger.info(`MCP server "${serverName}": transport test failed with 4xx-like error, falling back to SSE`);
return "sse";
}
// Re-throw other errors (network issues, etc.)
throw error;
}
}
/**
* Creates an HTTP transport with automatic fallback from Streamable HTTP to SSE.
* Follows the MCP specification recommendation to test with direct POST InitializeRequest first,
* then fall back to SSE if a 4xx error is encountered.
*
* See: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility
*
* @param url - The URL to connect to
* @param config - URL-based server configuration
* @param logger - Logger instance for recording connection attempts
* @param serverName - Server name for logging context
* @returns A promise that resolves to a configured Transport
*
* @internal This function is meant to be used internally by convertSingleMcpToLangchainTools
*/
export async function createHttpTransportWithFallback(url, config, logger, serverName) {
const transportType = config.transport || config.type;
// If transport is explicitly specified, respect user's choice
if (transportType === "streamable_http" || transportType === "http") {
logger.debug(`MCP server "${serverName}": using explicitly configured Streamable HTTP transport`);
const options = createStreamableHttpOptions(config, logger, serverName);
return new StreamableHTTPClientTransport(url, options);
}
if (transportType === "sse") {
logger.debug(`MCP server "${serverName}": using explicitly configured SSE transport`);
const options = createSseOptions(config, logger, serverName);
return new SSEClientTransport(url, options);
}
// Auto-detection: test with POST InitializeRequest per MCP specification
logger.debug(`MCP server "${serverName}": auto-detecting transport using MCP specification method`);
try {
const detectedTransport = await testTransportSupport(url, config, logger, serverName);
if (detectedTransport === "streamable_http") {
const options = createStreamableHttpOptions(config, logger, serverName);
return new StreamableHTTPClientTransport(url, options);
}
else {
const options = createSseOptions(config, logger, serverName);
return new SSEClientTransport(url, options);
}
}
catch (error) {
// If transport detection fails completely, log error and re-throw
logger.error(`MCP server "${serverName}": transport detection failed:`, error);
throw error;
}
}