javascript
#!/usr/bin/env node
//
// bridge.js — stdio ⇄ streamable HTTP for the alto.index MCP server.
//
// Why this exists at all: the MCPB manifest schema (v0.3 AND v0.4) only allows
// `server.type` of python | node | binary | uv, each requiring an entry_point.
// There is no "http" server type, so a thin URL descriptor cannot be packed or
// installed — `mcpb pack` rejects it outright. Claude Desktop speaks stdio to an
// extension; the app serves streamable HTTP on localhost. Something has to sit
// between them, and this is it.
//
// It replaces the 1.0.0 bridge, which POSTed to `/` and knew nothing about
// sessions. The current server is MCPKit at `/mcp` and hands out an
// `Mcp-Session-Id` on initialize that every later request must carry.
//
// No dependencies, no build step: node ships with Claude Desktop.
//
// Env: ALTOINDEX_PORT (default 8743) — set from the extension's user_config.
const http = require("http");
const PORT = Number(process.env.ALTOINDEX_PORT || 8743);
const HOST = "127.0.0.1";
const PATH = "/mcp";
// Handed to us by the server on initialize; every subsequent request repeats it.
let sessionId = null;
function post(body) {
return new Promise((resolve, reject) => {
const payload = Buffer.from(body, "utf8");
const headers = {
"Content-Type": "application/json",
// The server may answer either way; accept both so it can choose.
Accept: "application/json, text/event-stream",
"Content-Length": payload.length,
};
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
const req = http.request(
{ hostname: HOST, port: PORT, path: PATH, method: "POST", headers },
(res) => {
// Capture the session the moment it is issued.
const issued = res.headers["mcp-session-id"];
if (issued) sessionId = issued;
let data = "";
res.setEncoding("utf8");
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode, body: data }));
}
);
req.on("error", reject);
req.write(payload);
req.end();
});
}
/// A streamable-HTTP server may reply as SSE. Claude Desktop expects one JSON
/// object per line on stdout, so unwrap the data frames.
function unwrap(body) {
const trimmed = body.trim();
if (!trimmed.startsWith("event:") && !trimmed.startsWith("data:")) return trimmed;
return trimmed
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim())
.filter(Boolean)
.join("\n");
}
function send(object) {
process.stdout.write(JSON.stringify(object) + "\n");
}
function sendError(id, code, message) {
// A notification has no id and must never be answered.
if (id === undefined || id === null) return;
send({ jsonrpc: "2.0", id, error: { code, message } });
}
async function handle(line) {
let request;
try {
request = JSON.parse(line);
} catch {
return sendError(null, -32700, "Parse error");
}
try {
const { status, body } = await post(line);
if (status >= 400) {
return sendError(
request.id,
-32603,
`alto.index returned ${status}. Is the app running with its MCP server started?`
);
}
const payload = unwrap(body);
// A notification legitimately gets an empty 202 back — say nothing.
if (!payload) return;
process.stdout.write(payload + "\n");
} catch (error) {
sendError(
request.id,
-32603,
`Cannot reach alto.index on ${HOST}:${PORT}${PATH} (${error.code || error.message}). ` +
`Open alto.index and start the MCP server in Integrations.`
);
}
}
// Line-delimited JSON-RPC in, line-delimited JSON-RPC out. Requests are
// serialized so a session id issued by initialize is set before anything uses it.
let buffer = "";
let chain = Promise.resolve();
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
buffer += chunk;
let index;
while ((index = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, index).trim();
buffer = buffer.slice(index + 1);
if (line) chain = chain.then(() => handle(line));
}
});
// Drain before exiting: `process.exit` here would kill requests still in
// flight and Claude Desktop would see a silent, truncated session.
process.stdin.on("end", () => {
chain.then(() => process.exit(0));
});