Portcall
README.md
# Portcall
A small plugin gateway that serves local MCP servers over HTTP.
The name is a nautical pun: *port call* — `port` (harbour / network port) + `call`
(a ship's stop / a request).
## What it is
Portcall listens on one HTTP port and mounts one or more MCP servers at separate
paths:
```
/vault/mcp → mcpvault (Obsidian vault)
/healthz → liveness + mount list
```
Each mount is an independent MCP endpoint. Clients register them separately —
there is no tool aggregation, so no name collisions and no namespacing scheme to
maintain.
Exposing the port beyond localhost is deliberately out of scope. Put a tunnel,
reverse proxy, or nothing at all in front of it; Portcall binds to `127.0.0.1`
by default and does not care what is upstream.
## Why not a stdio bridge
The obvious way to put a stdio MCP server on HTTP is a generic bridge such as
`supergateway`. That works, but it has a structural problem: every request or
session spawns a child process, and reaping those children is easy to get wrong.
In `supergateway` specifically, the child is only reclaimed from
`transport.onclose` or `transport.onerror`. Nothing calls `transport.close()` on
a normally-completed stateless request, so `onclose` never fires and **every
successful request leaks a process** — only failed requests get cleaned up. Its
stateful mode does not leak (a session timer closes the transport), but it holds
a long-lived `GET` SSE stream instead, which some proxies handle badly.
Wrapping the command in `npx` makes it worse: `npx` forks the real server, so
killing the child kills the wrapper and orphans the grandchild.
Portcall's answer is to not spawn anything when it does not have to.
## Adapters
| Adapter | For | How |
|---|---|---|
| `inProcess` | Servers that export a factory as a library | Calls the factory in-process. No child process exists, so there is nothing to reap. |
| `stdio` | Third-party servers that only speak stdio | Not implemented yet. When it lands it must reclaim the child on normal completion, not just on error, and handle process-group kills for wrapper commands. |
`inProcess` is the interesting case and covers the servers worth self-hosting.
`@bitbonsai/mcpvault`, for example, exports `createServer(vaultPath, options)`
returning an MCP SDK v2 `Server`; its `bin` entry is essentially
`serveStdio(() => createServer(...))`. Portcall calls the same function directly
and skips stdio entirely.
The SDK builds a fresh server instance per request and disposes it with the
request, so there is no session state to time out and no accumulating handles.
## Protocol versions
Portcall is built on `@modelcontextprotocol/server` v2, which serves two
protocol eras from a single handler:
- **Modern (`2026-07-28`)** — per-request envelope. Requests carry
`MCP-Protocol-Version`, `Mcp-Method`, and (for tool calls) `Mcp-Name` headers
plus a `params._meta` block. There is no `initialize` handshake and no
long-lived session; discovery is `server/discover`.
- **Legacy (2025-era)** — served statelessly by default. `GET` and `DELETE`
(2025 session operations) answer `405`. Set `PORTCALL_MODERN_ONLY=true` to
reject legacy traffic outright.
Because the modern era is per-request, there is no standing SSE stream to keep
open. That sidesteps a class of proxy problem: some reverse proxies withhold
response headers until the first body byte arrives, which stalls a
just-opened-but-silent SSE stream indefinitely. For the streams that do occur,
`PORTCALL_KEEPALIVE_MS` controls the SSE comment-frame interval; lower it if a
proxy in front is buffering.
## Configuration
All host-specific values come from the environment.
| Variable | Default | Meaning |
|---|---|---|
| `PORTCALL_VAULT_PATH` | *(required)* | Absolute path to the Obsidian vault to serve |
| `PORTCALL_PORT` | `7100` | TCP port |
| `PORTCALL_HOST` | `127.0.0.1` | Bind interface |
| `PORTCALL_TOKEN` | *(unset)* | Static bearer token. Unset means no authentication |
| `PORTCALL_ALIAS_ROOT_MCP` | *(unset)* | Also mount the named plugin at `/mcp` |
| `PORTCALL_PATH_PREFIX` | *(unset)* | Serve every mount under `/<prefix>/…` |
| `PORTCALL_KEEPALIVE_MS` | `15000` | SSE keepalive interval; `0` disables |
| `PORTCALL_MODERN_ONLY` | `false` | Reject 2025-era requests instead of serving them |
`PORTCALL_TOKEN` gates every mount with `Authorization: Bearer <token>`. Note
that some MCP clients — Claude's custom connector UI among them — offer no way
to set a request header, so for those the token has to be enforced upstream
instead (or left off, with access controlled at the network layer).
`PORTCALL_PATH_PREFIX` is the fallback for exactly those clients: it moves every
mount under a segment you choose, so `/vault/mcp` becomes `/<prefix>/vault/mcp`
and the URL itself carries the secret. Two things follow from that, and the
server enforces both:
- `404` responses say only `not_found`. They never list what is mounted.
- The mount listing moves out of the public `/healthz` and into
`/<prefix>/healthz`. The bare `/healthz` still answers, so liveness probes
keep working, but it discloses no paths.
Treat a path prefix as weaker than a header. URLs reach proxy access logs,
crash reports, and anything that records a destination, and a leaked one grants
the same access a leaked token would. It raises the bar — it is not
authentication.
Which plugins are mounted, and where, is declared in `plugins.config.ts`.
## Running
Requires Node 24 (see `.nvmrc`).
```bash
npm install
npm run build
cp .env.example .env # then set PORTCALL_VAULT_PATH
npm start
```
Both `npm start` and `npm run dev` load `.env` if it is present and start
without it if it is not, so a daemon can inject the environment directly
instead. Variables already set in the environment are not overridden.
`npm run dev` runs the entry point through `tsx` with watch. A daemon should
run the built output, not `tsx`.
Check it is up:
```bash
curl -s localhost:7100/healthz
```
## Tests
```bash
npm test # builds, then runs unit and integration tests
npm run typecheck
```
No test dependencies: the runner is `node:test`, and `tsx` (already needed for
`npm run dev`) loads the TypeScript.
The integration tests are black-box. They spawn the built server against a
throwaway vault on an ephemeral port and drive it over real HTTP, so they
exercise the same artifact a daemon runs — routing, the `/mcp` alias, bearer
auth, and both protocol eras. The unit tests cover mount resolution and the
bearer check, where a silent regression would look like a dead client rather
than an error.
## Layout
```
src/
server.ts HTTP entry point, wiring, health, shutdown
routes.ts mount resolution and URL normalisation
auth.ts bearer token check
config.ts environment parsing
log.ts structured logging
types.ts the Plugin interface
adapters/
inProcess.ts library-factory adapter
plugins/
vault.ts mcpvault
plugins.config.ts which plugins mount at which paths
test/
integration.test.ts black-box tests against the built server
routes.test.ts mount resolution
auth.test.ts bearer token check
helpers.ts server harness and MCP request builders
```
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues