Skip to content

Instantly share code, notes, and snippets.

@ochafik
Last active October 10, 2025 14:18
Show Gist options
  • Select an option

  • Save ochafik/3ba7eb0e5af9d250a77efbb2c948039b to your computer and use it in GitHub Desktop.

Select an option

Save ochafik/3ba7eb0e5af9d250a77efbb2c948039b to your computer and use it in GitHub Desktop.
http-as-stdio
# With inspector:
npx -y @modelcontextprotocol/inspector \
  docker -- run --rm -i node:latest \
    npx -y https://gist.github.com/ochafik/3ba7eb0e5af9d250a77efbb2c948039b \
      npx -y --silent @modelcontextprotocol/server-everything streamableHttp
      
# Or CC:
claude mcp add my-remote -- \
  docker run --rm -i node:latest \
    npx -y https://gist.github.com/ochafik/3ba7eb0e5af9d250a77efbb2c948039b \
      npx -y --silent @modelcontextprotocol/server-everything streamableHttp
/**
* HTTP to stdio converter for MCP protocol
*
* This converter allows an MCP server running over stdio to be accessed via HTTP.
* It acts as a bridge between stdio transport (parent process) and HTTP transport (server).
*/
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { CancelledNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
// Check if a message is a cancelled notification
const isCancelledNotification = (value: any): boolean =>
CancelledNotificationSchema.safeParse(value).success;
type NamedTransport<T extends Transport = Transport> = {
name: string,
transport: T,
}
// Bidirectionally propagates onclose & onmessage events between two transports
function proxyTransports(client: NamedTransport<StreamableHTTPClientTransport>, server: NamedTransport<StdioServerTransport>, cleanup: () => Promise<void>): void {
let closed = false;
const propagateClose = (source: NamedTransport, target: NamedTransport) => {
source.transport.onclose = async () => {
console.info(`[http-as-stdio]: Transport closed: source=${source.name}, target=${target.name}`);
if (!closed) {
closed = true;
try {
target.transport.close();
// Clean up before exiting
console.info(`[http-as-stdio]: Exiting due to ${source.name} closure`);
await cleanup();
} catch (error) {
console.error(`[http-as-stdio]: Error during cleanup after ${source.name} close:`, error);
}
process.exit(0);
}
};
};
propagateClose(server, client);
propagateClose(client, server);
const propagateMessage = (source: NamedTransport, target: NamedTransport) => {
source.transport.onmessage = (message: any) => {
console.info(`[http-as-stdio]: Message from ${source.name}: ${JSON.stringify(message)}`);
if (!closed) {
try {
const relatedRequestId = isCancelledNotification(message) ? message.params.requestId : undefined;
target.transport.send(message, {relatedRequestId});
} catch (error) {
console.error(`[http-as-stdio]: Error sending message to ${target.name}: ${error}`);
if (!closed) {
closed = true;
source.transport.close();
target.transport.close();
}
}
}
};
};
propagateMessage(server, client);
propagateMessage(client, server);
server.transport.start();
client.transport.start();
}
// Parse command line arguments
const args = process.argv.slice(2);
if (args.length === 0) {
console.error('[http-as-stdio]: Usage: http-as-stdio <server-url>');
console.error('[http-as-stdio]: Example: http-as-stdio http://elicitation-proxy:3000/mcp');
process.exit(1);
}
const serverUrl = args[0]!; // We already checked args.length
async function main() {
let cleanupInProgress = false;
console.info(`[http-as-stdio]: Starting stdio to HTTP bridge for ${serverUrl}`);
// Create stdio transport for communication with parent process
const stdioTransport = new StdioServerTransport();
// let retries = 0;
// let delay = initialDelay;
// while (retries < maxRetries) {
// try {
// console.error(`Attempting to connect to ${url} (attempt ${retries + 1}/${maxRetries})...`);
// // Try a simple HTTP request to check if the server is ready
// const response = await fetch(url, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify({
// jsonrpc: '2.0',
// method: 'initialize',
// params: {
// protocolVersion: '1.0.0',
// capabilities: {},
// clientInfo: {
// name: 'http-as-stdio',
// version: '1.0.0'
// }
// },
// id: 1
// })
// });
// // If we get any response (even an error), the server is at least running
// if (response.status < 500) {
// console.error(`Server is ready (status: ${response.status})`);
// return;
// }
// throw new Error(`Server returned ${response.status}`);
// } catch (error) {
// retries++;
// if (retries >= maxRetries) {
// throw new Error(`Failed to connect to ${url} after ${maxRetries} attempts: ${error}`);
// }
// console.error(`Connection failed, retrying in ${delay}ms...`);
// await new Promise(resolve => setTimeout(resolve, delay));
// // Exponential backoff with max delay of 10 seconds
// delay = Math.min(delay * 1.5, 10000);
// }
// }
// console.error(`Server is ready, establishing bridge...`);
// Create HTTP client transport for communication with the server
const httpTransport = new StreamableHTTPClientTransport(new URL(serverUrl));
// Handle graceful shutdown
const cleanup = async () => {
if (cleanupInProgress) {
console.warn('[http-as-stdio]: Cleanup already in progress...');
return;
}
cleanupInProgress = true;
console.info('[http-as-stdio]: Shutting down...');
// Kill any child processes in the same process group
try {
// Send SIGTERM to all processes in our process group
process.kill(-process.pid, 'SIGTERM');
// Give processes time to exit gracefully
await new Promise(resolve => setTimeout(resolve, 1000));
// Force kill if still running
try {
process.kill(-process.pid, 'SIGKILL');
} catch (e) {
// Ignore errors - processes may have already exited
}
} catch (error: any) {
if (error.code !== 'ESRCH') { // ESRCH = no such process
console.error('[http-as-stdio]: Error killing process group:', error);
}
}
try {
await Promise.all([
stdioTransport.close(),
httpTransport.close(),
]);
} catch (error) {
console.error('[http-as-stdio]: Error during cleanup:', error);
}
// Give a bit more time for cleanup
await new Promise(resolve => setTimeout(resolve, 100));
process.exit(0);
};
const client = {name: 'http', transport: httpTransport};
const server = {name: 'stdio', transport: stdioTransport};
const addErrorHandler = (transport: NamedTransport) => {
transport.transport.onerror = async (error: Error) => {
console.error(`[http-as-stdio]: Transport error on (${transport.name})`);
console.error(error);
await cleanup();
process.exit(1);
};
};
addErrorHandler(client);
addErrorHandler(server);
proxyTransports(client, server, cleanup);
console.info('[http-as-stdio]: Bridge established successfully');
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
process.on('SIGHUP', cleanup);
// Handle stdin closure (client disconnect)
process.stdin.on('end', () => {
console.info('[http-as-stdio]: stdin closed, client disconnected');
cleanup();
});
process.stdin.on('error', (error) => {
console.error('[http-as-stdio]: stdin error:', error);
cleanup();
});
// Keep the process alive
// process.stdin.resume();
}
// Run the converter
main().catch((error) => {
console.error(error);
console.error('[http-as-stdio]: Failed to start HTTP to stdio bridge:', error);
process.exit(1);
});
{
"name": "http-as-stdio",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"type": "module",
"bin": "dist/http-as-stdio.js",
"scripts": {
"prepare": "npm run build",
"start": "bun run http-as-stdio.ts",
"build": "bun build http-as-stdio.ts --outdir=dist --banner '#!/usr/bin/env node' --target=node --minify && shx chmod +x dist/http-as-stdio.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.19.1",
"zod-from-json-schema": "^0.5.0"
},
"devDependencies": {
"bun": "^1.2.23",
"shx": "^0.4.0"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment