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));
});