http-mcp-bridge.js•30.4 kB
#!/usr/bin/env node
/**
* HTTP-to-MCP Bridge for MCP Memory Service
*
* This bridge allows MCP clients (like Claude Desktop) to connect to a remote
* MCP Memory Service HTTP server instead of running a local instance.
*
* Features:
* - Automatic service discovery via mDNS (Bonjour/Zeroconf)
* - Manual endpoint configuration fallback
* - HTTPS support with self-signed certificate handling
* - API key authentication
*
* Usage in Claude Desktop config:
*
* Option 1: Auto-discovery (recommended for local networks)
* {
* "mcpServers": {
* "memory": {
* "command": "node",
* "args": ["/path/to/http-mcp-bridge.js"],
* "env": {
* "MCP_MEMORY_AUTO_DISCOVER": "true",
* "MCP_MEMORY_PREFER_HTTPS": "true",
* "MCP_MEMORY_API_KEY": "your-api-key"
* }
* }
* }
* }
*
* Option 2: Manual configuration
* {
* "mcpServers": {
* "memory": {
* "command": "node",
* "args": ["/path/to/http-mcp-bridge.js"],
* "env": {
* "MCP_MEMORY_HTTP_ENDPOINT": "https://your-server:8000/api",
* "MCP_MEMORY_API_KEY": "your-api-key"
* }
* }
* }
* }
*/
const http = require('http');
const https = require('https');
const { URL } = require('url');
const dgram = require('dgram');
const dns = require('dns');
const tls = require('tls');
/**
* Simple mDNS service discovery implementation
*/
class MDNSDiscovery {
constructor() {
this.services = new Map();
}
/**
* Discover MCP Memory Services using mDNS
*/
async discoverServices(timeout = 5000) {
return new Promise((resolve) => {
const socket = dgram.createSocket('udp4');
const services = [];
// mDNS query for _mcp-memory._tcp.local
const query = this.createMDNSQuery('_mcp-memory._tcp.local');
socket.on('message', (msg, rinfo) => {
try {
const service = this.parseMDNSResponse(msg, rinfo);
if (service) {
services.push(service);
}
} catch (error) {
// Ignore parsing errors
}
});
socket.bind(() => {
socket.addMembership('224.0.0.251');
socket.send(query, 5353, '224.0.0.251');
});
setTimeout(() => {
socket.close();
resolve(services);
}, timeout);
});
}
createMDNSQuery(serviceName) {
// Simplified mDNS query creation
// This is a basic implementation - in production, use a proper mDNS library
const header = Buffer.alloc(12);
header.writeUInt16BE(0, 0); // Transaction ID
header.writeUInt16BE(0, 2); // Flags
header.writeUInt16BE(1, 4); // Questions
header.writeUInt16BE(0, 6); // Answer RRs
header.writeUInt16BE(0, 8); // Authority RRs
header.writeUInt16BE(0, 10); // Additional RRs
// Question section (simplified)
const nameLabels = serviceName.split('.');
let nameBuffer = Buffer.alloc(0);
for (const label of nameLabels) {
if (label) {
const labelBuffer = Buffer.alloc(1 + label.length);
labelBuffer.writeUInt8(label.length, 0);
labelBuffer.write(label, 1);
nameBuffer = Buffer.concat([nameBuffer, labelBuffer]);
}
}
const endBuffer = Buffer.alloc(5);
endBuffer.writeUInt8(0, 0); // End of name
endBuffer.writeUInt16BE(12, 1); // Type PTR
endBuffer.writeUInt16BE(1, 3); // Class IN
return Buffer.concat([header, nameBuffer, endBuffer]);
}
parseMDNSResponse(msg, rinfo) {
// Simplified mDNS response parsing
// This is a basic implementation - in production, use a proper mDNS library
try {
// Look for MCP Memory Service indicators in the response
const msgStr = msg.toString('ascii', 0, Math.min(msg.length, 512));
if (msgStr.includes('mcp-memory') || msgStr.includes('MCP Memory')) {
// Try common ports for the service
const possiblePorts = [8000, 8080, 443, 80];
const host = rinfo.address;
for (const port of possiblePorts) {
return {
name: 'MCP Memory Service',
host: host,
port: port,
https: port === 443,
discovered: true
};
}
}
} catch (error) {
// Ignore parsing errors
}
return null;
}
}
class HTTPMCPBridge {
constructor() {
this.endpoint = process.env.MCP_MEMORY_HTTP_ENDPOINT;
this.apiKey = process.env.MCP_MEMORY_API_KEY;
this.autoDiscover = process.env.MCP_MEMORY_AUTO_DISCOVER === 'true';
this.preferHttps = process.env.MCP_MEMORY_PREFER_HTTPS !== 'false';
this.requestId = 0;
this.discovery = new MDNSDiscovery();
this.discoveredEndpoint = null;
}
/**
* Initialize the bridge by discovering or configuring the endpoint
*/
async initialize() {
if (this.endpoint) {
// Manual configuration takes precedence
console.error(`Using manual endpoint: ${this.endpoint}`);
return true;
}
if (this.autoDiscover) {
console.error('Attempting to discover MCP Memory Service via mDNS...');
try {
const services = await this.discovery.discoverServices();
if (services.length > 0) {
// Sort services by preference (HTTPS first if preferred)
services.sort((a, b) => {
if (this.preferHttps) {
if (a.https !== b.https) return b.https - a.https;
}
return a.port - b.port; // Prefer standard ports
});
const service = services[0];
const protocol = service.https ? 'https' : 'http';
this.discoveredEndpoint = `${protocol}://${service.host}:${service.port}/api`;
this.endpoint = this.discoveredEndpoint;
console.error(`Discovered service: ${this.endpoint}`);
// Test the discovered endpoint
const healthy = await this.testEndpoint(this.endpoint);
if (!healthy) {
console.error('Discovered endpoint failed health check, trying alternatives...');
// Try other discovered services
for (let i = 1; i < services.length; i++) {
const altService = services[i];
const altProtocol = altService.https ? 'https' : 'http';
const altEndpoint = `${altProtocol}://${altService.host}:${altService.port}/api`;
if (await this.testEndpoint(altEndpoint)) {
this.endpoint = altEndpoint;
console.error(`Using alternative endpoint: ${this.endpoint}`);
return true;
}
}
console.error('No healthy services found');
return false;
}
return true;
} else {
console.error('No MCP Memory Services discovered');
return false;
}
} catch (error) {
console.error(`Discovery failed: ${error.message}`);
return false;
}
}
// Default fallback
this.endpoint = 'http://localhost:8000/api';
console.error(`Using default endpoint: ${this.endpoint}`);
return true;
}
/**
* Test if an endpoint is healthy
*/
async testEndpoint(endpoint) {
try {
const healthUrl = `${endpoint}/api/health`;
const response = await this.makeRequestInternal(healthUrl, 'GET', null, 3000); // 3 second timeout
return response.statusCode === 200;
} catch (error) {
return false;
}
}
/**
* Make HTTP request to the MCP Memory Service with retry logic
*/
async makeRequest(path, method = 'GET', data = null, maxRetries = 3) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.error(`Attempt ${attempt}/${maxRetries} for ${method} ${path}`);
const result = await this.makeRequestInternal(path, method, data);
if (attempt > 1) {
console.error(`Request succeeded on attempt ${attempt}`);
}
return result;
} catch (error) {
lastError = error;
console.error(`Attempt ${attempt} failed: ${error.message}`);
if (attempt < maxRetries) {
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000); // Exponential backoff, max 5s
console.error(`Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
} else {
console.error(`All ${maxRetries} attempts failed. Last error: ${error.message}`);
}
}
}
throw lastError;
}
/**
* Internal HTTP request method with timeout support and comprehensive logging
*/
async makeRequestInternal(path, method = 'GET', data = null, timeout = 10000) {
const startTime = Date.now();
const requestId = Math.random().toString(36).substr(2, 9);
console.error(`[${requestId}] Starting ${method} request to ${path}`);
return new Promise((resolve, reject) => {
// Use URL constructor's built-in path resolution to avoid duplicate base paths
// Ensure endpoint has trailing slash for proper relative path resolution
const baseUrl = this.endpoint.endsWith('/') ? this.endpoint : this.endpoint + '/';
const url = new URL(path, baseUrl);
const protocol = url.protocol === 'https:' ? https : http;
console.error(`[${requestId}] Full URL: ${url.toString()}`);
console.error(`[${requestId}] Using protocol: ${url.protocol}`);
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method: method,
headers: {
'Content-Type': 'application/json',
'User-Agent': 'MCP-HTTP-Bridge/2.0',
'Connection': 'close'
},
timeout: timeout,
keepAlive: false
};
// For HTTPS, create custom agent for self-signed certificates with TLS 1.3
if (url.protocol === 'https:') {
const agent = new https.Agent({
rejectUnauthorized: false,
requestCert: false,
checkServerIdentity: () => undefined,
keepAlive: false
});
options.agent = agent;
console.error(`[${requestId}] Using custom HTTPS agent with default TLS settings`);
}
if (this.apiKey) {
options.headers['Authorization'] = `Bearer ${this.apiKey}`;
console.error(`[${requestId}] API key added to headers`);
}
if (data) {
const postData = JSON.stringify(data);
options.headers['Content-Length'] = Buffer.byteLength(postData);
console.error(`[${requestId}] Request body size: ${Buffer.byteLength(postData)} bytes`);
}
console.error(`[${requestId}] Request options:`, JSON.stringify(options, null, 2));
const req = protocol.request(options, (res) => {
const responseStartTime = Date.now();
console.error(`[${requestId}] Response received after ${responseStartTime - startTime}ms`);
console.error(`[${requestId}] Status code: ${res.statusCode}`);
console.error(`[${requestId}] Response headers:`, JSON.stringify(res.headers, null, 2));
let responseData = '';
res.on('data', (chunk) => {
responseData += chunk;
console.error(`[${requestId}] Received ${chunk.length} bytes`);
});
res.on('end', () => {
const endTime = Date.now();
console.error(`[${requestId}] Response completed after ${endTime - startTime}ms total`);
console.error(`[${requestId}] Response body: ${responseData}`);
try {
const result = JSON.parse(responseData);
resolve({ statusCode: res.statusCode, data: result });
} catch (error) {
console.error(`[${requestId}] JSON parse error: ${error.message}`);
reject(new Error(`Invalid JSON response: ${responseData}`));
}
});
});
req.on('error', (error) => {
const errorTime = Date.now();
console.error(`[${requestId}] Request error after ${errorTime - startTime}ms: ${error.message}`);
console.error(`[${requestId}] Error details:`, error);
reject(error);
});
req.on('timeout', () => {
const timeoutTime = Date.now();
console.error(`[${requestId}] Request timeout after ${timeoutTime - startTime}ms (limit: ${timeout}ms)`);
req.destroy();
reject(new Error(`Request timeout after ${timeout}ms`));
});
console.error(`[${requestId}] Sending request...`);
if (data) {
const postData = JSON.stringify(data);
console.error(`[${requestId}] Writing request body: ${postData}`);
req.write(postData);
}
req.end();
console.error(`[${requestId}] Request sent, waiting for response...`);
});
}
/**
* Handle MCP store_memory operation
*/
async storeMemory(params) {
try {
const response = await this.makeRequest('memories', 'POST', {
content: params.content,
tags: params.metadata?.tags || [],
memory_type: params.metadata?.type || 'note',
metadata: params.metadata || {}
});
if (response.statusCode === 200 || response.statusCode === 201) {
// Server returns 200 with success field indicating actual result
if (response.data.success) {
return { success: true, message: response.data.message || 'Memory stored successfully' };
} else {
return { success: false, message: response.data.message || response.data.detail || 'Failed to store memory' };
}
} else {
return { success: false, message: response.data.detail || 'Failed to store memory' };
}
} catch (error) {
return { success: false, message: error.message };
}
}
/**
* Handle MCP retrieve_memory operation
*/
async retrieveMemory(params) {
try {
const queryParams = new URLSearchParams({
q: params.query,
n_results: params.n_results || 5
});
const response = await this.makeRequest(`search?${queryParams}`, 'GET');
if (response.statusCode === 200) {
return {
memories: response.data.results.map(result => ({
content: result.memory.content,
metadata: {
tags: result.memory.tags,
type: result.memory.memory_type,
created_at: result.memory.created_at_iso,
relevance_score: result.relevance_score
}
}))
};
} else {
return { memories: [] };
}
} catch (error) {
return { memories: [] };
}
}
/**
* Handle MCP search_by_tag operation
*/
async searchByTag(params) {
try {
const queryParams = new URLSearchParams();
if (Array.isArray(params.tags)) {
params.tags.forEach(tag => queryParams.append('tags', tag));
} else if (typeof params.tags === 'string') {
queryParams.append('tags', params.tags);
}
const response = await this.makeRequest(`memories/search/tags?${queryParams}`, 'GET');
if (response.statusCode === 200) {
return {
memories: response.data.memories.map(memory => ({
content: memory.content,
metadata: {
tags: memory.tags,
type: memory.memory_type,
created_at: memory.created_at_iso
}
}))
};
} else {
return { memories: [] };
}
} catch (error) {
return { memories: [] };
}
}
/**
* Handle MCP delete_memory operation
*/
async deleteMemory(params) {
try {
const response = await this.makeRequest(`memories/${params.content_hash}`, 'DELETE');
if (response.statusCode === 200) {
return { success: true, message: 'Memory deleted successfully' };
} else {
return { success: false, message: response.data.detail || 'Failed to delete memory' };
}
} catch (error) {
return { success: false, message: error.message };
}
}
/**
* Handle MCP check_database_health operation
*/
async checkHealth(params = {}) {
try {
const response = await this.makeRequest('health', 'GET');
if (response.statusCode === 200) {
return {
status: response.data.status,
backend: response.data.storage_type,
statistics: response.data.statistics || {}
};
} else {
return { status: 'unhealthy', backend: 'unknown', statistics: {} };
}
} catch (error) {
// Handle errors that may not have a message property (like ECONNREFUSED)
const errorMessage = error.message || error.code || error.toString() || 'Unknown error';
return { status: 'error', backend: 'unknown', statistics: {}, error: errorMessage };
}
}
/**
* Process MCP JSON-RPC request
*/
async processRequest(request) {
const { method, params, id } = request;
let result;
try {
switch (method) {
case 'initialize':
result = {
protocolVersion: "2024-11-05",
capabilities: {
tools: {
listChanged: false
}
},
serverInfo: {
name: "mcp-memory-service",
version: "2.0.0"
}
};
break;
case 'notifications/initialized':
// No response needed for notifications
return null;
case 'tools/list':
result = {
tools: [
{
name: "store_memory",
description: "Store a memory with content and optional metadata",
inputSchema: {
type: "object",
properties: {
content: { type: "string", description: "The content to store" },
metadata: {
type: "object",
properties: {
tags: { type: "array", items: { type: "string" } },
type: { type: "string" }
}
}
},
required: ["content"]
}
},
{
name: "retrieve_memory",
description: "Retrieve memories based on a query",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Search query" },
n_results: { type: "integer", description: "Number of results to return" }
},
required: ["query"]
}
},
{
name: "search_by_tag",
description: "Search memories by tags",
inputSchema: {
type: "object",
properties: {
tags: {
oneOf: [
{ type: "string" },
{ type: "array", items: { type: "string" } }
]
}
},
required: ["tags"]
}
},
{
name: "delete_memory",
description: "Delete a memory by content hash",
inputSchema: {
type: "object",
properties: {
content_hash: { type: "string", description: "Hash of the content to delete" }
},
required: ["content_hash"]
}
},
{
name: "check_database_health",
description: "Check the health of the memory database",
inputSchema: {
type: "object",
properties: {}
}
}
]
};
break;
case 'tools/call':
const toolName = params.name;
const toolParams = params.arguments || {};
console.error(`Processing tool call: ${toolName} with params:`, JSON.stringify(toolParams));
let toolResult;
switch (toolName) {
case 'store_memory':
toolResult = await this.storeMemory(toolParams);
break;
case 'retrieve_memory':
toolResult = await this.retrieveMemory(toolParams);
break;
case 'search_by_tag':
toolResult = await this.searchByTag(toolParams);
break;
case 'delete_memory':
toolResult = await this.deleteMemory(toolParams);
break;
case 'check_database_health':
toolResult = await this.checkHealth(toolParams);
break;
default:
throw new Error(`Unknown tool: ${toolName}`);
}
console.error(`Tool result:`, JSON.stringify(toolResult));
return {
jsonrpc: "2.0",
id: id,
result: {
content: [
{
type: "text",
text: JSON.stringify(toolResult, null, 2)
}
]
}
};
case 'store_memory':
result = await this.storeMemory(params);
break;
case 'retrieve_memory':
result = await this.retrieveMemory(params);
break;
case 'search_by_tag':
result = await this.searchByTag(params);
break;
case 'delete_memory':
result = await this.deleteMemory(params);
break;
case 'check_database_health':
result = await this.checkHealth(params);
break;
default:
throw new Error(`Unknown method: ${method}`);
}
return {
jsonrpc: "2.0",
id: id,
result: result
};
} catch (error) {
return {
jsonrpc: "2.0",
id: id,
error: {
code: -32000,
message: error.message
}
};
}
}
/**
* Start the bridge server
*/
async start() {
console.error(`MCP HTTP Bridge starting...`);
// Initialize the bridge (discovery or manual config)
const initialized = await this.initialize();
if (!initialized) {
console.error('Failed to initialize bridge - no endpoint available');
process.exit(1);
}
console.error(`Endpoint: ${this.endpoint}`);
console.error(`API Key: ${this.apiKey ? '[SET]' : '[NOT SET]'}`);
console.error(`Auto-discovery: ${this.autoDiscover ? 'ENABLED' : 'DISABLED'}`);
console.error(`Prefer HTTPS: ${this.preferHttps ? 'YES' : 'NO'}`);
if (this.discoveredEndpoint) {
console.error(`Service discovered automatically via mDNS`);
}
let buffer = '';
process.stdin.on('data', async (chunk) => {
buffer += chunk.toString();
// Process complete JSON-RPC messages
let newlineIndex;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line) {
try {
const request = JSON.parse(line);
const response = await this.processRequest(request);
console.log(JSON.stringify(response));
} catch (error) {
console.error(`Error processing request: ${error.message}`);
console.log(JSON.stringify({
jsonrpc: "2.0",
id: null,
error: {
code: -32700,
message: "Parse error"
}
}));
}
}
}
});
process.stdin.on('end', () => {
process.exit(0);
});
// Handle graceful shutdown
process.on('SIGINT', () => {
console.error('Shutting down HTTP Bridge...');
process.exit(0);
});
process.on('SIGTERM', () => {
console.error('Shutting down HTTP Bridge...');
process.exit(0);
});
}
}
// Start the bridge if this file is run directly
if (require.main === module) {
const bridge = new HTTPMCPBridge();
bridge.start().catch(error => {
console.error(`Failed to start bridge: ${error.message}`);
process.exit(1);
});
}
module.exports = HTTPMCPBridge;