Reference: the device bridge (Node, no dependencies)

The complete source of the process that runs on your own network, beside the instrument — published here so an integrator outside the repo can copy it, read every line before running it, and port it if Node is not what your lab runs. It is the missing half of Connecting an instrument: that page explains what the bridge is for, this one is the thing itself.

Two files, no dependencies, no build step. Node 22+ runs the TypeScript directly:

node --experimental-strip-types index.ts

Who runs what

runs where operated by
your MCP driver the lab network, on a private address you
the bridge (below) the lab network, beside the driver you
the relay relay.pouchy.ai Pouchy

You do not deploy a relay. Pairing a device in the Pouchy dashboard registers it on the one Pouchy operates; the bridge is the only piece you run — and the only token you carry is the device token below, because the ingress token is stored server-side at pairing time and never leaves it.

What it opens

Nothing. The bridge dials out to the relay and polls; no inbound connection is ever accepted and no port is opened on your network. Your driver stays on localhost or a private address and the relay never learns where it is.

Configuration

DEVICE_BRIDGE_RELAY_URL=https://relay.pouchy.ai
DEVICE_BRIDGE_DEVICE_ID=bench-01      # from the dashboard
DEVICE_BRIDGE_TOKEN=…                 # the DEVICE token, not the ingress token
DEVICE_BRIDGE_DRIVER_URL=http://localhost:8930/mcp

The driver URL is set here, at the lab, and is never accepted from Pouchy. That is what keeps this design from creating a server-side request forgery vector: nothing upstream can point this process at an address of its choosing. If you port this file, keep that property — it is the one that matters most.

Of the two tokens the dashboard shows once, only the device token belongs here. The ingress token stays with Pouchy — it is written straight into the project vault when you pair — and a bridge that had it would be holding a credential it cannot use.

Two properties this process owns

Both are also enforced at the relay. That is deliberate rather than redundant: the relay can be restarted, replaced, or run by someone else, and a rule about your actuator that lives only upstream of it is a rule that can be deployed away.

  • One tool call in flight, refused rather than queued (-32002, HTTP 409). Reads are not serialised, so discovery never waits behind a moving stage.
  • It never invents a response. A driver timeout comes back as a JSON-RPC error (-32001), never as a synthesised success and never as silence. This is load-bearing: Pouchy sends an approved write at most once and will not re-send, so an answer this process makes up is an answer nothing will correct.

MCP headers

The bridge forwards Mcp-Session-Id, MCP-Protocol-Version, Accept and Last-Event-ID to your driver, and returns Mcp-Session-Id, MCP-Protocol-Version and Content-Type — allowlists, in both directions, never a passthrough.

A session-based driver does not work without this, which is worth stating plainly because it was once missing: an earlier build forwarded bodies only, and every end-to-end test passed against a fixture that happened to be stateless and lenient. If you port this file, port the allowlists. And note what is deliberately absent from them: authorization never reaches your driver, so the token authenticating Pouchy to the relay is not a credential your lab ends up holding.

bridge.ts

// Runs next to the instrument, on the lab's own network. Dials OUT only.
//
// It is a local MCP client of the driver and a polling client of the relay.
// The driver stays on `localhost` or a private address and is never exposed;
// the relay never learns where it is. That is the whole security shape: the
// only host Pouchy ever resolves is the relay's public name, so
// `assertPublicHost` passes on its own terms rather than needing an exception.
//
// Two properties live HERE because only this process can hold them.

/** The driver is on this machine's own network, so a hung call is bounded by
 *  us rather than by anything upstream. Under the relay's own deadline. */
export const DRIVER_TIMEOUT_MS = 60_000;

export interface BridgeConfig {
	readonly relayUrl: string;
	readonly deviceId: string;
	readonly deviceToken: string;
	/** The MHS driver's local MCP endpoint — the ONE place a private address is
	 *  legal in this whole design, and legal precisely because the call is made
	 *  from inside the lab rather than by Pouchy. */
	readonly driverUrl: string;
}

export interface Envelope {
	readonly id: string;
	readonly body: string;
	/** Allowlisted MCP headers the caller sent. Absent on an older relay, which
	 *  is why every read of it tolerates undefined rather than assuming the two
	 *  processes deploy together — they do not: one is in a lab. */
	readonly headers?: Record<string, string>;
}

export type Fetch = (url: string, init?: Record<string, unknown>) => Promise<{
	status: number;
	text: () => Promise<string>;
	headers?: { get(name: string): string | null };
}>;

/** MCP carries protocol state in headers, so a bridge that forwards only the
 *  body silently breaks every session-based driver: the server issues
 *  `Mcp-Session-Id` on `initialize` and requires it afterwards, and a client
 *  that never sends `Accept: application/json, text/event-stream` is refused
 *  by conformant servers before any tool is reached.
 *
 *  Allowlists, not passthrough, and deliberately the same names the relay
 *  uses — pinned equal by test rather than shared by import, because these two
 *  packages deploy independently and a shared constant would imply otherwise.
 *  `authorization` is absent on purpose: the relay's ingress token authorises
 *  Pouchy to the RELAY, and handing it to the driver would widen it into a
 *  credential the lab holds. */
export const BRIDGE_REQUEST_HEADERS = [
	'mcp-session-id',
	'mcp-protocol-version',
	'accept',
	'last-event-id'
] as const;

export const BRIDGE_RESPONSE_HEADERS = [
	'mcp-session-id',
	'mcp-protocol-version',
	'content-type'
] as const;

export const MAX_HEADER_VALUE_CHARS = 512;

export function pickHeaders(
	src: Record<string, unknown> | undefined,
	allow: ReadonlyArray<string>
): Record<string, string> {
	const out: Record<string, string> = {};
	if (!src) return out;
	for (const name of allow) {
		const v = src[name];
		if (typeof v !== 'string') continue;
		const trimmed = v.trim();
		if (!trimmed) continue;
		out[name] = trimmed.slice(0, MAX_HEADER_VALUE_CHARS);
	}
	return out;
}

/** Is this envelope a tool call?
 *
 *  The relay serialises these too, but the bridge holds the property
 *  independently: the relay can be restarted, replaced, or run by someone
 *  else, and a rule that only exists upstream of the actuator is a rule that
 *  can be deployed away. */
export function isToolCall(body: string): boolean {
	try {
		return (JSON.parse(body) as { method?: unknown }).method === 'tools/call';
	} catch {
		return true;
	}
}

export class Bridge {
	/** Depth ONE, and a refusal rather than a queue. A second command that
	 *  arrives while a stage is moving was composed against a world-state that
	 *  no longer holds; queueing it hides that, and the caller finds out by
	 *  watching the instrument. */
	private busy = false;
	private stopped = false;
	private readonly cfg: BridgeConfig;
	private readonly fetchImpl: Fetch;

	// Explicit fields, not parameter properties — see the note in
	// `device-relay/src/relay.ts`: the entrypoint runs under
	// `--experimental-strip-types`, which cannot desugar them.
	constructor(cfg: BridgeConfig, fetchImpl: Fetch) {
		this.cfg = cfg;
		this.fetchImpl = fetchImpl;
	}

	stop(): void {
		this.stopped = true;
	}

	private authHeaders(): Record<string, string> {
		return { authorization: `Bearer ${this.cfg.deviceToken}`, 'content-type': 'application/json' };
	}

	/** Forward one envelope to the local driver and return what it said.
	 *
	 *  NEVER INVENTS A RESPONSE. A driver timeout comes back as an error, not
	 *  as a synthesized success and not as silence — the caller's at-most-once
	 *  rule means nothing will be re-sent, so an answer this process makes up
	 *  is an answer nobody can correct. */
	async forward(
		body: string,
		headers?: Record<string, string>
	): Promise<{ status: number; body: string; headers: Record<string, string> }> {
		const ctrl = new AbortController();
		const timer = setTimeout(() => ctrl.abort(), DRIVER_TIMEOUT_MS);
		try {
			const res = await this.fetchImpl(this.cfg.driverUrl, {
				method: 'POST',
				headers: {
					'content-type': 'application/json',
					...pickHeaders(headers, BRIDGE_REQUEST_HEADERS)
				},
				body,
				signal: ctrl.signal
			});
			const back: Record<string, string> = {};
			for (const name of BRIDGE_RESPONSE_HEADERS) {
				const v = res.headers?.get(name);
				if (typeof v === 'string' && v.trim()) back[name] = v.trim().slice(0, MAX_HEADER_VALUE_CHARS);
			}
			return { status: res.status, body: await res.text(), headers: back };
		} catch (e) {
			return {
				status: 504,
				headers: {},
				body: JSON.stringify({
					jsonrpc: '2.0',
					id: null,
					error: {
						code: -32001,
						message: `driver did not answer: ${e instanceof Error ? e.message : String(e)}`
					}
				})
			};
		} finally {
			clearTimeout(timer);
		}
	}

	/** One poll → execute → reply cycle. Returns false when there was nothing
	 *  to do, so the caller can decide how to pace itself. */
	async tick(): Promise<boolean> {
		if (this.stopped) return false;
		const base = this.cfg.relayUrl.replace(/\/$/, '');
		const poll = await this.fetchImpl(`${base}/d/${this.cfg.deviceId}/next`, {
			method: 'GET',
			headers: this.authHeaders()
		});
		if (poll.status !== 200) return false;
		let env: Envelope;
		try {
			env = JSON.parse(await poll.text()) as Envelope;
		} catch {
			return false;
		}
		if (!env?.id || typeof env.body !== 'string') return false;

		const exclusive = isToolCall(env.body);
		if (exclusive && this.busy) {
			// Refuse in the device's own voice. The relay refuses this too, but
			// a bridge that trusts an upstream guard for a property about ITS
			// actuator is trusting the wrong process.
			await this.reply(env.id, 409, {
				jsonrpc: '2.0',
				id: null,
				error: { code: -32002, message: 'device busy: a tool call is already in flight' }
			});
			return true;
		}
		if (exclusive) this.busy = true;
		try {
			const out = await this.forward(env.body, env.headers);
			await this.replyRaw(env.id, out.status, out.body, out.headers);
		} finally {
			if (exclusive) this.busy = false;
		}
		return true;
	}

	private reply(id: string, status: number, body: unknown): Promise<unknown> {
		return this.replyRaw(id, status, JSON.stringify(body));
	}

	private replyRaw(
		id: string,
		status: number,
		body: string,
		headers?: Record<string, string>
	): Promise<unknown> {
		const base = this.cfg.relayUrl.replace(/\/$/, '');
		return this.fetchImpl(`${base}/d/${this.cfg.deviceId}/reply`, {
			method: 'POST',
			headers: this.authHeaders(),
			body: JSON.stringify({ id, status, body, ...(headers ? { headers } : {}) })
		}).catch(() => undefined);
	}

	/** Run until stopped. A failed poll backs off briefly rather than spinning
	 *  — a relay that is down must not become a load generator. */
	async run(sleep: (ms: number) => Promise<void>): Promise<void> {
		while (!this.stopped) {
			try {
				const did = await this.tick();
				if (!did) await sleep(200);
			} catch {
				await sleep(2000);
			}
		}
	}
}

index.ts

// Entrypoint. Runs on the lab network, beside the instrument.
//
//   DEVICE_BRIDGE_RELAY_URL    https://relay.pouchy.ai
//   DEVICE_BRIDGE_DEVICE_ID
//   DEVICE_BRIDGE_TOKEN
//   DEVICE_BRIDGE_DRIVER_URL   http://localhost:8930/mcp
//
// The driver URL is configured HERE, at the lab, and is never accepted from
// Pouchy. That is what keeps this design from creating an SSRF vector: nothing
// upstream can point this process at an address of its choosing.

import { Bridge, type BridgeConfig } from './bridge.ts';

const cfg: BridgeConfig = {
	relayUrl: process.env.DEVICE_BRIDGE_RELAY_URL ?? '',
	deviceId: process.env.DEVICE_BRIDGE_DEVICE_ID ?? '',
	deviceToken: process.env.DEVICE_BRIDGE_TOKEN ?? '',
	driverUrl: process.env.DEVICE_BRIDGE_DRIVER_URL ?? ''
};

const missing = Object.entries(cfg)
	.filter(([, v]) => !v)
	.map(([k]) => k);
if (missing.length) {
	console.error(`[device-bridge] missing config: ${missing.join(', ')}`);
	process.exit(1);
}

const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const bridge = new Bridge(cfg, globalThis.fetch as never);
console.log(`[device-bridge] ${cfg.deviceId} → ${cfg.relayUrl} (driver ${cfg.driverUrl})`);
for (const sig of ['SIGINT', 'SIGTERM'] as const) {
	process.on(sig, () => {
		bridge.stop();
		process.exit(0);
	});
}
void bridge.run(sleep);

Running it against a fixture first

Before you point this at a real actuator, point it at something that cannot move. Any MCP server will do; what you are testing is your own network path and your tokens, not the instrument.

  1. Start your driver (or any MCP server) on localhost.
  2. Pair a device in the Pouchy dashboard under Hardware, picking the agent that should be able to drive it. Copy the device token — that is the only one you need. The ingress token is stored for you, server-side, and never has to leave it.
  3. Run the bridge with that device token and your driver's local URL.
  4. Back in the dashboard, press Finish the install if the panel is still waiting. It usually is, for one of two ordinary reasons: the relay polls for new devices every 30 seconds, and until step 3 there was no bridge for it to reach.
  5. Run the Device-driver probe (Ops Dashboard → Tools) against the same endpoint. It reports the negotiated protocol version, the tool list, the resource URIs, and the result of executing one read tool you name explicitly — it never picks a tool itself.

A correct tunnel returns what a direct connection returns. That is the whole design goal: the client cannot tell the difference.