Horizon

Expose a local MCP server

Expose a local MCP server over Streamable HTTP with a Horizon tunnel, and connect Claude or the MCP Inspector to it.

Serve a remote Model Context Protocol (MCP) server from your laptop at https://my-app.hrzn.run/mcp, so a client such as Claude can connect to it.

Horizon streams responses unbuffered, so Server-Sent Events (SSE) responses from your MCP server reach the client as they are written.

Before you begin

  • Node.js 18 or later
  • A Horizon account and the CLI (see Getting started)
  • A reserved subdomain for -s. Reserve one on the Subdomains page.
  • Node.js 22.19.0 or later, if you want to test with the MCP Inspector

Build the server

The official TypeScript SDK is @modelcontextprotocol/server. The Express and Node.js adapters are separate packages.

npm install @modelcontextprotocol/server @modelcontextprotocol/node @modelcontextprotocol/express express zod

createMcpHandler builds a fresh server for each request. registerTool defines a tool with a Zod input schema. createMcpExpressApp adds JSON body parsing and validates the Host and Origin headers against 127.0.0.1, localhost and ::1.

server.ts
import { createMcpExpressApp } from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import * as z from "zod/v4";

const handler = createMcpHandler(() => {
  const server = new McpServer({ name: "notes", version: "1.0.0" });
  server.registerTool(
    "add-note",
    { description: "Append a note", inputSchema: z.object({ text: z.string() }) },
    async ({ text }) => ({
      content: [{ type: "text", text: `Saved: ${text}` }],
    }),
  );
  return server;
});

const app = createMcpExpressApp();
const node = toNodeHandler(handler);
app.all("/mcp", (req, res) => void node(req, res, req.body));
app.listen(3000);

Start it on port 3000 with a TypeScript runner such as tsx.

npx tsx server.ts

Start a tunnel

Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so your client would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.

hrzn tunnel http://localhost:3000 -s my-app
Output
HORIZON: Tunnel connected
  URL          https://my-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-app

Your MCP endpoint is https://my-app.hrzn.run/mcp. Keep this terminal open. Horizon sets the Host header to localhost:3000, so the SDK's default host check accepts the request.

Connect a client

Run the Inspector against the remote URL with the http transport.

npx @modelcontextprotocol/inspector --server-url https://my-app.hrzn.run/mcp --transport http

The command prints a URL with a per-launch token and opens it in your browser.

Check it works

In the Inspector, connect and list the tools. You should see add-note. Call it with text set to hello. The result contains:

Output
Saved: hello

Your Horizon terminal prints one line per call:

Output
  POST    200  /mcp

Troubleshooting

The client can't connect

  • Check that the Horizon terminal is still running.
  • Check that the URL ends with /mcp, the path in app.all("/mcp", ...).
  • Check that npx tsx server.ts is still running on port 3000.

The server rejects requests from the tunnel

createMcpExpressApp validates Host and Origin against localhost values by default. Horizon sends Host: localhost:3000, so the default passes. If you pass allowedHosts, keep localhost in the list.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-app and the URL stays the same. -s needs a subdomain you reserved, see Pricing.

Next steps

On this page