Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,44 @@ All notable changes to `@orcarouter/mcp` follow the
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format and this
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added

- **Streamable HTTP transport.** `--transport http` (or `MCP_TRANSPORT=http`)
serves the MCP
[Streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports)
transport on a single endpoint handling `POST`, `GET` and `DELETE`, instead
of stdio. stdio remains the default, so existing client configs are
unaffected.

Previously the only entry point was stdio, which a reverse proxy cannot sit
in front of — anyone wanting to host one shared instance had to fork the
package or run a third-party stdio-to-HTTP bridge as a subprocess. Since
`createOrcaRouterMcpServer` was already exported and transport-agnostic,
this wires it to the SDK's own `StreamableHTTPServerTransport`.

Defaults follow the transport spec's security guidance: binds to
`127.0.0.1` rather than all interfaces, and validates the `Host` and
`Origin` headers on every request (`403` otherwise) to prevent DNS
rebinding. `--allowed-hosts` / `--allowed-origins` widen the allowlist for
a hosted deployment; `--no-dns-rebinding-protection` disables the check for
deployments where a trusted proxy already performs it.

Sessions are on by default (`Mcp-Session-Id`, with `DELETE` termination and
cleanup on close); `--stateless` builds one server per request for replicas
behind a load balancer.

`MCP_HTTP_AUTH_TOKEN` optionally requires a shared secret as
`Authorization: Bearer` or `X-API-Key`, compared in constant time. Unset
means no check, which is only appropriate on loopback. OAuth 2.1, the
spec's answer for public endpoints, is not implemented.

`GET /healthz` reports liveness without authentication and without calling
the OrcaRouter API, so probes do not consume quota.

- `--help` output describing every flag and environment variable.

## v1.1.5

### Changed
Expand Down
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,11 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER node

# The same entry point serves either transport. For Streamable HTTP:
# docker run -e MCP_TRANSPORT=http -e HOST=0.0.0.0 -p 3000:3000 <image>
# HOST must be set: the server binds 127.0.0.1 by default, which is
# unreachable from outside the container.
EXPOSE 3000

ENTRYPOINT ["node", "/app/dist/index.js"]
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,20 +91,93 @@ required for `orcarouter_chat`; catalog tools work without it.

Full input schemas are exposed at runtime via the MCP `tools/list` method — your MCP client (Claude Desktop, Cursor, etc.) reads them automatically.

## Remote / HTTP transport

By default the server speaks MCP over **stdio**, which is what a client that
launches it as a subprocess expects. Pass `--transport http` to serve the
[Streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports)
transport instead, for when you want to host one instance and point several
clients at it.

```sh
# defaults: 127.0.0.1:3000, endpoint /mcp, sessions on
npx -y @orcarouter/mcp --transport http

# a hosted deployment behind a reverse proxy
MCP_TRANSPORT=http \
MCP_HTTP_AUTH_TOKEN=$(openssl rand -hex 32) \
npx -y @orcarouter/mcp --host 0.0.0.0 --port 3000 \
--allowed-hosts mcp.example.com
```

Point a client at it with the `http` server type:

```json
{
"mcpServers": {
"orcarouter": {
"type": "http",
"url": "http://127.0.0.1:3000/mcp"
}
}
}
```

| Flag | Environment | Default | Description |
| --- | --- | --- | --- |
| `--transport <stdio\|http>` | `MCP_TRANSPORT` | `stdio` | Transport to serve. |
| `--port <n>` | `PORT` | `3000` | Port to listen on. |
| `--host <addr>` | `HOST` | `127.0.0.1` | Interface to bind. |
| `--path <path>` | `MCP_HTTP_PATH` | `/mcp` | Endpoint path. |
| `--stateless` | `MCP_HTTP_STATELESS` | off | One server per request; no `Mcp-Session-Id`. |
| `--allowed-hosts <a,b>` | `MCP_HTTP_ALLOWED_HOSTS` | bound host | `Host` header allowlist. |
| `--allowed-origins <a,b>` | `MCP_HTTP_ALLOWED_ORIGINS` | bound host | `Origin` header allowlist. |
| `--no-dns-rebinding-protection` | — | off | Skip `Host`/`Origin` validation. |
| — | `MCP_HTTP_AUTH_TOKEN` | unset | Require this secret as `Authorization: Bearer` or `X-API-Key`. |

`GET /healthz` answers without authentication and makes no call to the
OrcaRouter API, so a liveness probe never spends your quota.

**Stateful vs stateless.** The default issues an `Mcp-Session-Id` and keeps a
session per client, which supports the `GET` SSE stream for server-initiated
messages. `--stateless` builds a fresh server per request and returns no
session id — every tool here is one round trip to a stateless API, so nothing
is lost, and it lets several replicas sit behind a load balancer with no
sticky routing.

### Security for HTTP deployments

The transport spec is explicit about what an HTTP MCP server owes its users,
and the defaults here follow it:

- **Binds to `127.0.0.1`**, not `0.0.0.0`. Exposing it is a deliberate act.
- **Validates `Host` and `Origin`** on every request, rejecting anything not
on the allowlist with `403`. This is what stops a page in someone's browser
from driving a local MCP server through DNS rebinding. Name your public
hostname with `--allowed-hosts` when you put it behind a proxy.
- **`MCP_HTTP_AUTH_TOKEN`** adds a shared-secret check. It is the small answer
for a server on a private network behind a proxy; the spec's full answer for
a public endpoint is OAuth 2.1, which this server does not implement — put
it behind something that does.

## Configuration

| Name | Required | Description |
| --------------------------- | -------- | -------------------------------------------------------- |
| `ORCAROUTER_API_KEY` | optional | OrcaRouter API key. Required only for `orcarouter_chat`. |
| `ORCAROUTER_BASE_URL` | optional | API base URL. Defaults to `https://api.orcarouter.ai`. |
| `ORCAROUTER_REQUEST_TIMEOUT`| optional | Per-request HTTP timeout in **seconds**. Defaults to `300`. |
| `MCP_TRANSPORT` | optional | `stdio` (default) or `http`. See [Remote / HTTP transport](#remote--http-transport). |

## Security

API keys are read from environment variables, never logged, and only
sent to the OrcaRouter API. See [SECURITY.md](SECURITY.md) for the
vulnerability disclosure policy.

Running over HTTP adds a network surface; see
[Security for HTTP deployments](#security-for-http-deployments).

## Development

```sh
Expand Down
8 changes: 8 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ client.
| Claude Code | [claude-code.json](claude-code.json) | `~/.claude.json` | Merge under top-level `mcpServers` key (Claude Code stores other settings here too) |
| Cursor | [cursor.json](cursor.json) | `~/.cursor/mcp.json` | Replace contents |
| Windsurf | [windsurf.json](windsurf.json) | `~/.codeium/windsurf/mcp_config.json` | Replace contents |
| Any HTTP client | [http.json](http.json) | your client's MCP config | Use when the server is already running with `--transport http` |

The `http.json` example assumes a server started separately with
`npx -y @orcarouter/mcp --transport http`. Unlike the stdio examples the
client does not launch the server, so `ORCAROUTER_API_KEY` belongs in the
server's environment rather than the client config. If that server is
reachable over a network, give it `MCP_HTTP_AUTH_TOKEN` and send the same
value from the client as an `Authorization: Bearer` header.

For Zed and other MCP clients, consult your client's documentation for
the exact config schema and merge the `orcarouter` entry under whatever
Expand Down
8 changes: 8 additions & 0 deletions examples/http.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"orcarouter": {
"type": "http",
"url": "http://127.0.0.1:3000/mcp"
}
}
}
170 changes: 170 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import {
DEFAULT_HTTP_HOST,
DEFAULT_HTTP_PORT,
DEFAULT_MCP_PATH,
} from "./http.js";

export const USAGE = `orcarouter-mcp — Official MCP server for OrcaRouter

Usage: orcarouter-mcp [options]

Transport:
--transport <stdio|http> Transport to serve (default: stdio; env MCP_TRANSPORT)

HTTP options (ignored for stdio):
--port <number> Port to listen on (default: ${DEFAULT_HTTP_PORT}; env PORT)
--host <address> Interface to bind (default: ${DEFAULT_HTTP_HOST}; env HOST)
--path <path> Endpoint path (default: ${DEFAULT_MCP_PATH}; env MCP_HTTP_PATH)
--stateless One server per request; no Mcp-Session-Id (env MCP_HTTP_STATELESS=1)
--allowed-hosts <a,b> Host header allowlist (env MCP_HTTP_ALLOWED_HOSTS)
--allowed-origins <a,b> Origin header allowlist (env MCP_HTTP_ALLOWED_ORIGINS)
--no-dns-rebinding-protection
Skip Host/Origin validation; only when a trusted proxy does it
-h, --help Show this message

Environment:
ORCAROUTER_API_KEY OrcaRouter API key (catalog tools work without it)
ORCAROUTER_BASE_URL Override the API base URL
ORCAROUTER_REQUEST_TIMEOUT Request timeout in seconds
MCP_HTTP_AUTH_TOKEN If set, HTTP requests must present it as
\`Authorization: Bearer <token>\` or \`X-API-Key\`
`;

export interface ParsedArgs {
transport: "stdio" | "http";
help: boolean;
port?: number;
host?: string;
path?: string;
stateless?: boolean;
allowedHosts?: string[];
allowedOrigins?: string[];
disableDnsRebindingProtection?: boolean;
}

function envFlag(name: string): boolean | undefined {
const raw = process.env[name]?.trim().toLowerCase();
if (raw === undefined || raw === "") return undefined;
return raw === "1" || raw === "true" || raw === "yes";
}

function envList(name: string): string[] | undefined {
const raw = process.env[name]?.trim();
if (!raw) return undefined;
const items = raw.split(",").map((s) => s.trim()).filter(Boolean);
return items.length > 0 ? items : undefined;
}

function envPort(): number | undefined {
const raw = process.env.PORT?.trim();
if (!raw) return undefined;
const parsed = Number(raw);
return Number.isInteger(parsed) && parsed > 0 && parsed < 65536 ? parsed : undefined;
}

/**
* Flags win over environment, environment wins over defaults. Anything unrecognised is an error
* rather than a silent no-op: a typo in a launcher config should fail loudly at start, not turn
* into a server quietly listening somewhere unintended.
*/
export function parseArgs(argv: string[]): ParsedArgs {
const parsed: ParsedArgs = {
transport: (process.env.MCP_TRANSPORT?.trim().toLowerCase() as ParsedArgs["transport"]) || "stdio",
help: false,
port: envPort(),
host: process.env.HOST?.trim() || undefined,
path: process.env.MCP_HTTP_PATH?.trim() || undefined,
stateless: envFlag("MCP_HTTP_STATELESS"),
allowedHosts: envList("MCP_HTTP_ALLOWED_HOSTS"),
allowedOrigins: envList("MCP_HTTP_ALLOWED_ORIGINS"),
};

const next = (i: number, flag: string): string => {
const value = argv[i + 1];
if (value === undefined || value.startsWith("-")) {
throw new Error(`${flag} requires a value`);
}
return value;
};

for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
switch (arg) {
case "-h":
case "--help":
parsed.help = true;
break;
case "--transport": {
const value = next(i, arg).toLowerCase();
if (value !== "stdio" && value !== "http") {
throw new Error(`Unknown transport "${value}"; expected "stdio" or "http"`);
}
parsed.transport = value;
i += 1;
break;
}
case "--port": {
const value = Number(next(i, arg));
if (!Number.isInteger(value) || value <= 0 || value >= 65536) {
throw new Error(`--port must be an integer between 1 and 65535`);
}
parsed.port = value;
i += 1;
break;
}
case "--host":
parsed.host = next(i, arg);
i += 1;
break;
case "--path": {
const value = next(i, arg);
if (!value.startsWith("/")) throw new Error(`--path must start with "/"`);
parsed.path = value;
i += 1;
break;
}
case "--stateless":
parsed.stateless = true;
break;
case "--allowed-hosts":
parsed.allowedHosts = next(i, arg).split(",").map((s) => s.trim()).filter(Boolean);
i += 1;
break;
case "--allowed-origins":
parsed.allowedOrigins = next(i, arg).split(",").map((s) => s.trim()).filter(Boolean);
i += 1;
break;
case "--no-dns-rebinding-protection":
parsed.disableDnsRebindingProtection = true;
break;
default:
throw new Error(`Unknown argument "${arg}"`);
}
}

if (parsed.transport !== "stdio" && parsed.transport !== "http") {
throw new Error(`Unknown transport "${parsed.transport}"; expected "stdio" or "http"`);
}
return parsed;
}

export function serverOptionsFromEnv(): {
apiKey?: string;
baseUrl?: string;
timeoutMs?: number;
} {
const timeoutRaw = process.env.ORCAROUTER_REQUEST_TIMEOUT?.trim();
let timeoutMs: number | undefined;
if (timeoutRaw) {
const parsedSeconds = Number(timeoutRaw);
if (Number.isFinite(parsedSeconds) && parsedSeconds > 0) {
timeoutMs = parsedSeconds * 1000;
}
}
return {
apiKey: process.env.ORCAROUTER_API_KEY?.trim() || undefined,
baseUrl: process.env.ORCAROUTER_BASE_URL?.trim() || undefined,
timeoutMs,
};
}

Loading